diff --git a/CLAUDE.md b/CLAUDE.md index b616a33981..1681e05242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1592 +**Current Version:** 0.5.1593 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 480657380e..2fa3c95b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1592" +version = "0.5.1593" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1592" +version = "0.5.1593" [[package]] name = "perry-parser" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1592" +version = "0.5.1593" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1592" +version = "0.5.1593" [[package]] name = "perry-ui-tvos" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1592" +version = "0.5.1593" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b4f5891706..06fe77db59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1592" +version = "0.5.1593" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10539-fs-read-error-shapes.md b/changelog.d/10539-fs-read-error-shapes.md new file mode 100644 index 0000000000..9d7a9fe38b --- /dev/null +++ b/changelog.d/10539-fs-read-error-shapes.md @@ -0,0 +1,58 @@ +**Buffer-mode `fs` reads throw on failure, and read-stream errors carry Node's +`code`/`errno`/`syscall`/`path`** (#10452, #10451). + +`fs.readFileSync(path)`, `readFileSync(path, {})`, `fs.promises.readFile(path)` +and `import { readFile } from "node:fs/promises"` returned or resolved +`null`/`undefined` for a missing file. `try { cfg = readFileSync(optional) } +catch { defaults }` took the wrong branch, and `.catch(e => e.code === "ENOENT")` +never ran. The `'utf8'` and callback forms did report the error. + +Every `readFile` form reads through `read_file_bytes_with_options`, which folded +each failure into `None`. The Buffer entry points +(`js_fs_read_file_binary{,_options}`) turned that into a null `BufferHeader`, so +the value was `null` or `undefined` depending on the call shape. The string +entry point re-read the file to get an `io::Error` back and always reported it +as `open`. The reader now returns the OS error and the syscall that failed, and +every form reports it the way Node does: ENOENT or EACCES as `open ''`, +and a directory as `EISDIR: illegal operation on a directory, read` with no +path. Before, a directory read returned a Buffer or reported `open`. The +callback form now reads once. Its old `stat` pre-check missed directories, so a +directory gave `(null, undefined)` in Buffer mode and a synchronous throw in +string mode. `FileHandle.readFile()` now rejects when the read fails. + +`fs.createReadStream` open and read failures emitted a plain `Error` with only +the Rust message (`No such file or directory (os error 2)`) and no own +properties. The write side already stored a Node-shaped `error_value` (#9493). +The read side now does too. The constructor's open failure is held as the OS +error until the stream state is registered (the registry is the GC root), then +turned into the error value. The pump's read failures are stored the same way. +`fs.writeFile(dest, failingReadStream)` also rejects with that error now. The +stream error helpers moved to `fs/stream/stream_errors.rs` so `stream.rs` stays +under the 2000-line cap. + +fs error messages used Rust's `Display` text. They now use libuv's description +of the errno (`ENOENT: no such file or directory, open ''`), as Node +does, for every error built by `build_fs_error_value*`. + +Validation: `test_gap_10452_fs_read_error_shapes` covers sync, callback and +promise reads (with and without an encoding), `FileHandle.readFile`, and read +and write streams, for a missing file and a directory, plus successful-read +controls. It matches Node byte for byte and fails on the parent commit. EACCES +was checked by hand as an unprivileged user. New `fs::errors` unit tests cover +the libuv text and the open-vs-read failure shape. User-space instructions for +50k `readFileSync` calls on a small file: Buffer form −0.6 %, UTF-8 form −9.5 % +(it no longer decodes the path twice per call). + +Consuming a failed read stream through `fs.promises.writeFile` reported the +missing fd (`EBADF: bad file descriptor, read`) rather than the failure the +constructor stored; the consumer now returns the stored Node-shaped value +before it tries to read. + +On Windows `io::Error::raw_os_error()` is a Win32 error code, not an errno, so +keying `code`/`errno`/message on it reported an `errno` of -2 where Node reports +libuv's -4058, with Rust's message text. `win32_error_to_uv` ports libuv's +`uv_translate_sys_error` (`src/win/error.c`, v1.52.1) for the filesystem arms and +`UV_WINDOWS_ERRNOS` holds libuv's Windows error numbers and messages; +`io_error_code`/`io_error_errno` consult them under `cfg(windows)`. The mapping +is pure and stays compiled under `cfg(test)`, so its unit tests run on every +host — Windows itself was not run. diff --git a/changelog.d/10547-function-constructor-paths.md b/changelog.d/10547-function-constructor-paths.md new file mode 100644 index 0000000000..1410f7c7d5 --- /dev/null +++ b/changelog.d/10547-function-constructor-paths.md @@ -0,0 +1,53 @@ +Every way of reaching the `Function` constructor now builds the function the +same way `new Function(...)` does, and an auto-optimized binary keeps the +runtime interpreter whenever the program can reach the constructor. + +- #10422: `Function(p, body)`, `Function.apply(null, [...])` and + `Function.call(null, ...)` with a body built at runtime compiled to a + function that always threw "new Function() cannot run in an ahead-of-time + compiled binary". `check_eval_function_call` built that stub for the call + form while the `new` form already fell through to the #6559 interpreter. + An unfolded call now lowers to the same `js_function_ctor_from_strings` + construct (the direct call), or calls the `Function` value (`.apply`, + `.call`, spread arguments). This is the generate-function 2.3.1 `toFunction` + shape that every mysql2 3.23.2 row parser uses. +- #10423: the global `Function` value carried the shared no-op thunk, so + `const F = Function; F(p, body)`, lodash's `var Function = context.Function`, + `Function.bind(...)` and `module.exports = Function` all returned + `undefined`. It now has a rest-argument call thunk that runs the + constructor. `fn.constructor(p, body)` returned the dispatcher's empty-object + stub because a function receiver never resolved its inherited + `constructor`; it now calls the value the `fn.constructor` read returns. +- #10424: `js_function_ctor_from_strings` read each argument as a string and + turned anything else into `""`, so `new Function(['a', 'b'], body)` (lodash + `_.template`'s import names) lost its parameters. Arguments now go through + ToString, left to right (rooted first, since `toString` is user code), and a + Symbol throws TypeError. `new Function(...parts)` passed the spread array as + one argument; it now lowers to `NewDynamicSpread` on the `Function` value. + The interpreter also binds a rest parameter (`new Function('...xs', body)`), + which it used to refuse. +- #10421: the auto-optimize build linked `dyn-eval` only for recorded + runtime-unknown sites. A known-codegen-library site (find-my-way, ajv, + fast-json-stringify), an unfoldable constant call, and every value route to + the constructor compiled against a runtime without the interpreter and threw + at the first call, while `PERRY_NO_AUTO_OPTIMIZE=1` builds worked. Lowering + now notes each runtime construction it emits, and a per-module AST pre-scan + (`pre_scan/function_ctor_reach.rs`) notes value uses: the `Function` + identifier outside `Function.prototype` / `typeof` / `instanceof` / + equality, a property named `Function`, `globalThis[key]` with a computed key, + and `.constructor` of a function or any `x.constructor(...)` call. + +Reflective construction of the intrinsic now routes to the from-strings entry +before `js_new_function_construct` allocates an instance, since its argument +buffer is not a GC root. + +The integration test `function_apply_dynamic_args_eval_surface` asserted the +always-throwing stub; it now asserts the mysql2 shape builds a working +function. + +Validation: gap tests `test_gap_10421_function_ctor_as_value`, +`test_gap_10422_function_call_runtime_body` and +`test_gap_10424_function_ctor_to_string_args` fail on v0.5.1589 and match Node +26.5.1 in both no-auto and auto-optimize builds. Each #10421 shape compiled +alone under auto-optimize prints Node's result; programs that never reach the +constructor keep the interpreter out (hello-world size +4 KB). diff --git a/changelog.d/10548-export-default-fn-identity.md b/changelog.d/10548-export-default-fn-identity.md new file mode 100644 index 0000000000..384ae7c076 --- /dev/null +++ b/changelog.d/10548-export-default-fn-identity.md @@ -0,0 +1,44 @@ +### Fixed + +- **`function F(){}; export default F;` exports `F` itself (#10434).** Importers + used to get a second function object for the default export: prototype + methods and statics assigned on `F` were missing (`new F().m` undefined), + `F === imported` was false, and calling the import through a value + (`const g = f; g()`, `.call`, `.apply`, `new`) passed garbage for missing + arguments and skipped default/rest parameter handling. It blocked axios 1.19.0 + (`AxiosURLSearchParams.prototype.append`), uuid 14 (`v5.DNS`), lodash-es + (`MapCache.prototype.clear`) and long 5.3 (`Long.fromInt`). + + Root cause: the `ExportDefaultExpr` arm in `perry-hir`'s `module_decl.rs` + recorded a `FuncRef` default export as `Export::Named { local: "default" }`. + The CLI driver only maps a renamed declared-function export back to its local + name when the row names that local, so importers resolved the `default` + closure wrapper instead of `F`'s. That wrapper forwards calls to `F`'s body + but is a separate closure with no expandos and no registered arity. + `export { F as default }` written after the declaration already wrote + `{ local: "F" }` and worked. + + Fix: when the exported expression is an identifier naming the function + (through parentheses and erased TypeScript wrappers), the row is + `{ local: "F", exported: "default" }`, the same as the alias form. + + A second, hoisting-order bug had the same symptoms. The export arms mark a + function `is_exported` only if its body is already lowered, so an export + clause that comes before its hoisted declaration (`export default F; + function F(){}` or `export { F as default }; function F(){}`) left the flag + unset, and the driver skipped the origin-name mapping. That made even the + alias form lose identity, but only in that ordering. After the whole module + is lowered, a function is now marked exported when an export row names it as + its local binding and `exported_functions` lists its id. A value alias + (`export const g = F`) names `g`, so `F` is left as it was. + + Validation: new `test_gap_10434_export_default_fn_identity` (identity across + two importers, a barrel, a namespace import and a dynamic import; prototype + methods; statics; `instanceof`; argument padding through a value; default and + rest parameters; export ahead of a hoisted declaration; cyclic imports; the + alias, `export default function`, class, arrow and function-expression forms + as controls) fails on the baseline and matches Node on the fix, plus four + `perry-hir` unit tests. Package probes that failed on the baseline now match + Node: axios 1.19.0's default ESM entry (a GET with params and a JSON POST + against a local server), uuid 14.0.1 (`v5.DNS`, `v3.URL`, `v1`/`v4`/`v7` + called through values) and lodash-es 4.18.1 (`get`, `memoize`, `set`). diff --git a/changelog.d/10550-entry-block-allocas.md b/changelog.d/10550-entry-block-allocas.md new file mode 100644 index 0000000000..99d4e9ab8f --- /dev/null +++ b/changelog.d/10550-entry-block-allocas.md @@ -0,0 +1,44 @@ +### Fixed + +- Loops no longer consume stack on every iteration when they call a + `Date.prototype.set*` setter, `Date.UTC`, `arr.concat`, `arr.splice`, + `arr.toSpliced`, `arr.unshift` or `Array.prototype.{push,unshift,splice,concat}.call` + (#10463). Such a loop died with SIGSEGV after about 2^19 iterations at the + default 8 MB stack (`d.setTime(i)` used 16 B per iteration, and the crash point + moved with `ulimit -s`). date-fns `addMinutes` in a loop crashed the same way, + because the cross-module inliner copies its `setTime` into the caller's loop. + + These lowerings emitted their argument buffer (`alloca [N x double]`) or + out-parameter (`alloca i64`) into whatever block was current. LLVM lowers an + `alloca` outside the entry block to a runtime stack-pointer bump that is only + released when the function returns. #167 added + `LlFunction::alloca_entry_array` for one family of call sites; these sibling + sites were never converted: `lower_date_setter` and `ArrayToSpliced` + (`expr/os_uri_dates.rs`), `Date.UTC` (`expr/misc_methods.rs`), the + `concat`/`unshift`/`splice` arms of `lower_array_method.rs`, + `Expr::ArraySplice` (`expr/instance_misc1.rs`) and the array-like `.call` + arms (`expr/logical_collections.rs`). Other sites had the same pattern: the + multi-target dynamic `import()`/`require` and i18n join slots + (`expr/dyn_extern_i18n.rs`), `new Worker` (`expr/worker_new.rs`), the V8 + interop argument buffers (`expr/v8_interop.rs`), the fused `push` length slot + (`lower_call/native/native_instance_branch.rs`) and the module namespace + populator (`codegen/helpers.rs`). All of them now allocate through + `alloca_entry` / `alloca_entry_array` / `lower_js_args_array`. Each buffer is + still filled completely right before its call. + + So the class cannot come back one call site at a time, the invariant is now + enforced where every function body is finalized: + `LlFunction::for_each_final_item` (read by both the textual and the native + backend) refuses any `alloca` outside the entry block, whether typed or raw + text, inside a multi-line raw payload, or after an inline invoke-EH label in + block 0. The panic message names the function, block and instruction + (`function/entry_allocas.rs`). + + Validation: the gap test `test_gap_10463_entry_block_allocas` crashes on the + baseline (every section crashes on its own at 8 MB) and matches Node with the + fix. `expr::entry_block_alloca_tests` compiles each construct inside a counted + loop and reads the IR back with its own scanner. `function::entry_allocas::tests` + sabotage-test the refusal. A `--no-link --trace llvm` sweep over all 1659 + `test-files/*.ts` found non-entry allocas in 61 files before the fix and 0 + after. Instruction counts are neutral to slightly lower (−0.08% to −0.36% on + date-setter, `concat`, `Array.prototype.*.call` and `splice` loops). diff --git a/changelog.d/10551-stream-module-constructor.md b/changelog.d/10551-stream-module-constructor.md new file mode 100644 index 0000000000..a492e4a766 --- /dev/null +++ b/changelog.d/10551-stream-module-constructor.md @@ -0,0 +1,37 @@ +`require('stream')` and `import Stream from "node:stream"` are now the legacy +`Stream` constructor, as in Node, instead of a separate namespace object +(#10430, #10431). Before, `x instanceof Stream` threw "Right-hand side of +'instanceof' is not callable" for both forms (node-fetch's `body instanceof +Stream`), `Stream !== NamedStream`, and nothing inherited from EventEmitter: +`require('stream').EventEmitter` was undefined, so redis's +`class ClientSideCacheProvider extends stream_1.EventEmitter` threw "Class +extends value is not a constructor" at module init. `new Stream()` also built an +empty placeholder with no `on`/`emit`, and `class X extends require('stream')` +instances had no EventEmitter methods. + +Root cause: `cjs_default_export_value` had no `stream` arm, so the CommonJS +module value fell back to the namespace object; the HIR lowered the default +import's value to the bare `NativeModuleRef("stream")` (only its `typeof` was +folded to "function"); and `attach_stream_legacy_prototype` never linked +`Stream`/`Stream.prototype` to EventEmitter or hung the exports on the +constructor. + +Fix: the CommonJS value and the default binding's value both resolve to the +named `Stream` export. The constructor carries every module export as an own +static (Node's own-key order, `Stream.Stream === Stream`), and gets Node's two +`ObjectSetPrototypeOf` edges (`Stream` → `EventEmitter`, `Stream.prototype` → +`EventEmitter.prototype`). `new Stream()` builds an instance of +`Stream.prototype`, and a dynamic `extends` of `Stream` gets the EventEmitter +parent edge and EventEmitter init on `super()`. The attach now roots the +constructor and prototype across its allocations. A namespace import stays the +namespace object, and member reads and calls on the default binding keep their +static lowering. + +Validation: `test_gap_10430_stream_module_constructor` differs from Node on +7661bc05fe and matches it here, in both no-auto and auto-optimize modes. HIR and +runtime unit tests were added. The full gap suite matches the snapshot, with the +same 6 known mismatches as the baseline. The stream + events node-suite is +868/872 on both baseline and fix, with zero per-test deltas. Stream data paths +are flat in `instructions:u` (+0.05% and +0.22%). `instanceof Stream` alone is ++0.56–0.71% (median of 7), within the ±1% band. Startup cost is +1.1 M +instructions, paid once when the constructor is first minted. diff --git a/changelog.d/10552-residual-prototype-relocation.md b/changelog.d/10552-residual-prototype-relocation.md new file mode 100644 index 0000000000..5cc6654e99 --- /dev/null +++ b/changelog.d/10552-residual-prototype-relocation.md @@ -0,0 +1,9 @@ +### Fixed + +- **A relocated non-object owner lost its explicit `[[Prototype]]` (#10493).** `Object.setPrototypeOf` on a receiver that is not meta-capable records the prototype in the residual address-keyed registry (#9304), and relocation owes that entry two things. Neither happened outside arrays and ordinary objects: `layout_transfer` reached the rekey only *below* its layout-kind early return, which a `GcLayoutSlotKind::None` cell never passes, and the recorded value was emitted as a child edge from the Array and Object arms alone. So a lazy JSON array, Map, Set, Error, Promise, Date, RegExp, Temporal cell or `dyn_eval` closure silently lost its prototype at its first relocation — correct before a collection, wrong after, exit code 0 and no warning — and the prototype value itself was neither retained nor rewritten. Fixing only the rekey is worse than fixing neither: it turns "prototype lost" into "entry names a stale address", a state measured between the two halves. + + Both obligations now follow the registry's population, stated once in `prototype_chain::residual_prototype_owner_type` (everything except strings, bigints, meta records and compiled regex programs) rather than being wired to two kinds by hand. The rekey runs before the layout-kind return for every owner kind, latch-gated, with the move in a `#[cold]` call; the array-arm and move-hook copies are deleted. The recorded value is emitted ahead of the kind arms, so no arm's early return can skip it. + + Each half has its own sabotage witness: removing the rekey fails at "the registry entry did not follow its owner", and restricting the value visit back to arrays and objects fails at "the recorded prototype still names its pre-collection address". Cost is +0.04% to +0.16% instructions where the registry is never armed, and +0.68% on a fixture that arms it and churns Errors/Maps/Dates — the per-owner cost arrays and objects have always paid. + + Not a regression: the funnel before #10381 returns on the same check. Found from a CodeRabbit review comment on #10381 that landed unactioned; the population turned out to be six kinds wider than the one it named. diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index dbb5a7ad51..65ace3da66 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -693,6 +693,12 @@ impl LlBlock { // -------- Memory -------- + /// An `alloca` in THIS block. Legal only while this is the entry block + /// (the parameter prologues): anywhere else the slot is a per-execution + /// stack bump, and `LlFunction::for_each_final_item` refuses it (#10463). + /// Lowering code allocates with `LlFunction::alloca_entry` / + /// `alloca_entry_array`, which place the slot in the entry block whatever + /// block is current. pub fn alloca(&mut self, ty: LlvmType) -> String { let r = self.reg(); self.push_inst(crate::inst::LlInst::Alloca { dst: r.clone(), ty }); diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 5f8b1e1b16..23f1245f10 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1388,17 +1388,13 @@ pub(super) fn emit_namespace_populator( // per-entry loop simply doesn't execute. let n = entries.len(); let buf_len = n.max(1); - let blk = ctx.block(); - // Alloca the four parallel buffers. - let keys_buf = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x ptr]", keys_buf, buf_len)); - let lens_buf = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x i32]", lens_buf, buf_len)); - let vals_buf = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", vals_buf, buf_len)); - let live_buf = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x i8]", live_buf, buf_len)); + // Alloca the four parallel buffers — in the entry block, like every + // alloca (#10463). + let keys_buf = ctx.func.alloca_entry_array(PTR, buf_len); + let lens_buf = ctx.func.alloca_entry_array(I32, buf_len); + let vals_buf = ctx.func.alloca_entry_array(DOUBLE, buf_len); + let live_buf = ctx.func.alloca_entry_array(I8, buf_len); // #7210 (2): `vals_buf` is a plain stack alloca, not a shadow slot the // collector scans. Each entry's value is a NaN-boxed JSValue that can be diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 109d708c81..fbdef0d15c 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -103,7 +103,9 @@ fn lower_dynamic_require(ctx: &mut FnCtx<'_>, paths: &[String], arg: &Expr) -> R // The no-match fallthrough resolves via the ambient require (builtin-or-throw) // rather than rejecting. let spec_val = lower_expr(ctx, arg)?; - let result_slot = ctx.block().alloca(DOUBLE); + // #10463: an entry-block slot; in the current block it grew the stack on + // every loop iteration. + let result_slot = ctx.func.alloca_entry(DOUBLE); let join_block_idx = ctx.new_block("dynamic_require_join"); let path_handle = ctx.block() @@ -381,7 +383,7 @@ fn emit_i18n_row_value( _ => return emit_i18n_template(ctx, &templates[default_idx], lowered_params), }; - let result_slot = ctx.block().alloca(DOUBLE); + let result_slot = ctx.func.alloca_entry(DOUBLE); let join_block_idx = ctx.new_block("i18n_locale_join"); for (li, template) in templates.iter().enumerate() { @@ -683,7 +685,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // promise (NaN-boxed POINTER_TAG f64) here, then jumps to // a join block which loads and returns. Using an alloca // keeps the IR straightforward without proper phi nodes. - let result_slot = ctx.block().alloca(DOUBLE); + // #10463: in the entry block, like every alloca. + let result_slot = ctx.func.alloca_entry(DOUBLE); let join_block_idx = ctx.new_block("dynamic_import_join"); // Unbox the path argument once into an i64 StringHeader*. @@ -1161,7 +1164,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .map(|(_, idx)| *idx) .unwrap_or(*string_idx); - let result_slot = ctx.block().alloca(DOUBLE); + let result_slot = ctx.func.alloca_entry(DOUBLE); let join_block_idx = ctx.new_block("i18n_plural_join"); for (cat, form_idx) in plural_forms.iter().filter(|(cat, _)| *cat != 5) { diff --git a/crates/perry-codegen/src/expr/entry_block_alloca_tests.rs b/crates/perry-codegen/src/expr/entry_block_alloca_tests.rs new file mode 100644 index 0000000000..b9d2015b17 --- /dev/null +++ b/crates/perry-codegen/src/expr/entry_block_alloca_tests.rs @@ -0,0 +1,303 @@ +//! #10463: the lowerings that used to place an argument buffer or an +//! out-parameter `alloca` in whatever block was current, each compiled inside +//! a counted loop. +//! +//! `LlFunction::for_each_final_item` now refuses any `alloca` outside the entry +//! block (`function/entry_allocas.rs`), so a regression at one of these sites +//! fails the compile below with that refusal. The IR is also read back here +//! with a scanner of its own, so the corpus does not rest on the refusal it is +//! meant to back up. Each case asserts its subject is live first: the runtime +//! entry the construct lowers to has to be called from a block after the entry +//! block, or the fixture never reached the site and a clean verdict would be +//! vacuous. + +use crate::compile_module; +use perry_hir::types::Type; +use perry_hir::{CompareOp, Expr, Function, Module, Param, Stmt, UpdateOp}; + +const N: u32 = 1; +const DATE: u32 = 2; +const ARR: u32 = 3; +const ARRAY_LIKE: u32 = 4; +const I: u32 = 10; + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn get(id: u32) -> Box { + Box::new(Expr::LocalGet(id)) +} + +fn num(v: f64) -> Box { + Box::new(Expr::Number(v)) +} + +/// `function probe(n, date, arr, arrayLike) { for (let i = 0; i < n; i++) { body } }` +fn looped(body: Vec) -> Module { + let mut module = Module::new("entry_block_alloca.ts"); + module.functions = vec![Function { + id: 90, + name: "probe".to_string(), + type_params: Vec::new(), + params: vec![ + param(N, "n", Type::Number), + param(DATE, "date", Type::Any), + param(ARR, "arr", Type::Array(Box::new(Type::Number))), + param(ARRAY_LIKE, "arrayLike", Type::Any), + ], + return_type: Type::Void, + body: vec![Stmt::For { + init: Some(Box::new(Stmt::Let { + id: I, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: get(I), + right: get(N), + }), + update: Some(Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + }), + body, + }], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + module +} + +fn method_call(receiver: u32, method: &str, args: Vec) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: get(receiver), + property: method.to_string(), + byte_offset: 0, + }), + args, + type_args: Vec::new(), + byte_offset: 0, + }) +} + +/// Every `alloca` outside its function's entry block, as `fn: line`. Labels are +/// flush-left `name:` lines; the first one after a `define` opens the entry +/// block and the next one closes it. +fn non_entry_allocas(ir: &str) -> Vec { + let mut found = Vec::new(); + let mut function = None; + let mut labels = 0usize; + for line in ir.lines() { + if line.starts_with("define ") { + function = Some(line.to_string()); + labels = 0; + } else if line.starts_with('}') { + function = None; + } else if let Some(define) = &function { + if line.ends_with(':') && !line.starts_with(|c: char| c.is_whitespace() || c == ';') { + labels += 1; + } else if labels > 1 && line.contains(" = alloca ") { + found.push(format!("{define}: {}", line.trim())); + } + } + } + found +} + +/// The number of calls to `@callee` in blocks after an entry block. +fn calls_outside_entry(ir: &str, callee: &str) -> usize { + let needle = format!("@{callee}("); + let mut in_function = false; + let mut labels = 0usize; + let mut count = 0; + for line in ir.lines() { + if line.starts_with("define ") { + in_function = true; + labels = 0; + } else if line.starts_with('}') { + in_function = false; + } else if in_function { + if line.ends_with(':') && !line.starts_with(|c: char| c.is_whitespace() || c == ';') { + labels += 1; + } else if labels > 1 && line.contains(&needle) { + count += 1; + } + } + } + count +} + +fn assert_entry_block_allocas_only(case: &str, callees: &[&str], body: Vec) { + let bytes = compile_module(&looped(body), crate::temp_root_coverage::entry_opts()) + .unwrap_or_else(|e| panic!("{case}: codegen failed: {e}")); + let ir = String::from_utf8(bytes).expect("LLVM IR should be UTF-8"); + for callee in callees { + assert!( + calls_outside_entry(&ir, callee) > 0, + "{case}: the fixture must lower to `@{callee}` inside the loop, or this \ + case checks nothing:\n{ir}" + ); + } + let stray = non_entry_allocas(&ir); + assert!( + stray.is_empty(), + "{case}: every alloca must be in its function's entry block (#10463); \ + found outside it:\n{}\n\n{ir}", + stray.join("\n") + ); +} + +#[test] +fn date_setters_keep_their_argument_buffer_in_the_entry_block() { + assert_entry_block_allocas_only( + "Date.prototype.set*", + &["js_date_apply_setter"], + vec![ + Stmt::Expr(Expr::DateSetTime { + date: get(DATE), + args: vec![Expr::LocalGet(I)], + }), + Stmt::Expr(Expr::DateSetUtcHours { + date: get(DATE), + args: vec![ + Expr::LocalGet(I), + Expr::Number(1.0), + Expr::Number(2.0), + Expr::Number(3.0), + ], + }), + ], + ); +} + +#[test] +fn date_utc_keeps_its_argument_buffer_in_the_entry_block() { + assert_entry_block_allocas_only( + "Date.UTC", + &["js_date_utc"], + vec![Stmt::Expr(Expr::DateUtc(vec![ + Expr::Number(2000.0), + Expr::LocalGet(I), + ]))], + ); +} + +#[test] +fn to_spliced_keeps_its_item_buffer_in_the_entry_block() { + assert_entry_block_allocas_only( + "Array.prototype.toSpliced", + &["js_array_to_spliced"], + vec![Stmt::Expr(Expr::ArrayToSpliced { + array: get(ARR), + start: num(1.0), + delete_count: num(1.0), + items: vec![Expr::LocalGet(I)], + })], + ); +} + +/// `Expr::ArraySplice`: the `i64` out-parameter AND the item buffer, with and +/// without items. +#[test] +fn local_splice_keeps_its_out_slot_and_item_buffer_in_the_entry_block() { + assert_entry_block_allocas_only( + "Expr::ArraySplice", + &["js_array_splice"], + vec![ + Stmt::Expr(Expr::ArraySplice { + array_id: ARR, + start: num(1.0), + delete_count: Some(num(1.0)), + items: vec![Expr::LocalGet(I)], + }), + Stmt::Expr(Expr::ArraySplice { + array_id: ARR, + start: num(1.0), + delete_count: Some(num(0.0)), + items: Vec::new(), + }), + ], + ); +} + +/// The generic array-method lowering (`lower_array_method`): `concat`, +/// `unshift`, and `splice` with its out-parameter. +#[test] +fn array_methods_keep_their_buffers_in_the_entry_block() { + assert_entry_block_allocas_only( + "arr.concat / arr.unshift / arr.splice", + &[ + "js_array_concat_variadic", + "js_array_unshift_variadic", + "js_array_splice", + ], + vec![ + method_call(ARR, "concat", vec![Expr::LocalGet(I)]), + method_call(ARR, "unshift", vec![Expr::LocalGet(I)]), + method_call( + ARR, + "splice", + vec![Expr::Number(0.0), Expr::Number(1.0), Expr::LocalGet(I)], + ), + ], + ); +} + +/// `Array.prototype.{push,unshift,splice,concat}.call(arrayLike, …)`. +#[test] +fn array_like_methods_keep_their_argument_buffer_in_the_entry_block() { + let call = |method: &str, args: Vec| { + Stmt::Expr(Expr::ArrayLikeMethod { + method: method.to_string(), + receiver: get(ARRAY_LIKE), + args, + }) + }; + assert_entry_block_allocas_only( + "Array.prototype.*.call", + &[ + "js_arraylike_push", + "js_arraylike_unshift", + "js_arraylike_splice", + "js_arraylike_concat", + ], + vec![ + call("push", vec![Expr::LocalGet(I)]), + call("unshift", vec![Expr::LocalGet(I)]), + call("splice", vec![Expr::Number(0.0), Expr::Number(2.0)]), + call("concat", vec![Expr::LocalGet(I)]), + ], + ); +} + +/// The scanner itself: it must report the pre-fix shape. +#[test] +fn the_scanner_reports_an_alloca_in_a_loop_body() { + let ir = "define double @f() {\nentry.0:\n %a = alloca double\n br label %for.body.1\n\ + \nfor.body.1:\n %b = alloca [1 x double]\n call double @g(ptr %b)\n}\n"; + assert_eq!( + non_entry_allocas(ir), + vec!["define double @f() {: %b = alloca [1 x double]".to_string()] + ); + assert_eq!(calls_outside_entry(ir, "g"), 1); +} diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 708e76133c..4c2690b505 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -69,7 +69,8 @@ use crate::types::{DOUBLE, I1, I32, I64, PTR}; use super::{ emit_root_nanbox_store_on_block, emit_shadow_slot_bind_for_local, emit_string_literal_global, emit_write_barrier, extract_array_of_object_shape, i32_bool_to_nanbox, lower_array_literal, - lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_str_handle, unbox_to_i64, FnCtx, + lower_expr, lower_js_args_array, nanbox_pointer_inline, nanbox_string_inline, unbox_str_handle, + unbox_to_i64, FnCtx, }; /// Reserved runtime class id for a built-in constructor usable as a class @@ -1425,10 +1426,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; let item_vals: Vec = vals[items_at..].to_vec(); + // Scratch out-parameter slot receiving the modified-array + // handle from js_array_splice. #10463: an entry-block alloca — + // `blk.alloca` in the current block grew the stack on every + // loop iteration, as did the item buffer below. + let out_slot = ctx.func.alloca_entry(I64); let blk = ctx.block(); - // Scratch out-parameter slot — used only in this block to - // receive the modified-array handle from js_array_splice. - let out_slot = blk.alloca(I64); blk.store(I64, "0", &out_slot); let arr_handle = unbox_to_i64(blk, &arr_box); // ToIntegerOrInfinity via the clamping helper: `fptosi` on @@ -1439,25 +1442,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let count_i32 = blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &count_d)]); - let (items_ptr, items_count_str) = if item_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - // Allocate a stack buffer of [N x double] for the - // items, store each value, and pass the base pointer. - let n = item_vals.len(); - let items_count_str = format!("{}", n); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, items_count_str) - }; + // A stack buffer of [N x double] holding the items (null/0 + // when there are none). + let (items_ptr, items_count_str) = lower_js_args_array(ctx, &item_vals); // Note: js_array_splice's return value is the DELETED // array; the modified-in-place arr is written to *out_arr. - let deleted_handle = blk.call( + let deleted_handle = ctx.block().call( I64, "js_array_splice", &[ diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index d5777e3d26..b0c0de265c 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -52,8 +52,8 @@ use crate::type_analysis::{map_static_type_args, string_value_is_runtime_guarant use crate::types::{DOUBLE, I32, I64, PTR}; use super::{ - emit_string_literal_global, i32_bool_to_nanbox, lower_expr, nanbox_pointer_inline, - nanbox_string_inline, record_collection_number_key_fallback, + emit_string_literal_global, i32_bool_to_nanbox, lower_expr, lower_js_args_array, + nanbox_pointer_inline, nanbox_string_inline, record_collection_number_key_fallback, record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, unbox_str_handle, unbox_to_i64, FnCtx, }; @@ -845,24 +845,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // of raw NaN-boxed doubles + count (mirrors the dense // `js_array_concat_variadic` lowering). "splice" | "concat" => { - let n = arg_boxes.len(); - let (buf_reg, count_str) = if n == 0 { - ("null".to_string(), "0".to_string()) - } else { - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in arg_boxes.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; + // #10463: an entry-block buffer; allocated in the + // current block it grew the stack per loop iteration. + let (buf_reg, count_str) = lower_js_args_array(ctx, &arg_boxes); let fname = if method == "splice" { "js_arraylike_splice" } else { "js_arraylike_concat" }; - blk.call( + ctx.block().call( DOUBLE, fname, &[(DOUBLE, &recv_box), (PTR, &buf_reg), (I32, &count_str)], @@ -880,24 +871,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // push(...) / unshift(...): variadic — pass an alloca buffer of // raw NaN-boxed doubles + count (mirrors splice/concat above). "push" | "unshift" => { - let n = arg_boxes.len(); - let (buf_reg, count_str) = if n == 0 { - ("null".to_string(), "0".to_string()) - } else { - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in arg_boxes.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; + let (buf_reg, count_str) = lower_js_args_array(ctx, &arg_boxes); let fname = if method == "push" { "js_arraylike_push" } else { "js_arraylike_unshift" }; - blk.call( + ctx.block().call( DOUBLE, fname, &[(DOUBLE, &recv_box), (PTR, &buf_reg), (I32, &count_str)], diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index da90b4bee7..b1d349643c 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -14,9 +14,9 @@ use crate::type_analysis::{is_numeric_expr, is_provably_not_bigint}; use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8, PTR}; use super::{ - i32_bool_to_nanbox, lower_expr, lower_expr_native, lower_expr_value, lower_math_operand, - materialize_js_value, nanbox_pointer_inline, nanbox_string_inline, unbox_str_handle, - unbox_to_i64, FnCtx, + i32_bool_to_nanbox, lower_expr, lower_expr_native, lower_expr_value, lower_js_args_array, + lower_math_operand, materialize_js_value, nanbox_pointer_inline, nanbox_string_inline, + unbox_str_handle, unbox_to_i64, FnCtx, }; fn lowered_value_to_iter_result_f64( @@ -189,20 +189,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { for a in args.iter() { vals.push(lower_expr(ctx, a)?); } - let blk = ctx.block(); - let (args_ptr, argc) = if vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = vals.len(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; - Ok(blk.call(DOUBLE, "js_date_utc", &[(PTR, &args_ptr), (I32, &argc)])) + // #10463: an entry-block buffer, not one per loop iteration. + let (args_ptr, argc) = lower_js_args_array(ctx, &vals); + Ok(ctx + .block() + .call(DOUBLE, "js_date_utc", &[(PTR, &args_ptr), (I32, &argc)])) } // -------- Object.defineProperty -------- diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e6ada1e9a2..fae77a1dba 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -165,6 +165,8 @@ mod class_field_barrier_tests; mod class_field_get_shape_tests; mod dispatch; #[cfg(test)] +mod entry_block_alloca_tests; +#[cfg(test)] mod hit_path_access_tests; #[cfg(test)] mod index_set_barrier_tests; diff --git a/crates/perry-codegen/src/expr/os_uri_dates.rs b/crates/perry-codegen/src/expr/os_uri_dates.rs index ced295966c..6d2b564064 100644 --- a/crates/perry-codegen/src/expr/os_uri_dates.rs +++ b/crates/perry-codegen/src/expr/os_uri_dates.rs @@ -10,7 +10,10 @@ use perry_hir::Expr; use crate::nanbox::double_literal; use crate::types::{DOUBLE, I1, I32, I64, PTR}; -use super::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx}; +use super::{ + lower_expr, lower_js_args_array, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, + FnCtx, +}; /// Field selector codes for `js_date_apply_setter`. Must match the runtime /// (`crates/perry-runtime/src/date.rs`): 0=FullYear 1=Month 2=Date 3=Hours @@ -42,22 +45,12 @@ pub(crate) fn lower_date_setter( for a in args { arg_vals.push(lower_expr(ctx, a)?); } - let blk = ctx.block(); - let (args_ptr, argc) = if arg_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = arg_vals.len(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in arg_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; + // #10463: the buffer is an entry-block alloca. Emitted here, in whatever + // block is current, it grew the stack on every loop iteration. + let (args_ptr, argc) = lower_js_args_array(ctx, &arg_vals); let is_utc_str = if is_utc { "1" } else { "0" }; let field_str = format!("{}", field); - Ok(blk.call( + Ok(ctx.block().call( DOUBLE, "js_date_apply_setter", &[ @@ -350,23 +343,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { item_vals.push(lower_expr(ctx, it)?); } - let blk = ctx.block(); - let arr_handle = unbox_to_i64(blk, &arr_box); - - let (items_ptr, items_count_str) = if item_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = item_vals.len(); - let items_count_str = format!("{}", n); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, items_count_str) - }; + let arr_handle = unbox_to_i64(ctx.block(), &arr_box); + // #10463: entry-block buffer (see `lower_date_setter`). + let (items_ptr, items_count_str) = lower_js_args_array(ctx, &item_vals); + let blk = ctx.block(); let result = blk.call( I64, "js_array_to_spliced", diff --git a/crates/perry-codegen/src/expr/v8_interop.rs b/crates/perry-codegen/src/expr/v8_interop.rs index 5e6fae9455..e994532e02 100644 --- a/crates/perry-codegen/src/expr/v8_interop.rs +++ b/crates/perry-codegen/src/expr/v8_interop.rs @@ -103,7 +103,6 @@ pub(crate) fn emit_v8_export_call( let argc = lowered_args.len(); let alloca_count = if argc == 0 { 1 } else { argc }; - let blk = ctx.block(); let argc_lit = format!("{}", argc); let spec_ptr = format!("@{}", spec_global); let name_ptr = format!("@{}", name_global); @@ -112,12 +111,10 @@ pub(crate) fn emit_v8_export_call( // Stack-allocate the args buffer (zero-len → still need a pointer; an // `alloca [1 x double]` is well-formed in LLVM and never dereferenced - // because argc=0 in that branch of the runtime). - let args_slot = blk.fresh_reg(); - blk.emit_raw(format!( - "{} = alloca [{} x double], align 8", - args_slot, alloca_count - )); + // because argc=0 in that branch of the runtime). #10463: in the entry + // block, so a call inside a loop does not grow the stack per iteration. + let args_slot = ctx.func.alloca_entry_array(DOUBLE, alloca_count); + let blk = ctx.block(); for (i, v) in lowered_args.iter().enumerate() { let slot = blk.fresh_reg(); blk.emit_raw(format!( @@ -207,7 +204,6 @@ pub(crate) fn emit_v8_member_method_call( let argc = lowered_args.len(); let alloca_count = if argc == 0 { 1 } else { argc }; - let blk = ctx.block(); let argc_lit = format!("{}", argc); let spec_ptr = format!("@{}", spec_global); let member_ptr = format!("@{}", member_global); @@ -216,11 +212,9 @@ pub(crate) fn emit_v8_member_method_call( let member_len_lit = format!("{}", member_bytes); let method_len_lit = format!("{}", method_bytes); - let args_slot = blk.fresh_reg(); - blk.emit_raw(format!( - "{} = alloca [{} x double], align 8", - args_slot, alloca_count - )); + // #10463: entry-block args buffer (see `emit_v8_export_call`). + let args_slot = ctx.func.alloca_entry_array(DOUBLE, alloca_count); + let blk = ctx.block(); for (i, v) in lowered_args.iter().enumerate() { let slot = blk.fresh_reg(); blk.emit_raw(format!( diff --git a/crates/perry-codegen/src/expr/worker_new.rs b/crates/perry-codegen/src/expr/worker_new.rs index e4e34b727d..12224f2c05 100644 --- a/crates/perry-codegen/src/expr/worker_new.rs +++ b/crates/perry-codegen/src/expr/worker_new.rs @@ -49,7 +49,9 @@ pub(super) fn lower_candidates( let bits = ctx.block().bitcast_double_to_i64(&file); let tag = ctx.block().lshr(I64, &bits, "48"); let is_url = ctx.block().icmp_eq(I64, &tag, POINTER_TAG_TOP16_I64); - let normalized = ctx.block().alloca(DOUBLE); + // #10463: both join slots are entry-block allocas, so a `new Worker` + // inside a loop does not grow the stack per iteration. + let normalized = ctx.func.alloca_entry(DOUBLE); let url_block = ctx.new_block("worker_url"); let string_block = ctx.new_block("worker_string"); let dispatch = ctx.new_block("worker_dispatch"); @@ -75,7 +77,7 @@ pub(super) fn lower_candidates( .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &file)]); // Comparisons below do not allocate. Only the selected spawn can // collect, after the last use of `spec`; options remain rooted. - let result = ctx.block().alloca(DOUBLE); + let result = ctx.func.alloca_entry(DOUBLE); let join = ctx.new_block("worker_join"); for (path, target) in &aliases { let key = ctx.strings.intern(path); diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 541869cda4..dff3fb48e2 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -15,6 +15,9 @@ use crate::types::LlvmType; /// #7173 / #7174). A sibling file only because of the 2,000-line cap. mod precise_roots; +/// #10463: the entry-block `alloca` invariant, enforced on the final stream. +mod entry_allocas; + use precise_roots::{lower_precise_roots_to_native_stack, retype_landing_pads_for_statepoints}; pub struct LlFunction { @@ -1111,12 +1114,19 @@ impl LlFunction { usize::MAX }; let mut idx = 0usize; + let mut in_entry_block = is_entry; for inst in blk.insts() { if idx == boundary { for line in &self.entry_post_init_setup { self.text_item(line, rewrite_rets, &mut seq, sink)?; } } + entry_allocas::refuse_alloca_outside_entry_block( + &self.name, + &blk.label, + inst, + &mut in_entry_block, + ); self.inst_item(inst, rewrite_rets, &mut seq, sink)?; idx += 1; } diff --git a/crates/perry-codegen/src/function/entry_allocas.rs b/crates/perry-codegen/src/function/entry_allocas.rs new file mode 100644 index 0000000000..26bd6a9c4d --- /dev/null +++ b/crates/perry-codegen/src/function/entry_allocas.rs @@ -0,0 +1,210 @@ +//! #10463: every `alloca` a function emits lives in its LLVM entry block. +//! +//! LLVM lowers an `alloca` outside the entry block as a runtime stack-pointer +//! bump that is not undone until the function returns. Inside a loop every +//! iteration therefore consumes stack for good: `d.setTime(i)` took 16 B per +//! iteration and a two-million-iteration loop died with SIGSEGV at a point +//! that moved with `ulimit -s`. #167 added +//! [`LlFunction::alloca_entry_array`](super::LlFunction::alloca_entry_array) +//! for one family of call sites; eight sibling lowerings kept emitting +//! `alloca [N x double]` into whatever block was current, and the HIR-level +//! cross-module inliner copied them into callers' loops (date-fns +//! `addMinutes`). +//! +//! Fixing call sites one at a time is how the class survived #167, so the +//! invariant is enforced where every function body is finalized: +//! [`LlFunction::for_each_final_item`](super::LlFunction::for_each_final_item), +//! the single funnel both the textual and the native backends consume. An +//! entry-block slot comes from `LlFunction::alloca_entry*`, which splices it +//! ahead of block 0's instructions. The only other legal spelling is +//! `LlBlock::alloca` while block 0 is current (the parameter prologues). Any +//! `alloca` instruction the stream places after that point is refused. + +use crate::inst::LlInst; + +/// Refuse `inst` if it is an `alloca` outside the entry block. +/// +/// `in_entry_block` is true while the stream is still inside the LLVM entry +/// block: the caller starts it `true` for block 0 and `false` for every other +/// block, and a label inside an instruction stream (the invoke-EH +/// continuation `emit_inline_label` writes) ends the entry block part-way +/// through block 0, so it is cleared here. +pub(super) fn refuse_alloca_outside_entry_block( + function: &str, + block: &str, + inst: &LlInst, + in_entry_block: &mut bool, +) { + match inst { + LlInst::Alloca { .. } if !*in_entry_block => { + let mut line = String::new(); + inst.render_into(&mut line); + refuse(function, block, &line); + } + LlInst::Raw(text) => { + for line in text.split('\n') { + if is_label_line(line) { + *in_entry_block = false; + } else if !*in_entry_block && is_alloca_line(line) { + refuse(function, block, line); + } + } + } + _ => {} + } +} + +/// A flush-left `name:` line — the same column-0 rule the IR-reading scripts +/// anchor labels on (see `LlBlock::emit_inline_label`). Instructions carry a +/// two-space indent and never end in `:`. +fn is_label_line(line: &str) -> bool { + line.ends_with(':') + && line + .as_bytes() + .first() + .is_some_and(|b| !b.is_ascii_whitespace() && *b != b';') +} + +/// `%reg = alloca …`, whatever the allocated type. +fn is_alloca_line(line: &str) -> bool { + let line = line.trim_start(); + line.starts_with('%') + && line + .split_once(" = ") + .is_some_and(|(_, rhs)| rhs.starts_with("alloca ")) +} + +#[cold] +#[inline(never)] +fn refuse(function: &str, block: &str, line: &str) -> ! { + panic!( + "perry-codegen: `{}` is emitted in block `{block}` of @{function}, outside the \ + function's entry block. A non-entry alloca bumps the stack pointer at run time and \ + is not released until the function returns, so every loop iteration through it \ + consumes stack until the process dies with SIGSEGV (#167, #10463). Allocate the \ + slot with `LlFunction::alloca_entry` / `alloca_entry_array` instead.", + line.trim() + ) +} + +#[cfg(test)] +mod tests { + use super::super::LlFunction; + use crate::types::{DOUBLE, I64, PTR}; + + fn probe() -> LlFunction { + let mut f = LlFunction::new("perry_fn_alloca_probe", DOUBLE, Vec::new()); + let _ = f.create_block("entry"); + f + } + + fn entry_prologue_ends_at(ir: &str, first_non_entry_label: &str) -> usize { + ir.lines() + .position(|line| line == format!("{first_non_entry_label}:")) + .unwrap_or_else(|| panic!("no `{first_non_entry_label}:` label in:\n{ir}")) + } + + /// The control: the helpers put the slot in block 0 even when the block + /// being lowered is a loop body, and the body keeps only the uses. + #[test] + fn entry_helpers_hoist_the_slot_out_of_the_current_block() { + let mut f = probe(); + let body_label = f.create_block("for.body").label.clone(); + let buf = f.alloca_entry_array(DOUBLE, 2); + let out = f.alloca_entry(I64); + { + let blk = f.block_mut(1).unwrap(); + let slot = blk.gep(DOUBLE, &buf, &[(I64, "1")]); + blk.store(DOUBLE, "0.0", &slot); + blk.call(DOUBLE, "js_consume", &[(PTR, &buf), (PTR, &out)]); + blk.ret(DOUBLE, "0.0"); + } + f.block_mut(0).unwrap().br(&body_label); + let ir = f.to_ir(); + let body_starts = entry_prologue_ends_at(&ir, &body_label); + let alloca_lines: Vec = ir + .lines() + .enumerate() + .filter(|(_, line)| line.contains(" = alloca ")) + .map(|(i, _)| i) + .collect(); + assert_eq!(alloca_lines.len(), 2, "both slots rendered:\n{ir}"); + assert!( + alloca_lines.iter().all(|&i| i < body_starts), + "every alloca must precede the loop body's label:\n{ir}" + ); + } + + /// The bug shape the eight #10463 lowerings emitted: a raw `alloca` text + /// line in a loop body. + #[test] + #[should_panic(expected = "outside the function's entry block")] + fn a_raw_alloca_in_a_loop_body_is_refused() { + let mut f = probe(); + let _ = f.create_block("for.body"); + let blk = f.block_mut(1).unwrap(); + let buf = blk.next_reg(); + blk.emit_raw(format!("{buf} = alloca [1 x double]")); + blk.call(DOUBLE, "js_date_apply_setter", &[(PTR, &buf)]); + let _ = f.to_ir(); + } + + /// The typed spelling of the same mistake (`blk.alloca` in a non-entry + /// block — what `Expr::ArraySplice`'s out-parameter used). + #[test] + #[should_panic(expected = "outside the function's entry block")] + fn a_typed_alloca_in_a_non_entry_block_is_refused() { + let mut f = probe(); + let _ = f.create_block("splice"); + let _ = f.block_mut(1).unwrap().alloca(I64); + let _ = f.to_ir(); + } + + /// Multi-line raw payloads are split and checked line by line. + #[test] + #[should_panic(expected = "outside the function's entry block")] + fn an_alloca_inside_a_multi_line_raw_payload_is_refused() { + let mut f = probe(); + let _ = f.create_block("body"); + f.block_mut(1) + .unwrap() + .emit_raw("%a = add i64 1, 2\n %b = alloca double, align 8"); + let _ = f.to_ir(); + } + + /// An inline label ends the entry block part-way through block 0: an + /// `alloca` after it is in a different LLVM basic block. + #[test] + #[should_panic(expected = "outside the function's entry block")] + fn an_alloca_after_an_inline_label_in_block_zero_is_refused() { + let mut f = probe(); + let blk = f.block_mut(0).unwrap(); + blk.insts_mut() + .push(crate::inst::LlInst::Raw("eh.cont0:".to_string())); + let _ = blk.alloca(DOUBLE); + let _ = f.to_ir(); + } + + /// The native backend consumes the item stream, not the text: it is + /// refused there too. + #[test] + #[should_panic(expected = "outside the function's entry block")] + fn the_native_item_stream_refuses_it_too() { + let mut f = probe(); + let _ = f.create_block("body"); + let _ = f.block_mut(1).unwrap().alloca(DOUBLE); + let _ = f.for_each_final_item::<()>(&mut |_| Ok(())); + } + + /// The prologue spelling stays legal: `LlBlock::alloca` while block 0 is + /// current, before any inline label. + #[test] + fn a_typed_alloca_in_the_entry_prologue_is_accepted() { + let mut f = probe(); + let blk = f.block_mut(0).unwrap(); + let slot = blk.alloca(DOUBLE); + blk.store(DOUBLE, "0.0", &slot); + blk.ret(DOUBLE, "0.0"); + assert!(f.to_ir().contains(" = alloca double")); + } +} diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index b007c6f444..3b66f4cf99 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -49,8 +49,8 @@ use anyhow::{bail, Result}; use perry_hir::Expr; use crate::expr::{ - emit_root_nanbox_store_on_block, emit_write_barrier, nanbox_pointer_inline, - nanbox_string_inline, unbox_to_i64, FnCtx, + emit_root_nanbox_store_on_block, emit_write_barrier, lower_js_args_array, + nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx, }; use crate::nanbox::{double_literal, TAG_UNDEFINED}; use crate::rooting; @@ -302,21 +302,12 @@ pub(crate) fn lower_array_method( // // The buffer stores are pure, so the group's re-read above them // is the last thing that has to happen below a collection point. + let recv_handle = unbox_to_i64(ctx.block(), recv_box); + // No args: a null buffer + 0 count (concat() returns a copy). + // #10463: otherwise an entry-block buffer — allocated in the + // current block it grew the stack on every loop iteration. + let (buf_reg, count_str) = lower_js_args_array(ctx, &arg_vals); let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - let n = arg_vals.len(); - let (buf_reg, count_str) = if n == 0 { - // No args: pass a null buffer + 0 count (concat() returns a copy). - ("null".to_string(), "0".to_string()) - } else { - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in arg_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; let result = blk.call( I64, "js_array_concat_variadic", @@ -901,19 +892,8 @@ pub(crate) fn lower_array_method( // items at the front in source order via the variadic helper. // The (possibly reallocated) array forwards from its old pointer, // so in-place mutation stays visible to the receiver slot. - let (buf_ptr, count_str) = if arg_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = arg_vals.len(); - let blk = ctx.block(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in arg_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; + // #10463: an entry-block buffer (null/0 for no arguments). + let (buf_ptr, count_str) = lower_js_args_array(ctx, &arg_vals); let recv_handle = { let blk = ctx.block(); unbox_to_i64(blk, recv_box) @@ -968,8 +948,10 @@ pub(crate) fn lower_array_method( "2147483647.0".to_string() }; let item_vals: Vec = arg_vals.iter().skip(2).cloned().collect(); + // #10463: the out-parameter and the item buffer are entry-block + // allocas; `blk.alloca` here would grow the stack per iteration. + let out_slot = ctx.func.alloca_entry(I64); let blk = ctx.block(); - let out_slot = blk.alloca(I64); blk.store(I64, "0", &out_slot); let recv_handle = unbox_to_i64(blk, recv_box); // ToIntegerOrInfinity via the clamping helper: `fptosi` on @@ -981,19 +963,8 @@ pub(crate) fn lower_array_method( blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &start_d)]); let count_i32 = blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &count_d)]); - let (items_ptr, items_count_str) = if item_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = item_vals.len(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; - let deleted_handle = blk.call( + let (items_ptr, items_count_str) = lower_js_args_array(ctx, &item_vals); + let deleted_handle = ctx.block().call( I64, "js_array_splice", &[ diff --git a/crates/perry-codegen/src/lower_call/native/native_instance_branch.rs b/crates/perry-codegen/src/lower_call/native/native_instance_branch.rs index 852532ffdd..4af99b144b 100644 --- a/crates/perry-codegen/src/lower_call/native/native_instance_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_instance_branch.rs @@ -354,11 +354,13 @@ } } let arr_box = lower_expr(ctx, recv)?; + // #10463: the fused push's length out-parameter is an entry-block + // alloca; `blk.alloca` here grew the stack on every loop iteration. + let length_slot = u31_value.as_ref().map(|_| ctx.func.alloca_entry(I32)); let blk = ctx.block(); let mut arr_handle = unbox_to_i64(blk, &arr_box); let orig_handle = arr_handle.clone(); - let fused_length_slot = if let Some(value) = u31_value { - let length_slot = blk.alloca(I32); + let fused_length_slot = if let Some((value, length_slot)) = u31_value.zip(length_slot) { let fast_handle = blk.call( I64, "js_array_push_u31_with_length", diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index 1d406c7bad..c5155e1898 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -53,6 +53,7 @@ //! mirroring `#503`'s `PERRY_ALLOW_DYNAMIC_STDLIB`. use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use swc_ecma_ast as ast; @@ -622,9 +623,27 @@ fn record_deferred_site(classification: &EvalClassification) { ); } +/// #10421: the program can construct a function from runtime strings through a +/// path no recorded site stands for — a known-codegen-library site, a +/// `Function(...)` whose constant fold failed, or the constructor reached as a +/// value (`const F = Function`, `ctx.Function`, `fn.constructor(...)`). Only +/// ever set to `true` during a compile; process-global for the same reason as +/// [`EVAL_DEFERRED_SITES`], and cleared with it by the notice drain. +static DYNAMIC_FUNCTION_REACHABLE: AtomicBool = AtomicBool::new(false); + +/// Record that the program being compiled can reach the runtime `Function` +/// constructor, so the auto-optimized runtime keeps the `dyn-eval` +/// interpreter. Over-reporting costs binary size only; a miss compiles a +/// program that throws at runtime while a `PERRY_NO_AUTO_OPTIMIZE=1` build of +/// it works. +pub fn note_dynamic_function_reachable() { + DYNAMIC_FUNCTION_REACHABLE.store(true, Ordering::Relaxed); +} + /// Drain and return every deferred bucket-3 site recorded so far this /// compile. Called by the driver to render the end-of-compile notice. pub fn take_deferred_eval_sites() -> Vec { + DYNAMIC_FUNCTION_REACHABLE.store(false, Ordering::Relaxed); EVAL_DEFERRED_SITES .lock() .map(|mut v| std::mem::take(&mut *v)) @@ -637,15 +656,17 @@ pub fn take_deferred_eval_sites() -> Vec { /// BEFORE the notice drain, to decide whether `libperry_runtime.a` must carry /// the `dyn-eval` interpreter feature. Dynamic-`import(...)` and /// unimplemented-API deferrals don't count — they never reach the Function -/// constructor. +/// constructor. #10421: neither does a recorded site alone — see +/// [`note_dynamic_function_reachable`]. pub fn has_deferred_dynamic_code_sites() -> bool { - EVAL_DEFERRED_SITES - .lock() - .map(|v| { - v.iter() - .any(|s| s.kind.contains("eval") || s.kind.contains("Function")) - }) - .unwrap_or(false) + DYNAMIC_FUNCTION_REACHABLE.load(Ordering::Relaxed) + || EVAL_DEFERRED_SITES + .lock() + .map(|v| { + v.iter() + .any(|s| s.kind.contains("eval") || s.kind.contains("Function")) + }) + .unwrap_or(false) } /// What the lowering site should do with a classified call (#5206). @@ -953,6 +974,14 @@ mod tests { assert_eq!(mine[0].kind, "eval(...)"); } + /// #10421: a Function constructor reached without a recorded site still + /// selects the `dyn-eval` runtime. + #[test] + fn noted_dynamic_function_reach_needs_the_interpreter() { + note_dynamic_function_reachable(); + assert!(has_deferred_dynamic_code_sites()); + } + /// Strict-eval mode: a runtime-unknown site is a hard compile-time error. #[test] fn strict_mode_refuses_runtime_unknown() { diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index e1fa2b3308..e7ef4d32b1 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -51,9 +51,10 @@ pub use egress::{audit_module_egress, EgressRefusalReason, EgressViolation}; pub use enums::fix_imported_enums; pub use eval_classifier::{ check_unimplemented_api, classify as classify_eval_surface, has_deferred_dynamic_code_sites, - location_string, record_deferred_aot_site, set_eval_strict_mode, set_unimplemented_strict_mode, - take_deferred_eval_sites, DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, - EvalSurface, UnimplementedDecision, UNIMPLEMENTED_API_KIND, + location_string, note_dynamic_function_reachable, record_deferred_aot_site, + set_eval_strict_mode, set_unimplemented_strict_mode, take_deferred_eval_sites, + DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, EvalSurface, + UnimplementedDecision, UNIMPLEMENTED_API_KIND, }; pub use ir::*; pub use js_transform::{ diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs index bda915e193..ac46ef1481 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs @@ -4,7 +4,7 @@ use crate::types::Type; use anyhow::Result; use swc_ecma_ast as ast; -use super::super::super::LoweringContext; +use super::super::super::{lower_expr, LoweringContext}; /// #1678 (Phase 0 of #1677) — classify a bare `Function(...)` / /// `eval(...)` call. The `Function('return this')()` globalThis fold runs @@ -18,6 +18,8 @@ use super::super::super::LoweringContext; /// (defer) mode a runtime-unknown site returns `Ok(Some(throw_value))` /// (#5206): the caller uses that expression in place of the call so it /// throws a descriptive `Error` only if reached. `Ok(None)` means proceed. +/// #10422: that throw-on-reach value is `eval`'s only; every unfolded +/// `Function(...)` spelling builds its function at runtime instead. pub(crate) fn check_eval_function_call( ctx: &mut LoweringContext, call: &ast::CallExpr, @@ -108,7 +110,39 @@ pub(crate) fn check_eval_function_call( } .map(|a| a.expr.as_ref()) }; - match crate::eval_classifier::check_site(surface, body_arg, &ctx.source_file_path, call.span)? { + let decision = + crate::eval_classifier::check_site(surface, body_arg, &ctx.source_file_path, call.span)?; + if surface == crate::eval_classifier::EvalSurface::FunctionCall { + // #10422: the constant fold did not compile this call, so the function + // is built at runtime — by the same interpreter `new Function(...)` + // reaches (#6559), whichever bucket the body landed in. The call form + // used to compile a runtime-unknown body to a stub that always threw, + // and let every other unfolded call (an array parameter list, a + // known-library body) fall through to `undefined`. Strict-eval mode + // has already refused inside `check_site`. + crate::eval_classifier::note_dynamic_function_reachable(); + // `Function(p, body)` is spec-identical to `new Function(p, body)`: + // take the direct from-strings entry. A spread argument list and the + // `.call` / `.apply` spellings keep the generic lowering, which invokes + // the `Function` value (`global_this_function_call_thunk`) with its + // spec argument handling (`apply` of an array-like or `undefined`). + if matches!(callee, ast::Expr::Ident(_)) && call.args.iter().all(|a| a.spread.is_none()) { + let args = call + .args + .iter() + .map(|a| lower_expr(ctx, &a.expr)) + .collect::>>()?; + return Ok(Some(Expr::New { + class_name: "Function".to_string(), + args, + type_args: Vec::new(), + byte_offset: call.span.lo.0, + cap_args_appended: 0, + })); + } + return Ok(None); + } + match decision { crate::eval_classifier::EvalDecision::Proceed => Ok(None), crate::eval_classifier::EvalDecision::DeferToRuntimeError(message) => Ok(Some( super::super::super::const_fold_fn::synth_deferred_eval_value( diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 8eeac99c36..573bcf30b4 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -140,6 +140,37 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R args, }); } + // #10430: `new Stream()` for the legacy `node:stream` `Stream` + // constructor — the named export (any alias) or the default import, + // which IS that constructor. The by-name `Expr::New { "Stream" }` + // fallback built a prototype-less placeholder with no `on`/`emit`; + // construct the export value instead, so the runtime makes the + // instance inherit `Stream.prototype` (and through it EventEmitter). + // A namespace import keeps its builtin-module alias and is excluded. + let callee_name = callee_ident.sym.as_ref(); + let is_stream_constructor_value = ctx.lookup_local(callee_name).is_none() + && match ctx.lookup_native_module(callee_name) { + Some(("stream" | "node:stream", Some("Stream"))) => true, + Some(("stream" | "node:stream", None)) => { + ctx.lookup_builtin_module_alias(callee_name).is_none() + } + _ => false, + }; + let has_spread_arg = new_expr + .args + .as_deref() + .is_some_and(|args| args.iter().any(|arg| arg.spread.is_some())); + if is_stream_constructor_value && !has_spread_arg { + return Ok(Expr::NewDynamic { + callee: Box::new(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::NativeModuleRef("stream".to_string())), + property: "Stream".to_string(), + }), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + byte_offset: new_byte_offset, + }); + } // #4995: `new EE()` where `EE` is the events module *value* — the // default import (`import EE from 'events'`) or a CJS alias // (`var EE = require('events')`). Node's `events` module exports the @@ -813,6 +844,29 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // still catchable, still located, never a crash. crate::eval_classifier::EvalDecision::DeferToRuntimeError(_message) => {} } + // #10421: whichever bucket the body landed in (a + // known-library body, or constant strings the fold could + // not use), the function is built at runtime, so the + // auto-optimized runtime must keep the interpreter. + crate::eval_classifier::note_dynamic_function_reachable(); + // #10424: a spread argument list (`new Function(...parts)`) + // must reach the constructor element by element. The + // by-name `Expr::New` below lowers each argument as one + // value, so the whole array became a single non-string + // argument and the function got an empty body. + if args_slice.iter().any(|a| a.spread.is_some()) { + let callee = Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::GlobalGet(0)), + property: "Function".to_string(), + }; + let args = lower_new_spread_args(ctx, args_slice)?; + return Ok(Expr::NewDynamicSpread { + callee: Box::new(callee), + args, + byte_offset: new_byte_offset, + }); + } } } diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 3409a534d4..a2ca2aa9ad 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -31,6 +31,8 @@ mod assignment; mod helpers; mod json_literal; mod reactive_text; +#[cfg(test)] +mod stream_module_value_tests; pub(crate) use arm_bin::lower_bin_expr; pub(crate) use arm_class::lower_class_expr; diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index 6c4babbd35..1b9b985d8b 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -426,6 +426,26 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> property: "default".to_string(), }; } + // #10431: `import Stream from "node:stream"` binds `module.exports`, and + // for `stream` that is the legacy `Stream` constructor itself (which also + // carries every export as a static) — not a namespace object. Read the + // `default` export so the binding's VALUE is that constructor: + // `x instanceof Stream` needs a callable right-hand side and + // `Stream === NamedStream` needs the same object. Member reads/calls + // (`Stream.Readable`, `Stream.pipeline(…)`) are lowered from the binding, + // not from this value, and keep their static dispatch. A namespace import + // (`import * as ns`) registers a builtin-module alias and stays the + // namespace object; this is the same discriminator the `typeof` fold in + // `arm_unary` uses to report "function" for the default binding only. + if matches!(module_name, "stream" | "node:stream") + && ctx.lookup_builtin_module_alias(name).is_none() + { + return Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::NativeModuleRef("stream".to_string())), + property: "default".to_string(), + }; + } // Native module reference (e.g., mysql from 'mysql2/promise') Expr::NativeModuleRef(module_name.to_string()) } diff --git a/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs b/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs new file mode 100644 index 0000000000..8b772a5c27 --- /dev/null +++ b/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs @@ -0,0 +1,92 @@ +//! #10430 / #10431: the `node:stream` module value is the legacy `Stream` +//! constructor. The default binding's VALUE must read the `default` export +//! (the constructor) rather than evaluate to the namespace object, while a +//! namespace import stays the namespace; `new` of either constructor binding +//! must construct the export value instead of the by-name placeholder. + +use crate::ir::{Expr, Stmt}; + +fn lower(source: &str) -> crate::Module { + let ast = perry_parser::parse_typescript(source, "main.ts").unwrap(); + let hir = crate::lower::lower_module(&ast, "main", "main.ts").unwrap(); + crate::ir::clear_current_module_source(); + hir +} + +fn let_init<'a>(hir: &'a crate::Module, binding: &str) -> &'a Expr { + hir.init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name, + init: Some(init), + .. + } if name == binding => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("no `let {binding}` with an initializer")) +} + +fn is_stream_export_read(expr: &Expr, export: &str) -> bool { + matches!( + expr, + Expr::PropertyGet { object, property, .. } + if property == export + && matches!(object.as_ref(), Expr::NativeModuleRef(module) if module == "stream") + ) +} + +#[test] +fn default_import_value_is_the_stream_constructor() { + let hir = lower( + r#" + import Stream from "node:stream"; + import Bare from "stream"; + import * as ns from "node:stream"; + const fromNodeSpecifier: any = Stream; + const fromBareSpecifier: any = Bare; + const namespace: any = ns; + const check = ({} as any) instanceof Stream; + "#, + ); + assert!(is_stream_export_read( + let_init(&hir, "fromNodeSpecifier"), + "default" + )); + assert!(is_stream_export_read( + let_init(&hir, "fromBareSpecifier"), + "default" + )); + assert!( + matches!(let_init(&hir, "namespace"), Expr::NativeModuleRef(module) if module == "stream"), + "a namespace import must stay the namespace object" + ); + let Expr::InstanceOf { + ty_expr: Some(rhs), .. + } = let_init(&hir, "check") + else { + panic!("expected a dynamic instanceof"); + }; + assert!( + is_stream_export_read(rhs, "default"), + "`x instanceof Stream` needs the callable constructor on the right-hand side" + ); +} + +#[test] +fn new_of_a_stream_constructor_binding_constructs_the_export_value() { + let hir = lower( + r#" + import Stream from "node:stream"; + import { Stream as Aliased } from "node:stream"; + const fromDefault = new Stream(); + const fromAlias = new Aliased(); + "#, + ); + for binding in ["fromDefault", "fromAlias"] { + let Expr::NewDynamic { callee, .. } = let_init(&hir, binding) else { + panic!("`{binding}` must construct through NewDynamic"); + }; + assert!(is_stream_export_read(callee, "Stream"), "{binding}"); + } +} diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 755b16dbc3..db8bb8bc4e 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -948,6 +948,10 @@ pub fn lower_module_full_with_platform_globals( // literals, and counter vars (see `fn_ctor_env`). ctx.fn_ctor_env = super::fn_ctor_env::build_fn_ctor_env(ast_module); + // #10421: a `Function` constructor reached as a value (an alias, + // `ctx.Function`, `fn.constructor(...)`) needs the interpreter too. + pre_scan_function_ctor_reach(ast_module); + // #8882: every class DECLARATION name at any depth, for `lower_new`'s // unresolved-constructor guard (see `pre_scan/class_decl_names.rs`). pre_scan_class_decl_names(ast_module, &mut ctx); @@ -1710,6 +1714,7 @@ pub fn lower_module_full_with_platform_globals( ); } + module_decl::mark_exported_function_bodies(&mut module); module_decl::register_exported_local_variables(&ctx, &mut module); // Populate exported_native_instances by matching native_instances with exports diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 256d9e6870..142efb4eb5 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -9,6 +9,7 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +mod default_export_binding; mod namespace; pub(super) mod native_default_import; pub(super) mod native_profile_import; @@ -18,6 +19,8 @@ mod typescript; // Re-export moved items so existing `crate::...` / `super::*` call paths keep // resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`. +use default_export_binding::default_export_function_binding; +pub(super) use default_export_binding::mark_exported_function_bodies; pub(crate) use namespace::lower_namespace_as_class; use native_default_import::{ canonicalize_native_import_source, is_cjs_style_native_default_import, @@ -1887,8 +1890,14 @@ pub(crate) fn lower_module_decl( break; } } + // #10434: `export default F` of a declared function exports + // the binding `F` itself (the `export { F as default }` row), + // so importers share F's function object. + let local = + default_export_function_binding(ctx, &export_default_expr.expr, func_id) + .unwrap_or_else(|| "default".to_string()); module.exports.push(Export::Named { - local: "default".to_string(), + local, exported: "default".to_string(), }); } else if let Expr::ClassRef(class_name) = &lowered { diff --git a/crates/perry-hir/src/lower/module_decl/default_export_binding.rs b/crates/perry-hir/src/lower/module_decl/default_export_binding.rs new file mode 100644 index 0000000000..19f2df724e --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/default_export_binding.rs @@ -0,0 +1,186 @@ +//! Binding identity for `export default ` of a declared function +//! (#10434). +//! +//! `function F() {}; F.prototype.m = …; export default F;` must hand importers +//! the very function object the module calls `F`. Importers resolve a renamed +//! declared-function export through its origin (local) name, so the export row +//! has to be `{ local: "F", exported: "default" }` — the shape +//! `export { F as default }` already produces — rather than a +//! `{ local: "default" }` row that materializes a second function value with +//! none of F's expandos or prototype methods and no argument padding. + +use std::collections::HashSet; + +use swc_ecma_ast as ast; + +use crate::ir::{Export, Module}; +use crate::lower::LoweringContext; +use crate::types::FuncId; + +/// The module-scope function name an `export default ` refers to, when +/// `` is (modulo parentheses and erased TypeScript wrappers) a bare +/// identifier that resolves to the function `func_id`. Any other expression +/// that lowered to a `FuncRef` (a function expression) has no local binding +/// to share, so it keeps the synthetic `default` export row. +pub(super) fn default_export_function_binding( + ctx: &LoweringContext, + expr: &ast::Expr, + func_id: FuncId, +) -> Option { + let mut expr = expr; + loop { + expr = match expr { + ast::Expr::Paren(inner) => &inner.expr, + ast::Expr::TsAs(inner) => &inner.expr, + ast::Expr::TsNonNull(inner) => &inner.expr, + ast::Expr::TsSatisfies(inner) => &inner.expr, + ast::Expr::TsTypeAssertion(inner) => &inner.expr, + ast::Expr::TsConstAssertion(inner) => &inner.expr, + ast::Expr::TsInstantiation(inner) => &inner.expr, + ast::Expr::Ident(ident) => { + let name = ident.sym.to_string(); + return (ctx.lookup_func(&name) == Some(func_id)).then_some(name); + } + _ => return None, + }; + } +} + +/// Mark a function body exported when an export row names it as its local +/// binding (`export { F }`, `export { F as default }`, `export default F`). +/// +/// The export arms flip `is_exported` on the functions already lowered, but a +/// hoisted declaration that appears AFTER its export clause +/// (`export { F as default }; function F() {}` or `export default F; function +/// F() {}`) is not in `module.functions` yet at that point. The CLI driver +/// only records a renamed export's origin name — the thing that keeps the +/// importer's value identical to the local `F` — for exported function +/// bodies, so the flag is settled once the whole module is lowered. Value +/// aliases (`export const g = F`) name `g`, not `F`, and stay as they were. +pub(crate) fn mark_exported_function_bodies(module: &mut Module) { + if module.exported_functions.is_empty() { + return; + } + let exported_ids: HashSet = module + .exported_functions + .iter() + .map(|(_, id)| *id) + .collect(); + let export_locals: HashSet<&str> = module + .exports + .iter() + .filter_map(|export| match export { + Export::Named { local, .. } => Some(local.as_str()), + _ => None, + }) + .collect(); + for func in &mut module.functions { + if exported_ids.contains(&func.id) && export_locals.contains(func.name.as_str()) { + func.is_exported = true; + } + } +} + +#[cfg(test)] +mod tests { + use crate::ir::{Export, Module}; + use crate::lower_module; + use perry_diagnostics::SourceCache; + use perry_parser::parse_typescript_with_cache; + + fn lower_src(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "test.ts").expect("lowering should succeed") + }) + .expect("spawn") + .join() + .expect("lowering thread") + } + + fn default_rows(module: &Module) -> Vec { + module + .exports + .iter() + .filter_map(|export| match export { + Export::Named { local, exported } if exported == "default" => Some(local.clone()), + _ => None, + }) + .collect() + } + + /// The function named `name` is exported and is what `default` resolves to. + fn assert_default_is_function(module: &Module, name: &str) { + assert_eq!( + default_rows(module), + vec![name.to_string()], + "{:?}", + module.exports + ); + let func = module + .functions + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("function {name} not lowered")); + assert!(func.is_exported, "{name} must be flagged exported"); + assert!( + module + .exported_functions + .iter() + .any(|(exported, id)| exported == "default" && *id == func.id), + "{:?}", + module.exported_functions + ); + } + + #[test] + fn export_default_identifier_exports_the_function_binding() { + let module = lower_src( + "function F(this: any, a: any) { this.a = a; }\n\ + F.prototype.m = function () { return 1; };\n\ + export default F;\n", + ); + assert_default_is_function(&module, "F"); + } + + #[test] + fn export_default_ahead_of_hoisted_declaration_marks_it_exported() { + let module = lower_src("export default F;\nfunction F() { return 1; }\n"); + assert_default_is_function(&module, "F"); + } + + #[test] + fn export_alias_ahead_of_hoisted_declaration_marks_it_exported() { + let module = lower_src("export { F as default };\nfunction F() { return 1; }\n"); + assert_default_is_function(&module, "F"); + } + + #[test] + fn export_default_sees_through_parens_and_type_assertions() { + let module = lower_src("function F() { return 1; }\nexport default ((F as any)!);\n"); + assert_default_is_function(&module, "F"); + } + + #[test] + fn value_alias_does_not_mark_the_aliased_body_exported() { + let module = lower_src("export const g = F;\nfunction F() { return 1; }\n"); + let func = module.functions.iter().find(|f| f.name == "F").unwrap(); + assert!( + module + .exported_functions + .iter() + .any(|(name, id)| name == "g" && *id == func.id), + "{:?}", + module.exported_functions + ); + assert!( + !func.is_exported, + "`export const g = F` exports `g`, not `F`" + ); + } +} diff --git a/crates/perry-hir/src/lower/pre_scan.rs b/crates/perry-hir/src/lower/pre_scan.rs index ff378daba8..0638eebb4a 100644 --- a/crates/perry-hir/src/lower/pre_scan.rs +++ b/crates/perry-hir/src/lower/pre_scan.rs @@ -11,9 +11,11 @@ use super::*; use crate::ir::*; mod class_decl_names; +mod function_ctor_reach; mod weakref_locals; pub(crate) use class_decl_names::pre_scan_class_decl_names; +pub(crate) use function_ctor_reach::pre_scan_function_ctor_reach; pub(crate) use weakref_locals::pre_scan_weakref_locals; /// Pre-scan top-level function declarations for the standard TypeScript diff --git a/crates/perry-hir/src/lower/pre_scan/function_ctor_reach.rs b/crates/perry-hir/src/lower/pre_scan/function_ctor_reach.rs new file mode 100644 index 0000000000..34de28d948 --- /dev/null +++ b/crates/perry-hir/src/lower/pre_scan/function_ctor_reach.rs @@ -0,0 +1,377 @@ +//! #10421: can this module reach the `Function` constructor as a VALUE? +//! +//! The auto-optimized runtime links the `dyn-eval` interpreter only when the +//! compile records a reason to (`eval_classifier::has_deferred_dynamic_code_sites`). +//! Literal `new Function(...)` / `Function(...)` sites record it from their own +//! lowering. A constructor reached any other way never passed through that +//! lowering, so the program compiled, linked a runtime without the interpreter, +//! and threw `dynamic code generation ... is not supported` at the first call — +//! while a `PERRY_NO_AUTO_OPTIMIZE=1` build of the same program worked. +//! +//! This scan names those other ways. It is deliberately over-approximate: a +//! false positive links an interpreter the program never calls (binary size), +//! a false negative breaks the program at runtime. +//! +//! - any value use of the identifier `Function` (`const F = Function`, +//! `Reflect.construct(Function, …)`, `module.exports = Function`, +//! `Function.bind(…)`, `class X extends Function`), except the uses that +//! cannot construct: `Function.prototype` / `.name` / `.length`, +//! `typeof Function`, `x instanceof Function`, `x === Function`. The literal +//! `Function(…)`, `Function.call(…)`, `Function.apply(…)` and +//! `new Function(…)` callees are left to their lowering, which records the +//! site only when the constant fold did not compile it; +//! - a property named `Function` on anything (`globalThis.Function`, +//! lodash's `var Function = context.Function`, `{ Function: F } = …`); +//! - `globalThis[key]` / `global[key]` with a key that is not a literal; +//! - `.constructor` read on something that is statically a function (a +//! function, arrow or class expression, `Object.getPrototypeOf` of one, or a +//! name declared as one), and any `x.constructor(…)` call — calling a +//! constructor without `new` only produces a value for a plain function, +//! whose `constructor` is `Function`. + +use std::collections::HashSet; +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitWith}; + +/// Record the module for the auto-optimize `dyn-eval` decision when it can +/// reach the `Function` constructor through a value. +pub(crate) fn pre_scan_function_ctor_reach(ast_module: &ast::Module) { + // Once any module needs the interpreter the answer cannot change. + if crate::eval_classifier::has_deferred_dynamic_code_sites() { + return; + } + if module_reaches_function_ctor_value(ast_module) { + crate::eval_classifier::note_dynamic_function_reachable(); + } +} + +pub(crate) fn module_reaches_function_ctor_value(ast_module: &ast::Module) -> bool { + let mut scan = Scan::default(); + ast_module.visit_with(&mut scan); + scan.found + || scan + .constructor_receivers + .iter() + .any(|name| scan.function_names.contains(name)) +} + +#[derive(Default)] +struct Scan { + found: bool, + /// Names declared as a function / class, or initialized with a function, + /// arrow or class expression. + function_names: HashSet, + /// Identifiers whose `.constructor` is read. Resolved against + /// `function_names` after the walk, since declarations hoist. + constructor_receivers: Vec, +} + +fn peel(mut e: &ast::Expr) -> &ast::Expr { + loop { + match e { + ast::Expr::Paren(p) => e = &p.expr, + ast::Expr::TsAs(x) => e = &x.expr, + ast::Expr::TsTypeAssertion(x) => e = &x.expr, + ast::Expr::TsNonNull(x) => e = &x.expr, + ast::Expr::TsConstAssertion(x) => e = &x.expr, + ast::Expr::TsSatisfies(x) => e = &x.expr, + _ => return e, + } + } +} + +fn is_ident(e: &ast::Expr, name: &str) -> bool { + matches!(peel(e), ast::Expr::Ident(id) if id.sym.as_ref() == name) +} + +/// The static name of a member property: `.name` or `["name"]`. +fn member_prop_name(m: &ast::MemberExpr) -> Option<&str> { + match &m.prop { + ast::MemberProp::Ident(id) => Some(id.sym.as_ref()), + ast::MemberProp::Computed(c) => match peel(&c.expr) { + ast::Expr::Lit(ast::Lit::Str(s)) => s.value.as_str(), + _ => None, + }, + ast::MemberProp::PrivateName(_) => None, + } +} + +/// `recv` when `e` is a plain `recv.constructor` read. +fn constructor_read_receiver(e: &ast::Expr) -> Option<&ast::Expr> { + match peel(e) { + ast::Expr::Member(m) if member_prop_name(m) == Some("constructor") => Some(m.obj.as_ref()), + _ => None, + } +} + +fn is_function_like(e: &ast::Expr) -> bool { + match peel(e) { + ast::Expr::Fn(_) | ast::Expr::Arrow(_) | ast::Expr::Class(_) => true, + // `Function.constructor`, `Function.prototype.constructor`. + ast::Expr::Ident(id) => id.sym.as_ref() == "Function", + ast::Expr::Member(m) => match member_prop_name(m) { + Some("prototype") => is_ident(&m.obj, "Function"), + Some("__proto__") => is_function_like(&m.obj), + _ => false, + }, + // `Object.getPrototypeOf(async function () {})` — the AsyncFunction / + // GeneratorFunction constructor idiom. + ast::Expr::Call(call) => { + let ast::Callee::Expr(callee) = &call.callee else { + return false; + }; + let ast::Expr::Member(m) = peel(callee) else { + return false; + }; + member_prop_name(m) == Some("getPrototypeOf") + && (is_ident(&m.obj, "Object") || is_ident(&m.obj, "Reflect")) + && call.args.first().is_some_and(|a| is_function_like(&a.expr)) + } + _ => false, + } +} + +impl Visit for Scan { + fn visit_expr(&mut self, expr: &ast::Expr) { + if self.found { + return; + } + if let ast::Expr::Ident(id) = expr { + if id.sym.as_ref() == "Function" { + self.found = true; + } + return; + } + expr.visit_children_with(self); + } + + fn visit_call_expr(&mut self, call: &ast::CallExpr) { + if let ast::Callee::Expr(callee) = &call.callee { + let callee = peel(callee); + let literal_ctor_call = is_ident(callee, "Function") + || matches!(callee, ast::Expr::Member(m) + if is_ident(&m.obj, "Function") + && matches!(member_prop_name(m), Some("call" | "apply"))); + if literal_ctor_call { + call.args.visit_with(self); + return; + } + if let ast::Expr::Member(m) = callee { + if member_prop_name(m) == Some("constructor") && !call.args.is_empty() { + self.found = true; + return; + } + } + } + call.visit_children_with(self); + } + + fn visit_new_expr(&mut self, new_expr: &ast::NewExpr) { + if is_ident(&new_expr.callee, "Function") { + new_expr.args.visit_with(self); + return; + } + new_expr.visit_children_with(self); + } + + fn visit_member_expr(&mut self, m: &ast::MemberExpr) { + match member_prop_name(m) { + Some("Function") => { + self.found = true; + return; + } + Some("constructor") => { + if is_function_like(&m.obj) { + self.found = true; + return; + } + if let ast::Expr::Ident(id) = peel(&m.obj) { + self.constructor_receivers.push(id.sym.to_string()); + } + } + Some("prototype" | "name" | "length") if is_ident(&m.obj, "Function") => return, + // `x.constructor.name` only inspects the constructor. + Some("name") => { + if let Some(receiver) = constructor_read_receiver(&m.obj) { + receiver.visit_with(self); + return; + } + } + _ => {} + } + if let ast::MemberProp::Computed(c) = &m.prop { + if !matches!(peel(&c.expr), ast::Expr::Lit(_)) + && (is_ident(&m.obj, "globalThis") || is_ident(&m.obj, "global")) + { + self.found = true; + return; + } + } + m.visit_children_with(self); + } + + fn visit_unary_expr(&mut self, u: &ast::UnaryExpr) { + if u.op == ast::UnaryOp::TypeOf && is_ident(&u.arg, "Function") { + return; + } + u.visit_children_with(self); + } + + fn visit_bin_expr(&mut self, b: &ast::BinExpr) { + if matches!( + b.op, + ast::BinaryOp::InstanceOf + | ast::BinaryOp::EqEq + | ast::BinaryOp::NotEq + | ast::BinaryOp::EqEqEq + | ast::BinaryOp::NotEqEq + ) { + for side in [&b.left, &b.right] { + if is_ident(side, "Function") { + continue; + } + // `x.constructor === Y` compares the constructor, it does not + // hand it on. + match constructor_read_receiver(side) { + Some(receiver) => receiver.visit_with(self), + None => side.as_ref().visit_with(self), + } + } + return; + } + b.visit_children_with(self); + } + + fn visit_prop(&mut self, p: &ast::Prop) { + if let ast::Prop::Shorthand(id) = p { + if id.sym.as_ref() == "Function" { + self.found = true; + return; + } + } + p.visit_children_with(self); + } + + fn visit_object_pat_prop(&mut self, p: &ast::ObjectPatProp) { + let key_is_function = match p { + ast::ObjectPatProp::KeyValue(kv) => match &kv.key { + ast::PropName::Ident(id) => id.sym.as_ref() == "Function", + ast::PropName::Str(s) => s.value.as_str() == Some("Function"), + _ => false, + }, + ast::ObjectPatProp::Assign(a) => a.key.id.sym.as_ref() == "Function", + ast::ObjectPatProp::Rest(_) => false, + }; + if key_is_function { + self.found = true; + return; + } + p.visit_children_with(self); + } + + fn visit_fn_decl(&mut self, f: &ast::FnDecl) { + self.function_names.insert(f.ident.sym.to_string()); + f.visit_children_with(self); + } + + fn visit_class_decl(&mut self, c: &ast::ClassDecl) { + self.function_names.insert(c.ident.sym.to_string()); + c.visit_children_with(self); + } + + fn visit_var_declarator(&mut self, d: &ast::VarDeclarator) { + if let (ast::Pat::Ident(binding), Some(init)) = (&d.name, &d.init) { + if matches!( + peel(init), + ast::Expr::Fn(_) | ast::Expr::Arrow(_) | ast::Expr::Class(_) + ) { + self.function_names.insert(binding.id.sym.to_string()); + } + } + d.visit_children_with(self); + } +} + +#[cfg(test)] +mod tests { + use super::module_reaches_function_ctor_value; + + fn reaches(source: &str) -> bool { + let module = perry_parser::parse_typescript(source, "function-ctor-reach.ts") + .unwrap_or_else(|e| panic!("fixture must parse: {e}\n{source}")); + module_reaches_function_ctor_value(&module) + } + + #[test] + fn value_uses_of_the_constructor_reach_it() { + for source in [ + "const F = Function; new F('a', 'return a');", + "const F: any = Function; F('return 1');", + "Reflect.construct(Function, ['return 1']);", + "module.exports = Function;", + "Function.bind(null, 'a')('return a');", + "new (globalThis.Function)('return 1');", + "new globalThis['Function']('return 1');", + "function lodash(context: any) { var Function = context.Function; }", + "const { Function: F } = globalThis;", + "const { Function } = globalThis as any;", + "const o = { Function };", + "new (globalThis['Func' + 'tion'])('return 1');", + "const k = 'Function'; global[k];", + "class X extends Function {}", + "(function () {}).constructor('return 1');", + "const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;", + "const G = (function* () {}).constructor;", + "function f() {} const F = f.constructor;", + "const g = () => 1; const F = (g as any).constructor;", + "declare const x: any; x.constructor('return 1');", + "const F = Function.prototype.constructor;", + "Function.call.bind(Function);", + "(Function as any)?.bind(null);", + ] { + assert!( + reaches(source), + "must reach the Function constructor: {source}" + ); + } + } + + /// The common non-constructing uses must stay free: they are everywhere in + /// npm code, and each one linking the interpreter would grow the binary. + #[test] + fn non_constructing_uses_do_not_reach_it() { + for source in [ + "console.log('hello');", + "const s = Function.prototype.toString.call(() => 1);", + "const bind = Function.prototype.bind;", + "const n = Function.name + Function.length;", + "const t = typeof Function;", + "declare const v: unknown; const ok = v instanceof Function;", + "declare const c: unknown; const same = c === Function || Function !== c;", + "class A { clone() { return new (this.constructor as any)(); } }", + "declare const o: any; const C = o.constructor; const k = o.constructor.name;", + "const f = () => 1; const same = f.constructor === Function;", + "function g() {} const n = g.constructor.name;", + "const n = (async function () {}).constructor.name;", + "const g = globalThis['process'];", + "declare const self: any, key: string; self[key];", + "let x: Function = () => 1;", + // Literal constructor sites are recorded by their own lowering. + "new Function('a', 'return a');", + "Function('return 1');", + "declare const body: string; Function.apply(null, ['a', body]);", + "declare const body: string; Function.call(null, 'a', body);", + ] { + assert!( + !reaches(source), + "must not reach the Function constructor: {source}" + ); + } + } + + #[test] + fn literal_constructor_arguments_are_still_scanned() { + assert!(reaches("new Function(Function.bind(null), 'return 1');")); + assert!(reaches("Function('a', (globalThis as any).Function);")); + } +} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index b14aee3240..f127f3eff9 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1988,6 +1988,7 @@ mod unresolved_new_global; mod global_this_new_shadowed; mod capture_stash; +mod function_ctor_runtime_routing; mod mixin_parent_chain; mod native_module_sync; diff --git a/crates/perry-hir/src/lower/tests/function_ctor_runtime_routing.rs b/crates/perry-hir/src/lower/tests/function_ctor_runtime_routing.rs new file mode 100644 index 0000000000..f04e9ab774 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/function_ctor_runtime_routing.rs @@ -0,0 +1,78 @@ +//! #10422 / #10424: a `Function` constructor call the constant fold cannot +//! compile reaches the runtime from-strings constructor with its arguments +//! intact. Split from `tests.rs` for the 2000-line cap. + +fn lowered_function_debug(source: &str, name: &str) -> String { + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let function = hir + .functions + .iter() + .find(|function| function.name == name) + .unwrap_or_else(|| panic!("{name} is lowered")); + format!("{function:?}") +} + +const RUNTIME_CONSTRUCT: &str = r#"New { class_name: "Function""#; +const AOT_STUB: &str = "cannot run in an ahead-of-time compiled binary"; + +/// The call form used to lower a runtime-built body to a function that always +/// threw, and an unfoldable constant call (an array of parameter names) to a +/// plain call of the global that returned `undefined`. Both construct now, +/// like `new Function(...)`; the `.apply` / `.call` spellings keep the call of +/// the `Function` value, which the runtime routes to the same constructor. +#[test] +fn unfolded_function_calls_construct_at_runtime() { + let source = r#" + export function direct(body: string): any { return Function("a", "b", body); } + export function arrayParams(): any { return Function(["a", "b"] as any, "return a + b"); } + export function viaApply(body: string): any { return Function.apply(null, ["a", body]); } + export function viaCall(body: string): any { return Function.call(null, "a", body); } + export function folded(): any { return Function("a", "return a"); } + "#; + for name in ["direct", "arrayParams"] { + let debug = lowered_function_debug(source, name); + assert!( + debug.contains(RUNTIME_CONSTRUCT), + "{name} must construct at runtime:\n{debug}" + ); + assert!( + !debug.contains(AOT_STUB), + "{name} must not throw the AOT stub:\n{debug}" + ); + } + for (name, method) in [("viaApply", "apply"), ("viaCall", "call")] { + let debug = lowered_function_debug(source, name); + assert!( + !debug.contains(AOT_STUB), + "{name} must not throw the AOT stub:\n{debug}" + ); + assert!( + debug.contains(&format!(r#"property: "{method}""#)) + && debug.contains(r#"property: "Function""#), + "{name} must call `Function.{method}` on the global value:\n{debug}" + ); + } + let folded = lowered_function_debug(source, "folded"); + assert!( + !folded.contains(RUNTIME_CONSTRUCT), + "an all-constant call still compiles ahead of time:\n{folded}" + ); +} + +/// `new Function(...parts)` passed the spread array as ONE argument, which the +/// runtime turned into an empty body. It must keep the spread positions. +#[test] +fn spread_new_function_keeps_its_spread_arguments() { + let source = r#" + export function spread(body: string): any { return new Function(...["a", "b"], body); } + "#; + let debug = lowered_function_debug(source, "spread"); + assert!( + debug.contains( + r#"NewDynamicSpread { callee: PropertyGet { object: GlobalGet(0), property: "Function""# + ) && debug.contains("Spread("), + "a spread `new Function` must construct element by element:\n{debug}" + ); + assert!(!debug.contains(RUNTIME_CONSTRUCT), "{debug}"); +} diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index c7f60ea0d7..b3fc1819d2 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -111,15 +111,6 @@ pub(crate) fn scan_function_supported(func: &ast::Function) { if func.is_async { throw_unsupported("async function (no async codegen target needs it yet)"); } - for p in &func.params { - scan_param_supported(&p.pat); - } -} - -fn scan_param_supported(pat: &ast::Pat) { - if let ast::Pat::Rest(_) = pat { - throw_unsupported("rest parameter (...args)"); - } } /// `var` hoisting prepass: collect `var` names declared anywhere in the @@ -405,6 +396,17 @@ pub(crate) fn invoke_interp_fn( // Parameters. for (i, pat) in fun.params.iter().enumerate() { + // #10424: `...rest` (always the last parameter) collects every argument + // from its position on. `new Function('...a', body)` used to refuse it + // while the constant-fold spelling of the same call compiled. + if let ast::Pat::Rest(rest) = pat { + let rest_idx = root_push(bridge::array_new()); + for &arg_idx in arg_idxs.iter().skip(i) { + bridge::array_push_rooted(rest_idx, root_get(arg_idx)); + } + bind_pattern(&ctx, &rest.arg, root_get(rest_idx), env_idx, true); + break; + } let value = if i < nargs { root_get(arg_idxs[i]) } else { diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index a74b5c18b4..7696d0dd12 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -64,6 +64,21 @@ fn returns_arithmetic_on_parameters() { assert_eq!(as_num(r), 42.0); } +/// #10424: a rest parameter collects the trailing arguments — on the +/// constructed function and on a function declared inside its body. The +/// interpreter used to refuse it at construction. +#[test] +fn rest_parameters_collect_trailing_arguments() { + let f = dyn_fn(&["a", "...rest", "return a + rest.length * 10 + rest[1]"]); + let r = call(f, &[num(1.0), num(2.0), num(3.0), num(4.0)]); + assert_eq!(as_num(r), 34.0); + let r = call(f, &[num(1.0)]); + assert!(as_num(r).is_nan(), "rest is empty, so rest[1] is undefined"); + + let inner = dyn_fn(&["return ((...xs) => xs.length)(7, 8, 9)"]); + assert_eq!(as_num(call(inner, &[])), 3.0); +} + #[test] fn empty_body_returns_undefined_zod_probe() { // zod's JIT probe is `new Function("")` — it must now SUCCEED and yield a diff --git a/crates/perry-runtime/src/fs/callbacks.rs b/crates/perry-runtime/src/fs/callbacks.rs index 7e3804d845..0813110a5f 100644 --- a/crates/perry-runtime/src/fs/callbacks.rs +++ b/crates/perry-runtime/src/fs/callbacks.rs @@ -10,34 +10,18 @@ pub extern "C" fn js_fs_read_file_callback(path_value: f64, encoding: f64, callb const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; let cb_ptr = callback_from_options_arg(encoding, callback); - unsafe { - if let Some(err_val) = fs_callback_read_error(path_value, "open") { - if !cb_ptr.is_null() { - defer_fs_callback_chain(cb_ptr, &[err_val, f64::from_bits(TAG_UNDEFINED)], 4); - } - return f64::from_bits(TAG_UNDEFINED); - } - } let encoding_is_callback = !extract_closure_ptr(encoding).is_null(); let want_buffer = encoding_is_callback || read_file_encoding(encoding).is_none(); - let data_val = if want_buffer { - let buf = js_fs_read_file_binary_options(path_value, encoding); - if buf.is_null() { - f64::from_bits(TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) - } - } else { - let str_ptr = js_fs_read_file_sync_options(path_value, encoding); - if str_ptr.is_null() { - f64::from_bits(TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::js_nanbox_string(str_ptr as i64).to_bits()) - } + // One read decides both arms. The old pre-flight `stat` probe only saw a + // missing path, so a directory reached the reader, whose failure became + // `(null, undefined)` for a Buffer and a synchronous throw for a string + // (#10452); Node reports `EISDIR ... read` through the callback. + let args = match unsafe { read_file_value_result(path_value, encoding, !want_buffer) } { + Ok(data_val) => [f64::from_bits(TAG_NULL), data_val], + Err(err_val) => [err_val, f64::from_bits(TAG_UNDEFINED)], }; - if !cb_ptr.is_null() { - defer_fs_callback_chain(cb_ptr, &[f64::from_bits(TAG_NULL), data_val], 4); + defer_fs_callback_chain(cb_ptr, &args, 4); } f64::from_bits(TAG_UNDEFINED) } diff --git a/crates/perry-runtime/src/fs/errors.rs b/crates/perry-runtime/src/fs/errors.rs index 16df1c3226..e8d6eaf188 100644 --- a/crates/perry-runtime/src/fs/errors.rs +++ b/crates/perry-runtime/src/fs/errors.rs @@ -4,6 +4,15 @@ use super::*; pub(crate) fn io_error_code(err: &std::io::Error) -> &'static str { + // #10539 review: on Windows `raw_os_error` is a Win32 code, not an errno, + // so it goes through libuv's own translation table. + #[cfg(windows)] + if let Some((_, code)) = err + .raw_os_error() + .and_then(crate::util_syserr::win32_error_to_uv) + { + return code; + } #[cfg(unix)] if let Some(raw) = err.raw_os_error() { match raw { @@ -59,6 +68,13 @@ pub(crate) fn io_error_errno(err: &std::io::Error) -> i32 { if let Some(raw) = err.raw_os_error() { return -raw; } + // Windows has no errno to negate: libuv gives each code a fixed negative + // number there (`ENOENT` is -4058, not -2), and that is what node reports. + #[cfg(windows)] + { + const UV_WINDOWS_EIO: i32 = -4070; + return crate::util_syserr::uv_windows_errno(io_error_code(err)).unwrap_or(UV_WINDOWS_EIO); + } #[cfg(unix)] match io_error_code(err) { "ENOENT" => -libc::ENOENT, @@ -84,7 +100,7 @@ pub(crate) fn io_error_errno(err: &std::io::Error) -> i32 { "EXDEV" => -libc::EXDEV, _ => -libc::EIO, } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] match io_error_code(err) { "ENOENT" => -2, "EACCES" => -13, @@ -150,6 +166,20 @@ unsafe fn attach_fs_error_props( } } +/// The description Node puts in an fs error message: libuv's fixed lowercase +/// phrasing for the errno ("no such file or directory"), not Rust's `Display`, +/// which reads "No such file or directory (os error 2)" (#10451). An error +/// synthesized without an OS errno keeps its own text. +fn fs_error_description(err: &std::io::Error) -> String { + if err.raw_os_error().is_some() { + let code = io_error_errno(err) as i64; + if let Some(message) = crate::util_syserr::system_error_message_for_code(code) { + return message.to_string(); + } + } + err.to_string() +} + pub(crate) unsafe fn build_fs_error_value( err: &std::io::Error, syscall: &'static str, @@ -157,7 +187,8 @@ pub(crate) unsafe fn build_fs_error_value( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {} '{}'", code, err, syscall, path); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {} '{}'", code, desc, syscall, path); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, Some(path), None); @@ -175,7 +206,8 @@ pub(crate) unsafe fn build_fs_error_value_with_dest( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {} '{}' -> '{}'", code, err, syscall, path, dest); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {} '{}' -> '{}'", code, desc, syscall, path, dest); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, Some(path), Some(dest)); @@ -188,13 +220,73 @@ pub(crate) unsafe fn build_fs_error_value_no_path( ) -> f64 { let code = io_error_code(err); let errno = io_error_errno(err); - let msg = format!("{}: {}, {}", code, err, syscall); + let desc = fs_error_description(err); + let msg = format!("{}: {}, {}", code, desc, syscall); let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = crate::error::js_error_new_with_message(msg_ptr); attach_fs_error_props(err_ptr, code, errno, syscall, None, None); crate::value::js_nanbox_pointer(err_ptr as i64) } +/// An OS "no such file or directory" error. `libc::ENOENT` is 2 on Windows too, +/// where `from_raw_os_error` reads it as `ERROR_FILE_NOT_FOUND` — which libuv +/// also translates to `ENOENT`. +pub(crate) fn enoent_os_error() -> std::io::Error { + std::io::Error::from_raw_os_error(libc::ENOENT) +} + +/// An OS "bad file descriptor" error. On Windows this must be +/// `ERROR_INVALID_HANDLE`, the Win32 error libuv translates to `EBADF`: +/// `libc::EBADF` (9) is `ERROR_INVALID_BLOCK` there and translates to nothing. +pub(crate) fn ebadf_os_error() -> std::io::Error { + #[cfg(windows)] + { + const ERROR_INVALID_HANDLE: i32 = 6; + std::io::Error::from_raw_os_error(ERROR_INVALID_HANDLE) + } + #[cfg(not(windows))] + { + std::io::Error::from_raw_os_error(libc::EBADF) + } +} + +/// A failed file read (`readFile`, a read stream): the OS error plus the +/// syscall Node reports it under. Node opens before it reads, so a missing file +/// fails the `open` and names the path, while a directory opens fine and fails +/// the `read`, which Node reports without a path +/// (`EISDIR: illegal operation on a directory, read`). +pub(crate) struct FsReadFailure { + pub(super) err: std::io::Error, + syscall: &'static str, + path: Option, +} + +impl FsReadFailure { + pub(crate) fn open(err: std::io::Error, path: &str) -> Self { + let path = Some(path.to_string()); + Self { + err, + syscall: "open", + path, + } + } + + pub(crate) fn read(err: std::io::Error) -> Self { + Self { + err, + syscall: "read", + path: None, + } + } + + pub(crate) unsafe fn error_value(&self) -> f64 { + match &self.path { + Some(path) => build_fs_error_value(&self.err, self.syscall, path), + None => build_fs_error_value_no_path(&self.err, self.syscall), + } + } +} + /// Probe a path for read access and produce a NaN-boxed Error if the /// underlying syscall would fail. Returns `None` on success. pub(crate) unsafe fn fs_callback_read_error(path_value: f64, syscall: &'static str) -> Option { @@ -237,3 +329,65 @@ pub(crate) unsafe fn fs_callback_write_parent_error( Err(err) => Some(build_fs_error_value(&err, syscall, &path)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #10451: an fs error message carries libuv's description of the errno, + /// as Node's does, not Rust's "No such file or directory (os error 2)". + #[cfg(unix)] + #[test] + fn fs_error_description_uses_libuv_text() { + let described = |errno| fs_error_description(&std::io::Error::from_raw_os_error(errno)); + assert_eq!(described(libc::ENOENT), "no such file or directory"); + assert_eq!(described(libc::EISDIR), "illegal operation on a directory"); + assert_eq!(described(libc::EACCES), "permission denied"); + // A synthesized error has no errno to describe and keeps its own text. + let custom = std::io::Error::new(std::io::ErrorKind::NotFound, "parent is not a directory"); + assert_eq!(fs_error_description(&custom), "parent is not a directory"); + } + + /// #10452: every `readFile` form reads through `read_file_bytes_with_options`, + /// whose failures used to be a bare `None` the Buffer forms returned as + /// `null`/`undefined`. A missing file must fail the `open` and name the path; + /// a directory opens and must fail the `read`, which Node reports pathless. + #[cfg(unix)] + #[test] + fn read_file_failures_keep_the_os_error_and_failing_syscall() { + let _global = crate::gc::global_side_table_test_lock(); + let dir = + std::env::temp_dir().join(format!("perry_fs_read_failure_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ok.txt"); + std::fs::write(&file, b"hello").unwrap(); + let missing = dir.join("missing.txt"); + let path_value = |path: &std::path::Path| { + let path = path.to_str().unwrap(); + let ptr = js_string_from_bytes(path.as_ptr(), path.len() as u32); + crate::value::js_nanbox_string(ptr as i64) + }; + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + + let failure = read_file_bytes_with_options(path_value(&missing), undefined) + .err() + .expect("a missing file must fail"); + assert_eq!(failure.err.raw_os_error(), Some(libc::ENOENT)); + assert_eq!( + (failure.syscall, failure.path.as_deref()), + ("open", missing.to_str()) + ); + + let failure = read_file_bytes_with_options(path_value(&dir), undefined) + .err() + .expect("a directory must fail"); + assert_eq!(failure.err.raw_os_error(), Some(libc::EISDIR)); + assert_eq!((failure.syscall, failure.path.as_deref()), ("read", None)); + + let bytes = read_file_bytes_with_options(path_value(&file), undefined) + .ok() + .expect("a regular file reads"); + assert_eq!(bytes, b"hello"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/perry-runtime/src/fs/filehandle.rs b/crates/perry-runtime/src/fs/filehandle.rs index be05274c27..d01cef4e87 100644 --- a/crates/perry-runtime/src/fs/filehandle.rs +++ b/crates/perry-runtime/src/fs/filehandle.rs @@ -1144,7 +1144,11 @@ pub(crate) extern "C" fn filehandle_read_file_impl( return promise_value_fs(f64::from_bits(crate::value::TAG_UNDEFINED)); }; let mut bytes = Vec::new(); - let _ = file.read_to_end(&mut bytes); + if let Err(err) = file.read_to_end(&mut bytes) { + // #10452: a failed read rejects (`EISDIR ... read` for a directory) + // instead of resolving with whatever was read before it failed. + return promise_rejected_fs(unsafe { build_fs_error_value_no_path(&err, "read") }); + } if read_file_encoding(encoding).is_none() { let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); if !buf.is_null() { diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index 7875ec484e..91cf60bb17 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -301,9 +301,8 @@ fn numeric_fd_value(value: f64) -> Option { } } -/// Read a file synchronously and return its contents as a string -/// Returns null pointer on error -/// Accepts NaN-boxed string path +/// Read a file synchronously and return its contents as a string. +/// Throws a Node-shaped fs error on failure. Accepts NaN-boxed string path. // These readFileSync entry points intentionally throw on I/O failure. They // must permit the generated landingpad transport to cross their Rust FFI // frames so Node-style `try { readFileSync(optional) } catch { ... }` works @@ -321,15 +320,14 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( validate::validate_path_or_fd("path", path_value, "read"); validate::validate_string_or_object_options("options", options_value); unsafe { - let _path_str_for_log = decode_path_value(path_value).unwrap_or_default(); - // Debug: log path on Android #[cfg(target_os = "android")] { extern "C" { fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; } - let c_path = std::ffi::CString::new(_path_str_for_log).unwrap_or_default(); + let path_str_for_log = decode_path_value(path_value).unwrap_or_default(); + let c_path = std::ffi::CString::new(path_str_for_log).unwrap_or_default(); __android_log_print( 3, b"PerryFS\0".as_ptr(), @@ -339,7 +337,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( } match read_file_bytes_with_options(path_value, options_value) { - Some(bytes) => { + Ok(bytes) => { #[cfg(target_os = "android")] { extern "C" { @@ -359,7 +357,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( } js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } - None => { + Err(failure) => { #[cfg(target_os = "android")] { extern "C" { @@ -389,13 +387,7 @@ pub extern "C-unwind" fn js_fs_read_file_sync_options( // a Node-shaped fs error instead. This is a real, catchable JS // throw (caught by JS try/catch) — NOT the null-pointer segfault // the previous empty-string workaround was guarding against. - let path_str = decode_path_value(path_value).unwrap_or_default(); - let io_err = std::fs::read(&path_str) - .err() - .unwrap_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound)); - crate::exception::js_throw(crate::fs::errors::build_fs_error_value( - &io_err, "open", &path_str, - )) + crate::exception::js_throw(failure.error_value()) } } } @@ -407,15 +399,33 @@ pub extern "C-unwind" fn js_fs_read_file_dispatch(path_value: f64, options_value let str_ptr = js_fs_read_file_sync_options(path_value, options_value); f64::from_bits(crate::value::JSValue::string_ptr(str_ptr).bits()) } else { + // Throws on failure (#10452) — never null, so never `undefined`. let buf = js_fs_read_file_binary_options(path_value, options_value); - if buf.is_null() { - f64::from_bits(crate::value::TAG_UNDEFINED) - } else { - f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) - } + f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) } } +/// `readFile`'s shared core for the sync, callback and promise forms (#10452): +/// the contents as a string (`as_string`) or a Buffer, or the Node-shaped +/// error value to throw, hand to the callback, or reject with. +pub(crate) unsafe fn read_file_value_result( + path_value: f64, + options_value: f64, + as_string: bool, +) -> Result { + validate::validate_path_or_fd("path", path_value, "read"); + validate::validate_string_or_object_options("options", options_value); + let bytes = read_file_bytes_with_options(path_value, options_value) + .map_err(|failure| failure.error_value())?; + Ok(if as_string { + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(str_ptr).bits()) + } else { + let buf = buffer_from_file_bytes(&bytes); + f64::from_bits(crate::value::JSValue::pointer(buf as *const u8).bits()) + }) +} + /// Write content to a file synchronously /// Returns 1 on success, 0 on failure /// Accepts NaN-boxed string values @@ -439,7 +449,7 @@ fn js_string_value(value: f64) -> Option { } } -fn read_file_encoding(options_value: f64) -> Option { +pub(crate) fn read_file_encoding(options_value: f64) -> Option { let value = crate::value::JSValue::from_bits(options_value.to_bits()); if value.is_undefined() || value.is_null() { return None; @@ -500,18 +510,28 @@ fn open_file_for_read_flag(path: &str, flag: &str) -> std::io::Result opts.open(path) } -fn read_file_bytes_with_options(path_value: f64, options_value: f64) -> Option> { +/// The bytes behind every `readFile` form. Failures used to collapse into +/// `None`, which the Buffer forms turned into `null`/`undefined` instead of an +/// error (#10452); they now carry the OS error and the failing syscall. +fn read_file_bytes_with_options( + path_value: f64, + options_value: f64, +) -> Result, FsReadFailure> { unsafe { if let Some(fd) = numeric_fd_value(path_value) { let mut bytes = Vec::new(); - FD_REGISTRY.with(|r| { - if let Some(file) = r.borrow_mut().get_mut(&fd) { - let _ = file.read_to_end(&mut bytes); - } + let read = FD_REGISTRY.with(|r| { + r.borrow_mut() + .get_mut(&fd) + .map(|file| file.read_to_end(&mut bytes)) }); - return Some(bytes); + // `validate_path_or_fd` already threw EBADF for an unknown fd. + let read = read.unwrap_or_else(|| Err(ebadf_os_error())); + return read.map(|_| bytes).map_err(FsReadFailure::read); } - let path_str = decode_path_value(path_value)?; + let Some(path_str) = decode_path_value(path_value) else { + return Err(FsReadFailure::open(enoent_os_error(), "")); + }; // #5731 — virtual filesystem: a `$perryfs/...` path (or a bare key that // matches an embedded asset) is served from the in-binary registry // before any disk access, so `fs.readFileSync`/`readFile` (text and @@ -519,16 +539,19 @@ fn read_file_bytes_with_options(path_value: f64, options_value: f64) -> Option file, + Err(err) => return Err(FsReadFailure::open(err, &path_str)), + }; let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).ok()?; - Some(bytes) + file.read_to_end(&mut bytes).map_err(FsReadFailure::read)?; + Ok(bytes) } } @@ -877,15 +900,18 @@ pub extern "C" fn js_fs_chmod_sync(path_value: f64, mode: f64) -> i32 { } /// Read a file synchronously as binary and return a Buffer (binary-safe, works for PNG etc.) -/// Returns a *mut BufferHeader on success, null on error -/// Accepts NaN-boxed string path +/// Returns a *mut BufferHeader on success and throws a Node-shaped fs error on +/// failure, like the string form (#10452 — it used to return null, which the +/// callers surfaced as `null`/`undefined`). Accepts NaN-boxed string path. #[no_mangle] -pub extern "C" fn js_fs_read_file_binary(path_value: f64) -> *mut crate::buffer::BufferHeader { +pub extern "C-unwind" fn js_fs_read_file_binary( + path_value: f64, +) -> *mut crate::buffer::BufferHeader { js_fs_read_file_binary_options(path_value, f64::from_bits(crate::value::TAG_UNDEFINED)) } #[no_mangle] -pub extern "C" fn js_fs_read_file_binary_options( +pub extern "C-unwind" fn js_fs_read_file_binary_options( path_value: f64, options_value: f64, ) -> *mut crate::buffer::BufferHeader { @@ -893,21 +919,24 @@ pub extern "C" fn js_fs_read_file_binary_options( validate::validate_string_or_object_options("options", options_value); unsafe { match read_file_bytes_with_options(path_value, options_value) { - Some(bytes) => { - let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); - if !buf.is_null() { - let buf_data = - (buf as *mut u8).add(std::mem::size_of::()); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_data, bytes.len()); - (*buf).length = bytes.len() as u32; - } - buf - } - None => std::ptr::null_mut(), + Ok(bytes) => buffer_from_file_bytes(&bytes), + Err(failure) => crate::exception::js_throw(failure.error_value()), } } } +/// A fresh Buffer holding `bytes`. A new allocation is never a view or +/// foreign-backed, so its data sits directly after the header. +unsafe fn buffer_from_file_bytes(bytes: &[u8]) -> *mut crate::buffer::BufferHeader { + let buf = crate::buffer::js_buffer_alloc(bytes.len() as i32, 0); + if !buf.is_null() { + let buf_data = (buf as *mut u8).add(std::mem::size_of::()); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_data, bytes.len()); + (*buf).length = bytes.len() as u32; + } + buf +} + /// Recursively remove a directory or file. /// Returns 1 on success, 0 on failure. /// Accepts NaN-boxed string path. diff --git a/crates/perry-runtime/src/fs/stream.rs b/crates/perry-runtime/src/fs/stream.rs index 5b982175e1..a341d43bc0 100644 --- a/crates/perry-runtime/src/fs/stream.rs +++ b/crates/perry-runtime/src/fs/stream.rs @@ -88,6 +88,9 @@ pub(crate) struct StreamState { /// deferred open, handed to the pending callbacks and to `'error'`. /// `undefined` until then; `error_msg` stays the "errored" flag. error_value: f64, + /// #10451: a read stream's constructor-time open failure, held as the OS + /// error until `store_open_failure` turns it into `error_value`. + open_failure: Option, /// #9493: a turn is already parked on the callback-timer queue. turn_pending: bool, bytes_read: u64, @@ -167,6 +170,7 @@ impl StreamState { pending_writes: Vec::new(), end_callback: f64::from_bits(crate::value::TAG_UNDEFINED), error_value: f64::from_bits(crate::value::TAG_UNDEFINED), + open_failure: None, turn_pending: false, bytes_read: 0, bytes_written: 0, @@ -572,13 +576,6 @@ fn refresh_props(id: usize) { }); } -fn make_error_value(message: &str) -> f64 { - let msg = message.as_bytes(); - let err_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err_obj = crate::error::js_error_new_with_message(err_str); - crate::value::js_nanbox_pointer(err_obj as i64) -} - fn event_name(value: f64) -> String { String::from_utf8_lossy(&bytes_from_value(value)).into_owned() } @@ -709,36 +706,6 @@ fn call_js_method2(receiver: f64, name: &[u8], arg0: f64, arg1: f64) -> f64 { } } -/// The stream's stored error as a JS value: the node-shaped value the deferred -/// open produced when there is one (#9493), else an `Error` over `error_msg`. -fn stored_error_value(state: &StreamState) -> Option { - if !JSValue::from_bits(state.error_value.to_bits()).is_undefined() { - return Some(state.error_value); - } - state.error_msg.as_deref().map(make_error_value) -} - -fn emit_stored_error(id: usize) { - let error_value = STREAM_REGISTRY.with(|registry| { - let registry = registry.borrow(); - registry.get(&id).and_then(stored_error_value) - }); - if let Some(err) = error_value { - emit_event1(id, "error", err); - } -} - -fn record_stream_error(id: usize, message: String) { - STREAM_REGISTRY.with(|registry| { - if let Some(state) = registry.borrow_mut().get_mut(&id) { - state.errored = true; - state.error_msg = Some(message); - } - }); - refresh_props(id); - emit_stored_error(id); -} - fn close_fd_for_state(state: &mut StreamState) { let Some(fd) = state.fd else { state.closed = true; @@ -1271,6 +1238,8 @@ fn throw_plain_type_error_value(message: &str) -> ! { mod options_init; use options_init::*; +mod stream_errors; +use stream_errors::*; mod utf8_stream; pub(crate) use utf8_stream::*; @@ -1301,7 +1270,7 @@ fn read_chunk_value(bytes: &[u8], encoding: Option<&str>) -> f64 { } } -fn read_next_chunk(id: usize) -> Result, Option)>, String> { +fn read_next_chunk(id: usize) -> Result, Option)>, FsReadFailure> { let (fd, pos, amount, encoding) = STREAM_REGISTRY.with(|registry| { let registry = registry.borrow(); let Some(state) = registry.get(&id) else { @@ -1325,18 +1294,19 @@ fn read_next_chunk(id: usize) -> Result, Option)>, Strin if amount == 0 { return Ok(None); } + let ebadf = || FsReadFailure::read(ebadf_os_error()); let Some(fd) = fd else { - return Err("bad file descriptor".to_string()); + return Err(ebadf()); }; let result = FD_REGISTRY.with(|registry| { let mut registry = registry.borrow_mut(); let Some(file) = registry.get_mut(&fd) else { - return Err("bad file descriptor".to_string()); + return Err(ebadf()); }; file.seek(SeekFrom::Start(pos)) - .map_err(|err| err.to_string())?; + .map_err(FsReadFailure::read)?; let mut buffer = vec![0; amount]; - let read = file.read(&mut buffer).map_err(|err| err.to_string())?; + let read = file.read(&mut buffer).map_err(FsReadFailure::read)?; buffer.truncate(read); Ok(buffer) })?; @@ -1481,13 +1451,13 @@ fn read_stream_pump(id: usize) { finish_read_stream(id); return; } - Err(message) => { + Err(failure) => { STREAM_REGISTRY.with(|registry| { if let Some(state) = registry.borrow_mut().get_mut(&id) { state.pumping = false; } }); - record_stream_error(id, message); + record_read_failure(id, failure); maybe_close_stream(id, false); return; } @@ -1745,6 +1715,7 @@ fn create_write_stream_with_state(state: StreamState) -> f64 { fn create_read_stream_with_state(state: StreamState) -> f64 { register_stream_method_arities(); let id = alloc_stream(state); + store_open_failure(id); let method_funcs: [(&str, extern "C" fn()); 10] = [ ("on", unsafe { std::mem::transmute::< diff --git a/crates/perry-runtime/src/fs/stream/options_init.rs b/crates/perry-runtime/src/fs/stream/options_init.rs index edb0681842..51eb810a77 100644 --- a/crates/perry-runtime/src/fs/stream/options_init.rs +++ b/crates/perry-runtime/src/fs/stream/options_init.rs @@ -3,25 +3,6 @@ use super::*; -/// Starting a ReadStream must deliver a constructor-time open failure. Without -/// this transition, event-backed consumers wait forever for data/end/error -/// after `createReadStream()` recorded an invalid path (#9616). -pub(super) fn emit_pending_read_error(id: usize) -> bool { - let pending = STREAM_REGISTRY.with(|registry| { - registry.borrow().get(&id).and_then(|state| { - (state.kind == StreamKind::Read && !state.errored) - .then(|| state.error_msg.clone()) - .flatten() - }) - }); - let Some(message) = pending else { - return false; - }; - record_stream_error(id, message); - maybe_close_stream(id, false); - true -} - pub(super) fn register_stream_method_arities() { crate::closure::js_register_closure_arity(write_stream_write_impl as *const u8, 3); crate::closure::js_register_closure_arity(write_stream_end_impl as *const u8, 3); @@ -105,8 +86,9 @@ pub(super) fn init_read_state_from_options( state.owner = FdOwner::Path; state.opened = true; } - Err((err, _path)) => { + Err((err, path)) => { state.error_msg = Some(err.to_string()); + state.open_failure = Some(FsReadFailure::open(err, &path)); } } state diff --git a/crates/perry-runtime/src/fs/stream/stream_errors.rs b/crates/perry-runtime/src/fs/stream/stream_errors.rs new file mode 100644 index 0000000000..5f2e37fadb --- /dev/null +++ b/crates/perry-runtime/src/fs/stream/stream_errors.rs @@ -0,0 +1,109 @@ +//! A stream's stored `'error'` value and its delivery, split out of +//! `stream.rs` to keep it under the 2000-line cap. + +use super::*; + +pub(super) fn make_error_value(message: &str) -> f64 { + let msg = message.as_bytes(); + let err_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err_obj = crate::error::js_error_new_with_message(err_str); + crate::value::js_nanbox_pointer(err_obj as i64) +} + +/// The stream's stored error as a JS value: the node-shaped value the deferred +/// open produced when there is one (#9493), else an `Error` over `error_msg`. +pub(super) fn stored_error_value(state: &StreamState) -> Option { + if !JSValue::from_bits(state.error_value.to_bits()).is_undefined() { + return Some(state.error_value); + } + state.error_msg.as_deref().map(make_error_value) +} + +pub(super) fn emit_stored_error(id: usize) { + let error_value = STREAM_REGISTRY.with(|registry| { + let registry = registry.borrow(); + registry.get(&id).and_then(stored_error_value) + }); + if let Some(err) = error_value { + emit_event1(id, "error", err); + } +} + +pub(super) fn record_stream_error(id: usize, message: String) { + STREAM_REGISTRY.with(|registry| { + if let Some(state) = registry.borrow_mut().get_mut(&id) { + state.errored = true; + state.error_msg = Some(message); + } + }); + refresh_props(id); + emit_stored_error(id); +} + +/// #10451: a read stream's failure as Node reports it. `error_msg` alone +/// became a bare `Error` with the Rust text ("No such file or directory (os +/// error 2)") and no `code`/`errno`/`syscall`/`path`, while the write side +/// already stored a node-shaped `error_value` (#9493). +fn store_read_failure(id: usize, failure: &FsReadFailure) { + let error_value = unsafe { failure.error_value() }; + // No allocation between building the value and storing it: from here the + // registry is the GC root that keeps it (and rewrites it if it moves). + STREAM_REGISTRY.with(|registry| { + if let Some(state) = registry.borrow_mut().get_mut(&id) { + state.error_value = error_value; + } + }); +} + +/// Turn the constructor's open failure into `error_value` once the state is +/// registered, so the `'error'` replay to a listener attached right after +/// construction and the pump's delivery both hand out the node-shaped value. +pub(super) fn store_open_failure(id: usize) { + let failure = STREAM_REGISTRY.with(|registry| { + let mut registry = registry.borrow_mut(); + registry + .get_mut(&id) + .and_then(|state| state.open_failure.take()) + }); + if let Some(failure) = failure { + store_read_failure(id, &failure); + } +} + +/// The node-shaped error a read or write stream has already stored, if any. +/// Unlike `stored_error_value` this never synthesizes a bare `Error` from +/// `error_msg`: the caller wants the failure's `code`/`syscall`/`path` or +/// nothing. +pub(super) fn stored_node_error_value(id: usize) -> Option { + STREAM_REGISTRY.with(|registry| { + let registry = registry.borrow(); + let state = registry.get(&id)?; + (!JSValue::from_bits(state.error_value.to_bits()).is_undefined()) + .then_some(state.error_value) + }) +} + +/// A failed read while pumping (`EISDIR ... read` for a directory). +pub(super) fn record_read_failure(id: usize, failure: FsReadFailure) { + store_read_failure(id, &failure); + record_stream_error(id, failure.err.to_string()); +} + +/// Starting a ReadStream must deliver a constructor-time open failure. Without +/// this transition, event-backed consumers wait forever for data/end/error +/// after `createReadStream()` recorded an invalid path (#9616). +pub(super) fn emit_pending_read_error(id: usize) -> bool { + let pending = STREAM_REGISTRY.with(|registry| { + registry.borrow().get(&id).and_then(|state| { + (state.kind == StreamKind::Read && !state.errored) + .then(|| state.error_msg.clone()) + .flatten() + }) + }); + let Some(message) = pending else { + return false; + }; + record_stream_error(id, message); + maybe_close_stream(id, false); + true +} diff --git a/crates/perry-runtime/src/fs/stream/write_file_input.rs b/crates/perry-runtime/src/fs/stream/write_file_input.rs index 600aae4ccd..e8042a059a 100644 --- a/crates/perry-runtime/src/fs/stream/write_file_input.rs +++ b/crates/perry-runtime/src/fs/stream/write_file_input.rs @@ -313,6 +313,13 @@ fn consume_fs_read_stream_for_write_file( where F: FnMut(&[u8]) -> Result<(), f64>, { + // A stream whose constructor open failed has no fd, so reading it would + // report EBADF and lose the failure the constructor stored — node rejects + // `fs.promises.writeFile(out, createReadStream(missing))` with that + // stream's `ENOENT ... open ''` (#10451 review). + if let Some(error_value) = stored_node_error_value(id) { + return Err(error_value); + } loop { check_write_file_aborted(signal)?; match read_next_chunk(id) { @@ -324,7 +331,7 @@ where finish_read_stream(id); return Ok(()); } - Err(message) => return Err(make_error_value(&message)), + Err(failure) => return Err(unsafe { failure.error_value() }), } } } diff --git a/crates/perry-runtime/src/gc/layout/transfer.rs b/crates/perry-runtime/src/gc/layout/transfer.rs index b99cce0cf2..e8eb7bbd98 100644 --- a/crates/perry-runtime/src/gc/layout/transfer.rs +++ b/crates/perry-runtime/src/gc/layout/transfer.rs @@ -17,13 +17,27 @@ //! `GC_OBJ_TYPED_LAYOUT_INTACT`. What cannot ride a header is a record keyed //! by the object's ADDRESS, and that is all this funnel moves: //! +//! * the residual static-prototype owner registry (#9304), gated by its +//! process-global latch (#7733/#7737) — for EVERY kind that can own an +//! entry, not only the layout kinds (see below); //! * the element-shape proof record (#7480), gated by the header bit that is //! authoritative for it; -//! * the residual static-prototype owner registry (#9304), gated by its -//! process-global latch (#7733/#7737); //! * the per-object `TYPED_LAYOUTS` and `LAYOUT_SLOT_MASKS` entries, gated by //! #7510's emptiness flag and address filter. //! +//! # The prototype registry is not layout metadata +//! +//! Its population is every receiver `object::prototype_chain:: +//! meta_capable_object` turns away — a Map, Set, Error, Promise, Date, RegExp, +//! Temporal cell, lazy JSON array or closure as much as an array — and most of +//! those kinds have no layout slots at all. The rekey used to sit in the array +//! arm of the record path, below the layout-kind return, and in the ordinary +//! object's move hook; every other movable owner kept its entry under the +//! address it had just left, and the dead-owner prune then dropped it. So it +//! runs first, keyed on `prototype_chain::residual_prototype_owner_type`, the +//! same population predicate the collector's value visit uses +//! (`gc/layout_slot_visit.rs`). +//! //! Until #10362 the funnel re-derived the header half too — rewriting bits //! that were already equal, and re-resolving the intact bit through a //! ShapeId-keyed `SHAPE_LAYOUTS` probe — once per relocated object. Measured @@ -64,14 +78,33 @@ use crate::gc::layout_tables::per_object_layouts_may_hold_either; /// `old_user` and `new_user` are user pointers of live allocations, and the /// caller has already made the destination header a copy of the source's (see /// the module docs). The precondition is asserted in test and debug builds. -#[inline] +/// +/// `inline(always)`: every relocation of every object runs this and all it +/// keeps inline is gates — each record move is a cold out-of-line call — but +/// three gates are enough for the heuristic to outline it from `move_young`, +/// and then the call costs more than the gates. +#[inline(always)] pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { if old_user.is_null() || new_user.is_null() || old_user == new_user { return; } + if (old_user as usize) < GC_HEADER_SIZE + 0x1000 { + return; + } + // Before the layout-kind return, because the registry's owners are not the + // layout kinds (module docs). The latch first: it is one byte load, false + // for any process that never re-prototyped a non-object, and the move is + // out of line. + if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() + && crate::object::prototype_chain::residual_prototype_owner_type( + (*header_from_user_ptr(old_user as *const u8)).obj_type, + ) + { + transfer_residual_prototype(old_user as usize, new_user as usize); + } // Kinds with no layout metadata at all (strings, meta records, RegExps) - // leave before anything else, exactly as before #10362. The destination - // carries the same `obj_type`, so one classification answers for both. + // have no layout record to move. The destination carries the same + // `obj_type`, so one classification answers for both. let Some(old_header) = layout_header_for_user(old_user as usize) else { return; }; @@ -79,15 +112,13 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { let reserved = (*old_header)._reserved; let is_array = (*old_header).obj_type == GC_TYPE_ARRAY; - // Three gates, all answered from words already in registers or in the one + // Two gates, both answered from words already in registers or in the one // hot thread-local slot #7510 keeps them in. Each is the same question the // record mover behind it asks first, hoisted so the common case — no // record anywhere near either address — never leaves this function. let per_object = per_object_layouts_may_hold_either(old_user as usize, new_user as usize); let element_shape = is_array && reserved & GC_ARRAY_ELEMENT_SHAPE != 0; - let static_prototype = - is_array && crate::object::prototype_chain::object_static_prototypes_maybe_nonempty(); - if per_object || element_shape || static_prototype { + if per_object || element_shape { transfer_address_keyed_records( old_user as usize, new_user as usize, @@ -102,9 +133,18 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { header_clear_typed_layout_intact(old_header); } -/// The record moves themselves. Cold: on a workload holding no per-object -/// layout record, no element-shape proof and no re-prototyped array — the -/// steady state of every monomorphic program — it is never reached. +/// The residual prototype registry's rekey. Cold and out of line so the funnel +/// stays small enough to inline into every relocation site: it is reached only +/// once something in the process has been re-prototyped. +#[cold] +#[inline(never)] +fn transfer_residual_prototype(old_user: usize, new_user: usize) { + crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); +} + +/// The layout record moves themselves. Cold: on a workload holding no +/// per-object layout record and no element-shape proof — the steady state of +/// every monomorphic program — it is never reached. #[cold] #[inline(never)] unsafe fn transfer_address_keyed_records( @@ -119,10 +159,6 @@ unsafe fn transfer_address_keyed_records( // from both headers and fails closed — it clears the destination bit // when no record follows the move. crate::array::transfer_element_shape(old_user, new_user); - // #9304: a real array keeps an explicit [[Prototype]] in the residual - // address-keyed registry; moving GC and growth both replace the owner - // allocation through this hook. - crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); } // #7510's two per-object maps. Both re-test the gate above for their own // address pair, so calling them when only a sibling gate fired costs one diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 68ef1bccb0..d76398e700 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -155,7 +155,24 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( if header.is_null() || (*header).gc_flags & GC_FLAG_FORWARDED != 0 { return; } + let obj_type = (*header).obj_type; let user_ptr = (header as *mut u8).add(GC_HEADER_SIZE); + // An explicit `Object.setPrototypeOf` value recorded in the residual + // registry is a child edge of its owner, whatever the owner's kind: marking + // retains it and a moving collection rewrites it after + // `gc/layout/transfer.rs` rekeyed the entry. It used to be emitted from the + // array and ordinary-object arms only, so a Map, Set, Error, Promise, Date, + // RegExp, Temporal cell, lazy JSON array or closure owner kept a stale + // prototype address once the prototype moved. First, ahead of the kind + // arms, so no arm's early return can skip it. + if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() + && crate::object::prototype_chain::residual_prototype_owner_type(obj_type) + { + crate::object::prototype_chain::visit_object_static_prototype_slot_mut( + user_ptr as usize, + |slot| visit(fixed_slot(slot)), + ); + } match gc_type_rewrite_descriptor_kind((*header).obj_type) { GcRewriteDescriptorKind::Array => { visit_gc_layout_slot_descriptors(header, &mut visit); @@ -172,16 +189,6 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( |slot| visit(fixed_slot(slot)), ); } - // #9304: unlike shaped objects, real arrays keep an explicit - // [[Prototype]] in the residual side table. Treat that value as - // the array's child edge so collection retains and rewrites a - // movable custom prototype after layout_transfer rekeys its owner. - crate::object::prototype_chain::visit_object_static_prototype_slot_mut( - user_ptr as usize, - |slot| { - visit(fixed_slot(slot)); - }, - ); } GcRewriteDescriptorKind::Object => { // #6759 Phase B / #6812: the per-object meta record is a raw- @@ -195,14 +202,6 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( crate::object::visit_overflow_field_slots_mut(user_ptr as usize, |slot| { visit(fixed_slot(slot)); }); - // #2820: the recorded `Object.setPrototypeOf` value is a live - // reference; rewrite it if the prototype object moved. - crate::object::prototype_chain::visit_object_static_prototype_slot_mut( - user_ptr as usize, - |slot| { - visit(fixed_slot(slot)); - }, - ); } GcRewriteDescriptorKind::RegExp => { visit_gc_layout_slot_descriptors(header, &mut visit); diff --git a/crates/perry-runtime/src/gc/layout_tables.rs b/crates/perry-runtime/src/gc/layout_tables.rs index dc006c52c8..d429ab4c7f 100644 --- a/crates/perry-runtime/src/gc/layout_tables.rs +++ b/crates/perry-runtime/src/gc/layout_tables.rs @@ -1042,7 +1042,7 @@ pub(in crate::gc) fn per_object_layouts_maybe_nonempty() -> bool { /// where the two `transfer_*` entry points below each resolve the hot slot /// again for their own pair (#10362). Same answer, one thread-local /// resolution: the flag and the filter live in the same slot. -#[inline] +#[inline(always)] pub(in crate::gc) fn per_object_layouts_may_hold_either(old_user: usize, new_user: usize) -> bool { let hint = hot_per_object_layout_hint(); hint.nonempty.get() && (hint_may_hold(hint, old_user) || hint_may_hold(hint, new_user)) diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 3307fdea2b..5e8e3f86c0 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -60,6 +60,7 @@ mod os_tag; mod promote_in_place; mod promoted_cohort; mod proxy_registry; +mod residual_prototype_relocation; mod restore_coverage; mod retention_9628_9629; mod root_words; diff --git a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs new file mode 100644 index 0000000000..2340ea7f0a --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs @@ -0,0 +1,282 @@ +//! An explicit `[[Prototype]]` recorded in the residual registry must survive +//! its owner's relocation, whatever the owner's kind. +//! +//! `Object.setPrototypeOf` keeps a shaped object's prototype in its meta record +//! and everything else — every receiver `meta_capable_object` turns away — in +//! the residual address-keyed registry (`object::prototype_chain`). That entry +//! carries two collector obligations: rekey it when the owner moves, and treat +//! its value as a child edge so the prototype is retained and rewritten. Both +//! were wired to arrays and ordinary objects only, the rekey below +//! `layout_transfer`'s layout-kind return. Every other movable owner — a lazy +//! JSON array, Map, Set, Error, Promise, Date, RegExp — lost its prototype at +//! its first copying minor, and with the rekey alone would have kept a stale +//! address to a prototype that had moved or died. +//! +//! These tests have to be able to fail, so every premise they rest on — the +//! entry landed in the registry, the owner and the prototype really moved — is +//! asserted before the verdict. + +use super::super::*; +use super::support::*; + +/// Retargeting any array-like latches the process-wide "an array somewhere has +/// a custom `[[Prototype]]`" flag and stands the index fast paths down for the +/// rest of the binary (see `ArrayPrototypeLatchGuard` in `dyn_eval/tests.rs`). +/// Restore what the test found. +struct ArrayPrototypeLatchRestore { + latch: bool, + invalidated: u8, +} + +impl ArrayPrototypeLatchRestore { + fn capture() -> Self { + Self { + latch: crate::object::prototype_chain::array_static_proto_recorded(), + invalidated: crate::array::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED + .load(std::sync::atomic::Ordering::Relaxed), + } + } +} + +impl Drop for ArrayPrototypeLatchRestore { + fn drop(&mut self) { + crate::object::prototype_chain::test_swap_array_static_proto_recorded(self.latch); + crate::array::test_swap_array_index_fast_path_invalidated(self.invalidated); + } +} + +/// A small `JSON.parse`-shaped lazy array: small enough that its cluster is +/// born in the nursery (`json_tape::lazy_cluster_is_old`), so a copying minor +/// moves it. +fn nursery_lazy_array(input: &[u8]) -> usize { + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + crate::json_tape::with_built_tape(input, |tape| unsafe { + crate::json_tape::alloc_lazy_array( + tape, + 0, + crate::json_tape::count_array_length(tape, 0), + text, + ) + }) + .expect("valid JSON should build a tape") as usize +} + +fn obj_type_at(user: usize) -> u8 { + unsafe { (*header_from_user_ptr(user as *const u8)).obj_type } +} + +const MARKER: f64 = 10362.0; + +fn marked_prototype() -> usize { + let proto = crate::object::js_object_alloc(0, 1); + crate::object::js_object_set_field(proto, 0, crate::value::JSValue::number(MARKER)); + proto as usize +} + +fn forget_owners(owners: &[usize]) { + crate::object::prototype_chain::prune_dead_object_prototype_owners(&|owner| { + owners.contains(&owner) + }); +} + +/// The claim as reported against #10381: a lazy array's prototype, set through +/// the real `Object.setPrototypeOf` entry, across a copying minor that moves +/// both the array and its prototype. +#[test] +fn test_lazy_array_explicit_prototype_survives_a_copying_minor() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + // Two rooted values — the lazy array and its prototype — so two shadow + // slots: a store outside the pushed frame is a silent no-op (#7184). + let _guard = CopyingNurseryTestGuard::new(2); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let lazy = nursery_lazy_array(b"[1,2,3]"); + assert_eq!( + obj_type_at(lazy), + GC_TYPE_LAZY_ARRAY, + "premise: a real lazy array" + ); + assert!( + crate::arena::pointer_in_nursery(lazy), + "premise: the lazy cluster is nursery-born, so a copying minor can move it" + ); + assert!(crate::gc::gc_type_is_movable(GC_TYPE_LAZY_ARRAY)); + assert!( + unsafe { crate::object::prototype_chain::meta_capable_object(lazy) }.is_none(), + "premise: a lazy array has no meta record, so its prototype goes to the registry" + ); + + let proto = marked_prototype(); + js_shadow_slot_set(0, ptr_bits(lazy)); + js_shadow_slot_set(1, ptr_bits(proto)); + + // The real user-facing entry, not the recorder beneath it. + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(lazy)), + f64::from_bits(ptr_bits(proto)), + ); + let lazy = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let proto = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + assert_eq!( + obj_type_at(lazy), + GC_TYPE_LAZY_ARRAY, + "premise: setPrototypeOf left the receiver a lazy array" + ); + assert!( + crate::object::prototype_chain::test_prototype_registry_latch_armed(), + "premise: the residual registry holds an entry" + ); + assert_eq!( + crate::object::prototype_chain::object_static_prototype(lazy), + Some(ptr_bits(proto)), + "premise: the prototype is recorded in the residual registry under the lazy \ + header's own address" + ); + assert_eq!( + crate::object::js_object_get_prototype_of(f64::from_bits(ptr_bits(lazy))).to_bits(), + ptr_bits(proto), + "premise: Object.getPrototypeOf resolves it before anything moves" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let lazy_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + let proto_after = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + assert_ne!( + lazy_after, lazy, + "the copying minor must actually relocate the lazy header — an unmoved \ + receiver proves nothing" + ); + assert_ne!( + proto_after, proto, + "the prototype must move too, or a stale recorded address would go unseen" + ); + assert_eq!(obj_type_at(lazy_after), GC_TYPE_LAZY_ARRAY); + + let recorded = crate::object::prototype_chain::object_static_prototype(lazy_after); + let resolved = + crate::object::js_object_get_prototype_of(f64::from_bits(ptr_bits(lazy_after))).to_bits(); + // Leave no entry behind for a later test to trip over, whatever the verdict. + forget_owners(&[lazy, lazy_after]); + + assert_eq!( + recorded, + Some(ptr_bits(proto_after)), + "the registry entry must follow the lazy header to its new address and name \ + the prototype at ITS current address" + ); + assert_eq!( + resolved, + ptr_bits(proto_after), + "Object.getPrototypeOf on the relocated lazy array must still return the \ + prototype it was given" + ); +} + +/// Every movable receiver kind that keeps its prototype in the residual +/// registry, with the prototype held by NOTHING but that entry. One copying +/// minor has to rekey the entry, retain the prototype through it, and rewrite +/// the recorded address — the three obligations the registry's population +/// shares regardless of kind. Arrays and ordinary objects are the controls: +/// they were covered before. +#[test] +fn test_residual_prototype_owners_of_every_movable_kind_survive_a_copying_minor() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + // One rooted owner at a time; its prototype is deliberately unrooted. + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + type Alloc = Box usize>; + let mut owners: Vec<(&str, Alloc)> = vec![ + ( + "array", + Box::new(|| crate::array::js_array_alloc(4) as usize), + ), + ( + "object", + Box::new(|| crate::object::js_object_alloc(0, 1) as usize), + ), + ("lazy array", Box::new(|| nursery_lazy_array(b"[1,2,3]"))), + ("Map", Box::new(|| crate::map::js_map_alloc(0) as usize)), + ("Set", Box::new(|| crate::set::js_set_alloc(0) as usize)), + ("Error", Box::new(|| crate::error::js_error_new() as usize)), + ( + "Promise", + Box::new(|| crate::promise::js_promise_new() as usize), + ), + ( + "Date", + Box::new(|| (crate::date::js_date_new().to_bits() & POINTER_MASK) as usize), + ), + ]; + #[cfg(feature = "regex-engine")] + owners.push(( + "RegExp", + Box::new(|| { + let pattern = crate::string::js_string_from_bytes(b"a+".as_ptr(), 2); + let flags = crate::string::js_string_from_bytes(b"g".as_ptr(), 1); + crate::regex::js_regexp_new(pattern, flags) as usize + }), + )); + + for (kind, alloc) in owners { + let owner = alloc(); + let obj_type = obj_type_at(owner); + assert!( + crate::arena::pointer_in_nursery(owner) && crate::gc::gc_type_is_movable(obj_type), + "{kind}: premise: a nursery owner of a movable kind" + ); + js_shadow_slot_set(0, ptr_bits(owner)); + let proto = marked_prototype(); + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(owner)), + f64::from_bits(ptr_bits(proto)), + ); + let owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!( + crate::object::prototype_chain::object_static_prototype(owner), + Some(ptr_bits(proto)), + "{kind}: premise: the prototype is recorded for this owner" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let owner_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!( + owner_after, owner, + "{kind}: premise: the owner must actually move" + ); + + let recorded = crate::object::prototype_chain::object_static_prototype(owner_after); + forget_owners(&[owner, owner_after]); + js_shadow_slot_set(0, 0); + + let Some(recorded) = recorded else { + panic!("{kind}: the registry entry did not follow its owner to the new address"); + }; + let proto_after = (recorded & POINTER_MASK) as usize; + assert_ne!( + proto_after, proto, + "{kind}: the recorded prototype still names its pre-collection address — the \ + prototype was either not retained through the entry or not rewritten" + ); + assert!( + crate::arena::pointer_in_nursery(proto_after) + || crate::arena::pointer_in_old_gen(proto_after), + "{kind}: the recorded prototype must be a live heap address" + ); + let marker = crate::object::js_object_get_field( + proto_after as *const crate::object::ObjectHeader, + 0, + ); + assert_eq!( + marker.bits(), + MARKER.to_bits(), + "{kind}: the recorded address must hold the prototype that was set" + ); + } +} diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index b81e84120c..835cb92074 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -917,10 +917,9 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user GcMoveHookKind::None => {} GcMoveHookKind::ObjectOverflowFields => { crate::object::overflow_fields_owner_moved(old_user, new_user); - // #2820: migrate any recorded `Object.setPrototypeOf` entry for - // this ordinary object so getPrototypeOf/inherited reads still - // resolve after evacuation. - crate::object::prototype_chain::object_static_prototype_owner_moved(old_user, new_user); + // The residual `Object.setPrototypeOf` registry is rekeyed by the + // relocation funnel for every owner kind (`gc/layout/transfer.rs`), + // which runs before this hook on every move. crate::object::module_wrapper_owner_moved(old_user, new_user); } GcMoveHookKind::ClosureDynamicProps => { diff --git a/crates/perry-runtime/src/node_submodules/fs_promises.rs b/crates/perry-runtime/src/node_submodules/fs_promises.rs index 6d89c57642..96da1cd208 100644 --- a/crates/perry-runtime/src/node_submodules/fs_promises.rs +++ b/crates/perry-runtime/src/node_submodules/fs_promises.rs @@ -105,7 +105,11 @@ pub(crate) extern "C" fn thunk_fs_promises_readFile( path: f64, encoding: f64, ) -> f64 { - promise_from_sync_value(|| crate::fs::js_fs_read_file_dispatch(path, encoding)) + // #10452: the Buffer form used to resolve `undefined` for a missing file. + let as_string = crate::fs::read_file_encoding(encoding).is_some(); + promise_from_result_value(|| unsafe { + crate::fs::read_file_value_result(path, encoding, as_string) + }) } pub(crate) extern "C" fn thunk_fs_promises_open( diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index cc1692ccd3..1198fd7ac2 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -293,6 +293,10 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s // each back to "Array"/"Object"/"Date". || func_ptr == global_this_array_thunk as *const u8 as usize || func_ptr == global_this_object_thunk as *const u8 as usize + // #10423: `Function` carries its own call thunk now; `new F(…)` / + // `Reflect.construct(Function, …)` must still route to the + // from-strings constructor in construct.rs. + || func_ptr == global_this_function_call_thunk as *const u8 as usize || func_ptr == global_this_date_thunk as *const u8 as usize || func_ptr == global_this_blob_thunk as *const u8 as usize || func_ptr == global_this_file_thunk as *const u8 as usize @@ -364,6 +368,8 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s Some("Array") } else if func_ptr == global_this_object_thunk as *const u8 as usize { Some("Object") + } else if func_ptr == global_this_function_call_thunk as *const u8 as usize { + Some("Function") } else if func_ptr == global_this_string_thunk as *const u8 as usize { Some("String") } else if func_ptr == global_this_number_thunk as *const u8 as usize { diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index aefa9579b1..9e3700dbb9 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -231,6 +231,37 @@ pub(crate) unsafe fn nm_ctor_stream( _ => unreachable!(), }); } + // #10430: `new Stream()` (legacy `Stream`, i.e. `new (require('stream'))()`) + // is an ordinary instance of `Stream.prototype`, whose EventEmitter methods + // act on the receiver. Build it the way an ordinary function constructor's + // instance is built (the constructor's stable synthetic class id plus a + // class-default link to its `prototype`), not via `Object.create`, which + // mints a fresh synthetic class per call. Without this arm the instance + // had no `on`/`emit` and was not `instanceof Stream`. + if method == "Stream" { + let scope = crate::gc::RuntimeHandleScope::new(); + let ctor = scope.root_nanbox_f64(crate::object::bound_native_callable_export_value( + "stream", "Stream", + )); + let cid = synthetic_class_id_for_function(ctor.get_nanbox_f64()); + let instance = scope.root_raw_mut_ptr(js_object_alloc(cid, 0)); + let proto = crate::closure::closure_get_dynamic_prop( + (ctor.get_nanbox_u64() & crate::value::POINTER_MASK) as usize, + "prototype", + ); + if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { + instance.with_mut_ptr::(|obj| { + super::super::prototype_chain::object_link_class_default_prototype( + obj as usize, + proto.to_bits(), + ) + }); + } + return Some( + instance + .with_mut_ptr::(|obj| crate::value::js_nanbox_pointer(obj as i64)), + ); + } None } @@ -474,6 +505,17 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( std::slice::from_raw_parts(args_ptr, args_len) }; match name { + // Reflective construction of the intrinsic `Function` — + // `new F(p, body)` through a value, `Reflect.construct(Function, …)`, + // a spread `new Function(...parts)` — goes to the same from-strings + // entry the literal `new Function(...)` reaches from codegen + // (`lower_call/new.rs`). Identified by closure identity, so it holds + // when `globalThis.Function` is reassigned. Routed here, before the + // generic path allocates an instance: `args_ptr` is a plain copy of + // the arguments, not a GC root (#10424). + "Function" => { + return super::super::js_function_ctor_from_strings(args_ptr, args_len); + } #[cfg(feature = "global-webcrypto")] "Crypto" | "CryptoKey" | "SubtleCrypto" => { return crate::object::js_webcrypto_illegal_constructor(); @@ -1129,25 +1171,6 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( // `js_native_call_value` dispatch on a verified closure pointer // here — otherwise `new ()` would dereference an // arbitrary pointer as a `ClosureHeader` and crash. - // - // Reflective `Function.apply(self, scope, code)` (and `Reflect.construct` - // on Function) reach here with `func_value` = the reified Function - // constructor — a plain callable closure singleton, so - // `is_callable_function_value` below reports it callable and it would be - // CALLED as a value → "Function is not a function". The literal - // `new Function(...)` path routes to the Function-from-strings shim in - // codegen (`lower_call/new.rs`); route the reflective form to the SAME shim - // here. Identify the constructor by its intrinsic closure identity - // (`identify_global_builtin_constructor`, keyed on the builtin `func_ptr`) — - // robust to `globalThis.Function` reassignment, unlike reading the mutable - // global property. (User classes / other builtins / proxies were handled - // above and don't match.) - if matches!( - identify_global_builtin_constructor(func_value), - Some("Function") - ) { - return super::super::js_function_ctor_from_strings(args_ptr, args_len); - } if is_callable_function_value(func_value) { // Bind `this` to the new instance, dispatch the constructor, // then restore the previous IMPLICIT_THIS. The dispatch diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index e193539c0f..b4813599ad 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -166,9 +166,12 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, mut parent_val register_class(class_id, parent); } } - if module == "events" { + if module == "events" || (module == "stream" && method == "Stream") { + // #10430: the legacy `Stream` constructor extends EventEmitter, so a + // `class X extends require('stream')` subclass inherits the same + // EventEmitter parent edge (`new X() instanceof EventEmitter`). let parent = match method.as_str() { - "EventEmitter" => 0xFFFF0076, + "EventEmitter" | "Stream" => 0xFFFF0076, "EventEmitterAsyncResource" => 0xFFFF0077, _ => 0, }; diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 232391e548..36980d471e 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -88,11 +88,12 @@ pub(crate) use builtin_thunks::{ global_this_btoa_thunk, global_this_decode_uri_component_thunk, global_this_decode_uri_thunk, global_this_encode_uri_component_thunk, global_this_encode_uri_thunk, global_this_error_capture_stack_trace_thunk, global_this_error_is_error_thunk, - global_this_error_prepare_stack_trace_thunk, global_this_escape_thunk, global_this_gc_thunk, - global_this_is_finite_thunk, global_this_is_nan_thunk, global_this_number_thunk, - global_this_object_thunk, global_this_parse_float_thunk, global_this_parse_int_thunk, - global_this_string_thunk, global_this_structured_clone_thunk, global_this_unescape_thunk, - js_math_round_value, proxy_revocable_thunk, + global_this_error_prepare_stack_trace_thunk, global_this_escape_thunk, + global_this_function_call_thunk, global_this_gc_thunk, global_this_is_finite_thunk, + global_this_is_nan_thunk, global_this_number_thunk, global_this_object_thunk, + global_this_parse_float_thunk, global_this_parse_int_thunk, global_this_string_thunk, + global_this_structured_clone_thunk, global_this_unescape_thunk, js_math_round_value, + proxy_revocable_thunk, }; pub use ctor_thunks::js_webcrypto_illegal_constructor; pub(crate) use ctor_thunks::{ diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 1adb2efa93..0f5cfc3eac 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -428,12 +428,21 @@ pub extern "C" fn js_function_ctor_from_strings(args_ptr: *const f64, args_len: js_function_ctor_from_strings_impl(args_ptr, args_len) } -fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> f64 { - let arg_str = |i: usize| -> String { - if i >= args_len || args_ptr.is_null() { - return String::new(); - } - let v = unsafe { *args_ptr.add(i) }; +/// #10424: CreateDynamicFunction applies ToString to every argument, left to +/// right, before it assembles the source. The arguments used to be read as +/// strings only, so anything else became `""`: `new Function(['a', 'b'], body)` +/// lost its parameters (lodash `_.template` passes its import names as an +/// array) and an object with a `toString` did too. +/// +/// All-string argument lists (the common case) read the bytes directly. Any +/// other argument can run user code (`toString` / `valueOf` / +/// `Symbol.toPrimitive`), which can collect, so every argument is rooted +/// before the first conversion and re-read from its handle. +fn function_ctor_arg_strings(args_ptr: *const f64, args_len: usize) -> Vec { + if args_ptr.is_null() || args_len == 0 { + return Vec::new(); + } + let string_of = |v: f64| -> String { let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; match crate::string::str_bytes_from_jsvalue(v, &mut scratch) { Some((p, n)) if !p.is_null() => { @@ -443,6 +452,36 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> _ => String::new(), } }; + let args = unsafe { std::slice::from_raw_parts(args_ptr, args_len) }; + if args + .iter() + .all(|v| crate::value::JSValue::from_bits(v.to_bits()).is_any_string()) + { + return args.iter().map(|v| string_of(*v)).collect(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let handles = scope.root_nanbox_f64_slice(args); + handles + .iter() + .map(|handle| { + let v = handle.get_nanbox_f64(); + if crate::value::JSValue::from_bits(v.to_bits()).is_any_string() { + return string_of(v); + } + if unsafe { crate::symbol::js_is_symbol(v) != 0 } { + super::super::object_ops::throw_object_type_error( + b"Cannot convert a Symbol value to a string", + ); + } + let s = crate::builtins::js_string_coerce(v); + string_of(crate::value::js_nanbox_string(s as i64)) + }) + .collect() +} + +fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> f64 { + let args_vec = function_ctor_arg_strings(args_ptr, args_len); + let arg_str = |i: usize| -> &str { args_vec.get(i).map(String::as_str).unwrap_or("") }; // depd `wrapfunction`: `new Function("fn","log","deprecate","message", // "site", '…return function (…) { log.call(deprecate, message, site)\n // return fn.apply(this, arguments)\n}')`. The outer, called with @@ -483,7 +522,6 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> // interpreted. #[cfg(feature = "dyn-eval")] { - let args_vec: Vec = (0..args_len).map(arg_str).collect(); return crate::dyn_eval::dyn_function_from_strings(&args_vec); } // Without the `dyn-eval` feature (size-optimized builds that carry no @@ -492,15 +530,42 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> // eprintln names the offending library for diagnostics. #[cfg(not(feature = "dyn-eval"))] { - let body = if args_len > 0 { - arg_str(args_len - 1) - } else { - String::new() - }; - refuse_dynamic_function(args_len, &body) + let body = args_vec.last().map(String::as_str).unwrap_or(""); + refuse_dynamic_function(args_len, body) } } +/// #10423: the `Function` constructor called WITHOUT `new` through a value — +/// `const F = Function; F(p, body)`, lodash's `var Function = context.Function`, +/// `Function.bind(null, p)(body)`, `module.exports = Function`. `Function(…)` +/// is spec-identical to `new Function(…)`, so this is the same entry the +/// construct paths reach (`lower_call/new.rs` and `construct.rs`). The value +/// used to carry the shared no-op thunk, so every such call returned +/// `undefined`. Registered as a rest closure (`populate.rs`), so `rest` holds +/// every argument. +#[cfg(not(panic = "abort"))] +pub(crate) extern "C-unwind" fn global_this_function_call_thunk( + _closure: *const crate::closure::ClosureHeader, + rest: f64, +) -> f64 { + function_call_thunk_impl(rest) +} + +#[cfg(panic = "abort")] +pub(crate) extern "C" fn global_this_function_call_thunk( + _closure: *const crate::closure::ClosureHeader, + rest: f64, +) -> f64 { + function_call_thunk_impl(rest) +} + +fn function_call_thunk_impl(rest: f64) -> f64 { + // A plain copy of the rest array's slots; the impl reads them before + // anything can allocate, and roots them before running user code. + let values = global_this_rest_array_values(rest); + js_function_ctor_from_strings_impl(values.as_ptr(), values.len()) +} + #[cfg(any(not(feature = "dyn-eval"), test))] fn refuse_dynamic_function(args_len: usize, body: &str) -> ! { let preview: String = body.chars().take(160).collect(); @@ -600,4 +665,59 @@ mod tests { crate::error::ERROR_KIND_TYPE_ERROR, ); } + + fn string_value(s: &str) -> f64 { + let header = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + crate::value::js_nanbox_string(header as i64) + } + + fn array_value(items: &[f64]) -> f64 { + let mut arr = crate::array::js_array_alloc(items.len() as u32); + for item in items { + arr = crate::array::js_array_push_f64(arr, *item); + } + crate::value::js_nanbox_pointer(arr as i64) + } + + /// #10424: every argument goes through ToString. A non-string used to read + /// as `""`, so `new Function(['a', 'b'], body)` had no parameters. + #[test] + fn function_ctor_arguments_are_converted_with_to_string() { + let params = array_value(&[string_value("a"), string_value("b")]); + let args = [ + params, + f64::from_bits(crate::value::JSValue::number(1.5).bits()), + f64::from_bits(crate::value::TAG_NULL), + f64::from_bits(crate::value::JSValue::bool(true).bits()), + string_value("return a"), + ]; + assert_eq!( + function_ctor_arg_strings(args.as_ptr(), args.len()), + vec!["a,b", "1.5", "null", "true", "return a"], + ); + let strings = [string_value("a"), string_value("return a")]; + assert_eq!( + function_ctor_arg_strings(strings.as_ptr(), strings.len()), + vec!["a", "return a"], + ); + assert!(function_ctor_arg_strings(std::ptr::null(), 0).is_empty()); + } + + /// #10423: the `Function` value called without `new` builds a function + /// from its (rest-bundled) arguments instead of returning `undefined`. + #[cfg(feature = "dyn-eval")] + #[test] + fn function_value_call_builds_a_function() { + let rest = array_value(&[ + array_value(&[string_value("a"), string_value("b")]), + string_value("return a + b"), + ]); + let f = global_this_function_call_thunk(std::ptr::null(), rest); + let args = [ + f64::from_bits(crate::value::JSValue::number(2.0).bits()), + f64::from_bits(crate::value::JSValue::number(3.0).bits()), + ]; + let result = unsafe { crate::closure::js_native_call_value(f, args.as_ptr(), args.len()) }; + assert_eq!(crate::builtins::js_number_coerce(result), 5.0); + } } diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index b7e454a8c4..bd98a72379 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -181,6 +181,9 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade } let func_ptr = match name { "Array" => global_this_array_thunk as *const u8, + // #10423: `F(p, body)` through a `Function` value creates a + // function, exactly like `new F(p, body)`. + "Function" => global_this_function_call_thunk as *const u8, "Object" => global_this_object_thunk as *const u8, "String" => global_this_string_thunk as *const u8, // #2889: call-form `Number(x)` / `Boolean(x)` through a rebound @@ -233,7 +236,7 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade continue; } match name { - "Array" => { + "Array" | "Function" => { crate::closure::js_register_closure_rest(func_ptr, 0); } "Date" => { diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 41cc0945c7..59b0781a2f 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1788,6 +1788,26 @@ pub unsafe extern "C-unwind" fn js_native_call_method( IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); return result; } + // #10423: `fn.constructor(p, body)` with no own `constructor` calls + // the inherited one — the `Function` constructor (or a generator + // function's intrinsic one) — exactly as the `fn.constructor` read + // resolves it (`get_field_by_name_tail.rs`). Every other miss on a + // function receiver ends in the empty-object stub below, which is + // what this call used to return. + if method_name == "constructor" { + let ctor = crate::object::generator_function_constructor_of(raw_addr) + .unwrap_or_else(|| { + crate::object::js_get_global_this_builtin_value(b"Function".as_ptr(), 8) + }); + let ctor = root_scope.root_nanbox_f64(ctor); + // The global lookup allocates; re-read the rooted arguments. + let call_args = refreshed_args(); + return crate::closure::js_native_call_value( + ctor.get_nanbox_f64(), + call_args.as_ptr(), + call_args.len(), + ); + } // `fn.length()` / `fn.name()` — the own slots hold a number / // string, never a callable; calling one is a TypeError // (`f.length is not a function`), not a read. diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 1ae26fe3e7..3c2d38b9c9 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -487,8 +487,10 @@ unsafe fn nm_ee_dynamic_super( args_len: usize, ) -> Option { let (module, method) = bound_native_callable_module_and_method(func_value)?; - if module.trim_start_matches("node:") == "events" - && (method == "EventEmitter" || method == "EventEmitterAsyncResource") + let module = module.trim_start_matches("node:"); + // #10430: legacy `Stream` is `function Stream(opts) { EE.call(this, opts) }`. + if (module == "events" && (method == "EventEmitter" || method == "EventEmitterAsyncResource")) + || (module == "stream" && method == "Stream") { let this_val = super::js_implicit_this_get(); if crate::value::JSValue::from_bits(this_val.to_bits()).is_pointer() { @@ -691,6 +693,9 @@ pub(crate) fn cjs_default_export_value(module_name: &str) -> Option { match module_name { "assert" | "assert/strict" => Some(callable_exports::assert_cjs_export_value(module_name)), "events" => Some(bound_native_callable_export_value("events", "EventEmitter")), + // #10431: `stream`'s `module.exports` IS the legacy `Stream` constructor + // (exports hang off it: `attach_stream_legacy_prototype`). + "stream" => Some(bound_native_callable_export_value("stream", "Stream")), // #3687: `node:cluster` default import is a distinct EventEmitter-shaped // `cluster.default` namespace (its `on`/`emit`/… reads diverge from the // bare `import * as` namespace). diff --git a/crates/perry-runtime/src/object/native_module_stream.rs b/crates/perry-runtime/src/object/native_module_stream.rs index 98532f8b1e..fe5b2dfbed 100644 --- a/crates/perry-runtime/src/object/native_module_stream.rs +++ b/crates/perry-runtime/src/object/native_module_stream.rs @@ -28,41 +28,142 @@ pub(crate) fn scan_stream_event_emitter_prototype_roots_mut( }); } +/// The own properties Node hangs off `require('stream')` — which IS the legacy +/// `Stream` constructor (`lib/stream.js`: `module.exports = Stream`, then +/// `Stream.Readable = …` etc.) — in Node's own-key order. `Stream` is the +/// constructor itself and is filled in by the caller, not re-resolved. +const STREAM_MODULE_EXPORT_KEYS: &[&str] = &[ + "isDestroyed", + "isDisturbed", + "isErrored", + "isReadable", + "isWritable", + "Readable", + "Writable", + "Duplex", + "Transform", + "PassThrough", + "duplexPair", + "pipeline", + "addAbortSignal", + "finished", + "destroy", + "compose", + "setDefaultHighWaterMark", + "getDefaultHighWaterMark", + "promises", + "Stream", + "_isArrayBufferView", + "_isUint8Array", + "_uint8ArrayToBuffer", +]; + +fn closure_addr_of(value: f64) -> usize { + (value.to_bits() & crate::value::POINTER_MASK) as usize +} + +/// The NaN-boxed value of a rooted object pointer, read at the call site. +fn proto_value(proto: &crate::gc::RuntimeHandle<'_>) -> f64 { + proto.with_mut_ptr::(|p| crate::value::js_nanbox_pointer(p as i64)) +} + pub(crate) fn attach_stream_legacy_prototype(constructor_value: f64) { - let proto = js_object_alloc_with_shape( + // Every step below allocates (the prototype object, its EventEmitter + // method closures, each export value, the EventEmitter constructor), so + // both the constructor and the prototype are re-read from their handles at + // each use rather than held as raw addresses across a collection point. + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor = scope.root_nanbox_f64(constructor_value); + let proto = scope.root_raw_mut_ptr(js_object_alloc_with_shape( 0x7FFF_FF33, 1, b"constructor\0".as_ptr(), b"constructor\0".len() as u32, - ); - js_object_set_field(proto, 0, JSValue::from_bits(constructor_value.to_bits())); + )); + proto.with_mut_ptr::(|p| { + js_object_set_field(p, 0, JSValue::from_bits(constructor.get_nanbox_u64())) + }); // readable-stream's `Readable.prototype.on` borrows `Stream.prototype.on` // via `.call(this)`; expose the EventEmitter methods on the legacy // `Stream.prototype` as receiver-from-`this` values so the borrow works. - crate::node_stream::install_event_emitter_prototype_methods(proto); - let proto_value = crate::value::js_nanbox_pointer(proto as i64); + // (The installer roots `proto` itself.) + proto.with_mut_ptr::(|p| { + crate::node_stream::install_event_emitter_prototype_methods(p) + }); + let proto_bits = proto_value(&proto).to_bits(); STREAM_EVENT_EMITTER_PROTOTYPES.with(|protos| { let mut protos = protos.borrow_mut(); - if !protos.contains(&proto_value.to_bits()) { - protos.push(proto_value.to_bits()); + if !protos.contains(&proto_bits) { + protos.push(proto_bits); } }); crate::closure::closure_set_dynamic_prop( - (constructor_value.to_bits() & crate::value::POINTER_MASK) as usize, + closure_addr_of(constructor.get_nanbox_f64()), "prototype", - proto_value, + proto_value(&proto), ); - let closure = (constructor_value.to_bits() & crate::value::POINTER_MASK) as usize; - for name in [ - "_isArrayBufferView", - "_isUint8Array", - "_uint8ArrayToBuffer", - "isDestroyed", - ] { + + // #10431: the module value (`require('stream')`, `import Stream from + // "node:stream"`) is this constructor, so it must also carry every module + // export — `Stream.Readable`, `Stream.pipeline`, `Stream.promises`, and + // `Stream.Stream === Stream`. Resolve each through the namespace resolver + // `import * as ns` reads use, so `Stream.pipeline === ns.pipeline`. + for &name in STREAM_MODULE_EXPORT_KEYS { + let value = match name { + "Stream" => constructor.get_nanbox_f64(), + // The `stream_promises` submodule namespace — what + // `require('stream').promises` resolved to while the module value + // was a namespace object — whose `pipeline`/`finished` are the + // promise-returning implementations. + "promises" => unsafe { + let submodule = b"stream_promises"; + crate::node_submodules::js_node_submodule_namespace( + submodule.as_ptr(), + submodule.len() as u32, + ) + }, + _ => unsafe { + let module = b"stream"; + super::js_native_module_property_by_name( + module.as_ptr(), + module.len(), + name.as_ptr(), + name.len(), + ) + }, + }; + if JSValue::from_bits(value.to_bits()).is_undefined() { + continue; + } crate::closure::closure_set_dynamic_prop( - closure, + closure_addr_of(constructor.get_nanbox_f64()), name, - bound_native_callable_export_value("stream", name), + value, + ); + } + + // #10430: Node's `lib/internal/streams/legacy.js` does + // `ObjectSetPrototypeOf(Stream.prototype, EE.prototype)` and + // `ObjectSetPrototypeOf(Stream, EE)`. The constructor edge is what makes + // the inherited statics resolve (`require('stream').EventEmitter === + // require('events')`, `.defaultMaxListeners`, `.once`, …); the prototype + // edge makes `Object.create(Stream.prototype) instanceof EventEmitter`. + // Arm the events attach first: minting `EventEmitter` before it would + // cache a constructor without its statics for the whole process. + super::native_module_registry::js_nm_install_events(); + let event_emitter = + scope.root_nanbox_f64(bound_native_callable_export_value("events", "EventEmitter")); + crate::object::js_object_set_prototype_of( + constructor.get_nanbox_f64(), + event_emitter.get_nanbox_f64(), + ); + let event_emitter_proto = scope.root_nanbox_f64( + crate::object::js_function_prototype_value_for_read(event_emitter.get_nanbox_f64()), + ); + if JSValue::from_bits(event_emitter_proto.get_nanbox_u64()).is_pointer() { + crate::object::js_object_set_prototype_of( + proto_value(&proto), + event_emitter_proto.get_nanbox_f64(), ); } } @@ -288,6 +389,70 @@ mod tests { ); } + /// #10430 / #10431: `require('stream')` IS the legacy `Stream` + /// constructor. It carries the module exports as statics, extends + /// EventEmitter on both the constructor and the prototype edge, and + /// `new Stream()` inherits `Stream.prototype`. + #[test] + fn stream_module_value_is_the_legacy_constructor_extending_event_emitter() { + let _global = crate::gc::global_side_table_test_lock(); + // Mint a fresh constructor so its decoration runs inside this test + // instead of being served from an earlier test's cache entry. + NATIVE_CALLABLE_EXPORTS.with(|c| c.borrow_mut().remove("stream\0Stream")); + let stream_ctor = bound_native_callable_export_value("stream", "Stream"); + let ctor_ptr = closure_addr(stream_ctor); + assert_ne!(ctor_ptr, 0); + + assert_eq!( + cjs_default_export_value("stream").map(f64::to_bits), + Some(stream_ctor.to_bits()), + "the CommonJS module value must be the Stream constructor itself" + ); + assert_eq!( + crate::closure::closure_get_dynamic_prop(ctor_ptr, "Stream").to_bits(), + stream_ctor.to_bits(), + "Stream.Stream === Stream" + ); + for name in [ + "Readable", + "PassThrough", + "pipeline", + "finished", + "promises", + ] { + let value = crate::closure::closure_get_dynamic_prop(ctor_ptr, name); + assert!( + JSValue::from_bits(value.to_bits()).is_pointer(), + "Stream.{name} must be an own static of the module value" + ); + } + assert_eq!( + crate::closure::closure_get_dynamic_prop(ctor_ptr, "Readable").to_bits(), + bound_native_callable_export_value("stream", "Readable").to_bits() + ); + + let event_emitter = bound_native_callable_export_value("events", "EventEmitter"); + assert_eq!( + js_object_get_prototype_of(stream_ctor).to_bits(), + event_emitter.to_bits(), + "Object.getPrototypeOf(Stream) must be EventEmitter" + ); + let stream_proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype"); + assert_eq!( + js_object_get_prototype_of(stream_proto).to_bits(), + js_function_prototype_value_for_read(event_emitter).to_bits(), + "Object.getPrototypeOf(Stream.prototype) must be EventEmitter.prototype" + ); + + super::super::native_module_registry::js_nm_install_stream(); + let instance = unsafe { js_new_function_construct(stream_ctor, std::ptr::null(), 0) }; + assert_eq!( + js_object_get_prototype_of(instance).to_bits(), + stream_proto.to_bits(), + "new Stream() must inherit Stream.prototype" + ); + } + #[test] fn stream_constructors_expose_static_method_values() { // CLOSURE_PROPS is PROCESS-global and the gc test guards' state reset diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index c61ab25901..3376c3a07b 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -511,6 +511,34 @@ pub(crate) fn object_static_prototypes_maybe_nonempty() -> bool { OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) } +/// Can a cell of `obj_type` own an entry in the residual registry? +/// +/// The registry's population is every owner [`meta_capable_object`] turns +/// away, and the recorder is reached with whatever the caller holds: +/// `Object.setPrototypeOf` with an array, lazy JSON array, Map, Set, Error, +/// Promise, Date, RegExp or Temporal cell, `dyn_eval` with a closure. Only the +/// kinds that can never be a receiver stand outside it: strings and bigints +/// are primitives, meta records and compiled regex programs are internal. +/// `GC_TYPE_OBJECT` stays inside — its prototypes live in its meta record, and +/// the registry's obligations were always met for it too. +/// +/// The collector keys both of the registry's per-owner obligations on this +/// one predicate: the relocation rekey (`gc/layout/transfer.rs`) and the +/// value visit (`gc/layout_slot_visit.rs`). Both used to be wired to arrays +/// and ordinary objects by hand, so every other movable owner lost its +/// explicit prototype at its first relocation, and none had the prototype +/// value traced or rewritten. +#[inline] +pub(crate) fn residual_prototype_owner_type(obj_type: u8) -> bool { + !matches!( + obj_type, + crate::gc::GC_TYPE_STRING + | crate::gc::GC_TYPE_BIGINT + | crate::gc::GC_TYPE_OBJECT_META + | crate::gc::GC_TYPE_REGEX_PROGRAM + ) +} + /// Migrate the residual side-table entry when an owner's allocation address /// changes, either through moving GC or an `ArrayHeader` growth replacement. /// Mirrors `closure_dynamic_props_owner_moved`. diff --git a/crates/perry-runtime/src/util_syserr.rs b/crates/perry-runtime/src/util_syserr.rs index d115e4f642..758ece9dcd 100644 --- a/crates/perry-runtime/src/util_syserr.rs +++ b/crates/perry-runtime/src/util_syserr.rs @@ -148,11 +148,168 @@ fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { t } -#[cfg(not(unix))] +#[cfg(windows)] +fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { + // Keyed by the POSITIVE magnitude, like the unix table above: `lookup` + // negates it. Codes `uv_internal` already carries are skipped so the two + // tables cannot disagree about a key. + UV_WINDOWS_ERRNOS + .iter() + .filter(|(_, name, _)| !uv_internal().iter().any(|(_, other, _)| other == name)) + .map(|(errno, name, message)| (-errno, *name, *message)) + .collect() +} + +#[cfg(not(any(unix, windows)))] fn errno_backed() -> Vec<(i32, &'static str, &'static str)> { Vec::new() } +/// libuv's error numbers on Windows, with libuv's messages. +/// +/// `include/uv/errno.h` defines each `UV__E*` as `-errno` only on a platform +/// that has that errno and is not `_WIN32`; on Windows every code falls back to +/// a fixed negative number, so `UV__ENOENT` is `-4058` rather than `-2`. Node +/// reports those as `err.errno` there. The messages are libuv's own +/// (`UV_ERRNO_MAP` in `uv.h`) and are the same text the unix table carries — +/// `windows_and_unix_tables_agree` pins that. +/// +/// The set is the filesystem-relevant one: every code `win32_error_to_uv` can +/// produce, plus the ones `io_error_code`'s `ErrorKind` fallback can name. +/// +/// Only Windows reads these tables; `cfg(test)` keeps them (and the pure +/// translation below) compiled — and asserted — on every host. +#[cfg(any(windows, test))] +const UV_WINDOWS_ERRNOS: &[(i32, &str, &str)] = &[ + (-4093, "E2BIG", "argument list too long"), + (-4092, "EACCES", "permission denied"), + (-4088, "EAGAIN", "resource temporarily unavailable"), + (-4083, "EBADF", "bad file descriptor"), + (-4082, "EBUSY", "resource busy or locked"), + (-4081, "ECANCELED", "operation canceled"), + (-4080, "ECHARSET", "invalid Unicode character"), + (-4075, "EEXIST", "file already exists"), + (-4074, "EFAULT", "bad address in system call argument"), + (-4028, "EFTYPE", "inappropriate file type or format"), + (-4071, "EINVAL", "invalid argument"), + (-4070, "EIO", "i/o error"), + (-4068, "EISDIR", "illegal operation on a directory"), + (-4067, "ELOOP", "too many symbolic links encountered"), + (-4066, "EMFILE", "too many open files"), + (-4064, "ENAMETOOLONG", "name too long"), + (-4058, "ENOENT", "no such file or directory"), + (-4057, "ENOMEM", "not enough memory"), + (-4055, "ENOSPC", "no space left on device"), + (-4052, "ENOTDIR", "not a directory"), + (-4051, "ENOTEMPTY", "directory not empty"), + (-4049, "ENOTSUP", "operation not supported on socket"), + (-4048, "EPERM", "operation not permitted"), + (-4047, "EPIPE", "broken pipe"), + (-4043, "EROFS", "read-only file system"), + (-4039, "ETIMEDOUT", "connection timed out"), + (-4037, "EXDEV", "cross-device link not permitted"), + (-4095, "EOF", "end of file"), + (-4094, "UNKNOWN", "unknown error"), +]; + +/// libuv's `uv_translate_sys_error` (`src/win/error.c` in libuv v1.52.1, the +/// libuv node 26.5.1 ships) restricted to the Win32 errors a filesystem call +/// returns. The socket (`WSAE*`) and network `ERROR_*` arms are left out: +/// nothing on this path produces them, and an unmapped code keeps the existing +/// `ErrorKind` fallback. The Win32 names are documentation; the numbers are +/// `windows-sys`' `Win32::Foundation` values. +#[cfg(any(windows, test))] +const WIN32_TO_UV: &[(i32, &str, &str)] = &[ + (740, "ERROR_ELEVATION_REQUIRED", "EACCES"), + (1920, "ERROR_CANT_ACCESS_FILE", "EACCES"), + (232, "ERROR_NO_DATA", "EAGAIN"), + (1004, "ERROR_INVALID_FLAGS", "EBADF"), + (6, "ERROR_INVALID_HANDLE", "EBADF"), + (33, "ERROR_LOCK_VIOLATION", "EBUSY"), + (231, "ERROR_PIPE_BUSY", "EBUSY"), + (32, "ERROR_SHARING_VIOLATION", "EBUSY"), + (995, "ERROR_OPERATION_ABORTED", "ECANCELED"), + (1113, "ERROR_NO_UNICODE_TRANSLATION", "ECHARSET"), + (183, "ERROR_ALREADY_EXISTS", "EEXIST"), + (80, "ERROR_FILE_EXISTS", "EEXIST"), + (998, "ERROR_NOACCESS", "EFAULT"), + (122, "ERROR_INSUFFICIENT_BUFFER", "EINVAL"), + (13, "ERROR_INVALID_DATA", "EINVAL"), + (87, "ERROR_INVALID_PARAMETER", "EINVAL"), + (1464, "ERROR_SYMLINK_NOT_SUPPORTED", "EINVAL"), + (1102, "ERROR_BEGINNING_OF_MEDIA", "EIO"), + (1111, "ERROR_BUS_RESET", "EIO"), + (23, "ERROR_CRC", "EIO"), + (1166, "ERROR_DEVICE_DOOR_OPEN", "EIO"), + (1165, "ERROR_DEVICE_REQUIRES_CLEANING", "EIO"), + (1393, "ERROR_DISK_CORRUPT", "EIO"), + (1129, "ERROR_EOM_OVERFLOW", "EIO"), + (1101, "ERROR_FILEMARK_DETECTED", "EIO"), + (31, "ERROR_GEN_FAILURE", "EIO"), + (1106, "ERROR_INVALID_BLOCK_LENGTH", "EIO"), + (1117, "ERROR_IO_DEVICE", "EIO"), + (1104, "ERROR_NO_DATA_DETECTED", "EIO"), + (205, "ERROR_NO_SIGNAL_SENT", "EIO"), + (110, "ERROR_OPEN_FAILED", "EIO"), + (1103, "ERROR_SETMARK_DETECTED", "EIO"), + (156, "ERROR_SIGNAL_REFUSED", "EIO"), + (1921, "ERROR_CANT_RESOLVE_FILENAME", "ELOOP"), + (4, "ERROR_TOO_MANY_OPEN_FILES", "EMFILE"), + (111, "ERROR_BUFFER_OVERFLOW", "ENAMETOOLONG"), + (206, "ERROR_FILENAME_EXCED_RANGE", "ENAMETOOLONG"), + (161, "ERROR_BAD_PATHNAME", "ENOENT"), + // libuv maps ERROR_DIRECTORY to ENOENT, not ENOTDIR. + (267, "ERROR_DIRECTORY", "ENOENT"), + (203, "ERROR_ENVVAR_NOT_FOUND", "ENOENT"), + (2, "ERROR_FILE_NOT_FOUND", "ENOENT"), + (123, "ERROR_INVALID_NAME", "ENOENT"), + (15, "ERROR_INVALID_DRIVE", "ENOENT"), + (4392, "ERROR_INVALID_REPARSE_DATA", "ENOENT"), + (126, "ERROR_MOD_NOT_FOUND", "ENOENT"), + (3, "ERROR_PATH_NOT_FOUND", "ENOENT"), + (8, "ERROR_NOT_ENOUGH_MEMORY", "ENOMEM"), + (14, "ERROR_OUTOFMEMORY", "ENOMEM"), + (82, "ERROR_CANNOT_MAKE", "ENOSPC"), + (112, "ERROR_DISK_FULL", "ENOSPC"), + (277, "ERROR_EA_TABLE_FULL", "ENOSPC"), + (1100, "ERROR_END_OF_MEDIA", "ENOSPC"), + (39, "ERROR_HANDLE_DISK_FULL", "ENOSPC"), + (145, "ERROR_DIR_NOT_EMPTY", "ENOTEMPTY"), + (50, "ERROR_NOT_SUPPORTED", "ENOTSUP"), + (109, "ERROR_BROKEN_PIPE", "EOF"), + // libuv reports a denied Win32 access as EPERM, not EACCES. + (5, "ERROR_ACCESS_DENIED", "EPERM"), + (1314, "ERROR_PRIVILEGE_NOT_HELD", "EPERM"), + (230, "ERROR_BAD_PIPE", "EPIPE"), + (233, "ERROR_PIPE_NOT_CONNECTED", "EPIPE"), + (19, "ERROR_WRITE_PROTECT", "EROFS"), + (121, "ERROR_SEM_TIMEOUT", "ETIMEDOUT"), + (17, "ERROR_NOT_SAME_DEVICE", "EXDEV"), + (1, "ERROR_INVALID_FUNCTION", "EISDIR"), + (208, "ERROR_META_EXPANSION_TOO_LONG", "E2BIG"), + (193, "ERROR_BAD_EXE_FORMAT", "EFTYPE"), +]; + +/// The libuv error number a code has on Windows (`UV__ENOENT` → `-4058`). +#[cfg(any(windows, test))] +pub(crate) fn uv_windows_errno(code: &str) -> Option { + UV_WINDOWS_ERRNOS + .iter() + .find_map(|(errno, name, _)| (*name == code).then_some(*errno)) +} + +/// The libuv `(errno, code)` for a Win32 error, or `None` when libuv's table +/// has no filesystem arm for it (the caller then keeps its `ErrorKind` +/// fallback). Pure, so it is unit tested on every host; only `io_error_code` +/// and `io_error_errno` call it, under `cfg(windows)`. +#[cfg(any(windows, test))] +pub(crate) fn win32_error_to_uv(win32: i32) -> Option<(i32, &'static str)> { + let code = WIN32_TO_UV + .iter() + .find_map(|(value, _, code)| (*value == win32).then_some(*code))?; + Some((uv_windows_errno(code)?, code)) +} + /// libuv-internal codes with no system errno — fixed negative keys. fn uv_internal() -> &'static [(i32, &'static str, &'static str)] { &[ @@ -288,6 +445,12 @@ pub(crate) fn system_error_name_for_code(code: i64) -> String { } } +/// libuv's message for a libuv-style code (`-2` → "no such file or +/// directory"), if mapped. Shared with the fs error builders. +pub(crate) fn system_error_message_for_code(code: i64) -> Option<&'static str> { + lookup(code).map(|(_, message)| message) +} + fn system_error_name(value: f64) -> String { let code = validate_system_error_code(value); system_error_name_for_code(code) @@ -340,6 +503,73 @@ pub extern "C" fn js_util_get_system_error_map() -> f64 { mod tests { use super::*; + /// libuv's Windows translation is pure, so it is checked on every host — + /// this repo cannot run Windows. + #[test] + fn win32_errors_translate_to_libuv_windows_codes() { + // (Win32 code, libuv code) pairs read off `uv_translate_sys_error`. + assert_eq!(win32_error_to_uv(2), Some((-4058, "ENOENT"))); // ERROR_FILE_NOT_FOUND + assert_eq!(win32_error_to_uv(3), Some((-4058, "ENOENT"))); // ERROR_PATH_NOT_FOUND + assert_eq!(win32_error_to_uv(123), Some((-4058, "ENOENT"))); // ERROR_INVALID_NAME + // libuv maps a denied Win32 access to EPERM and ERROR_DIRECTORY to + // ENOENT — neither is the errno name a unix reader would guess. + assert_eq!(win32_error_to_uv(5), Some((-4048, "EPERM"))); // ERROR_ACCESS_DENIED + assert_eq!(win32_error_to_uv(267), Some((-4058, "ENOENT"))); // ERROR_DIRECTORY + assert_eq!(win32_error_to_uv(183), Some((-4075, "EEXIST"))); // ERROR_ALREADY_EXISTS + assert_eq!(win32_error_to_uv(145), Some((-4051, "ENOTEMPTY"))); // ERROR_DIR_NOT_EMPTY + assert_eq!(win32_error_to_uv(6), Some((-4083, "EBADF"))); // ERROR_INVALID_HANDLE + assert_eq!(win32_error_to_uv(4), Some((-4066, "EMFILE"))); // ERROR_TOO_MANY_OPEN_FILES + assert_eq!(win32_error_to_uv(32), Some((-4082, "EBUSY"))); // ERROR_SHARING_VIOLATION + assert_eq!(win32_error_to_uv(17), Some((-4037, "EXDEV"))); // ERROR_NOT_SAME_DEVICE + assert_eq!(win32_error_to_uv(1), Some((-4068, "EISDIR"))); // ERROR_INVALID_FUNCTION + assert_eq!(win32_error_to_uv(112), Some((-4055, "ENOSPC"))); // ERROR_DISK_FULL + // A socket/network arm, or anything libuv does not map, declines so the + // caller keeps its `ErrorKind` fallback. + assert_eq!(win32_error_to_uv(10061), None); // WSAECONNREFUSED + assert_eq!(win32_error_to_uv(0), None); + } + + #[test] + fn the_windows_tables_are_consistent() { + for (win32, win32_name, code) in WIN32_TO_UV { + assert!( + uv_windows_errno(code).is_some(), + "{win32_name} maps to {code}, which UV_WINDOWS_ERRNOS does not carry" + ); + assert_eq!( + WIN32_TO_UV.iter().filter(|(v, _, _)| v == win32).count(), + 1, + "{win32_name} ({win32}) is listed twice" + ); + } + for (errno, name, _) in UV_WINDOWS_ERRNOS { + assert!(*errno < 0, "{name} must be a negative libuv code"); + assert_eq!( + UV_WINDOWS_ERRNOS + .iter() + .filter(|(_, n, _)| n == name) + .count(), + 1, + "{name} is listed twice" + ); + } + } + + /// The Windows table and the tables serving `util.getSystemErrorMessage` + /// must not drift: a code in both says the same thing. + #[cfg(unix)] + #[test] + fn windows_and_unix_tables_agree_on_messages() { + for (_, name, message) in UV_WINDOWS_ERRNOS { + if let Some((_, _, unix)) = errno_backed().iter().find(|(_, n, _)| n == name) { + assert_eq!(message, unix, "{name} message drifted"); + } + if let Some((_, _, internal)) = uv_internal().iter().find(|(_, n, _)| n == name) { + assert_eq!(message, internal, "{name} message drifted"); + } + } + } + #[cfg(unix)] #[test] fn names_and_messages_match_libuv() { diff --git a/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs b/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs index f08ca5589c..4c1f7ec454 100644 --- a/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs +++ b/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs @@ -1,16 +1,20 @@ //! `Function.apply(null, )` is a CreateDynamicFunction //! surface, exactly like `new Function(body)` — the constructor is merely reached -//! indirectly. Perry cannot compile a body built from runtime data, so the site must -//! be classified as an eval surface and lowered to the deferred, located -//! "cannot run in an ahead-of-time compiled binary" error. +//! indirectly. Perry cannot compile a body built from runtime data ahead of time, +//! so the site is classified as an eval surface. //! -//! Before the fix the classifier only recognized `Function.apply(this, [])`. A runtime-built argument list fell through to the generic lowering and -//! evaluated to `undefined`; the caller then invoked `.apply` on that `undefined` and -//! failed several frames away with a misleading "Function.prototype.apply was called -//! on a value that is not a function". mysql2's row-parser codegen is exactly this -//! shape — `Function.apply(null, argNames.concat(body)).apply(null, argValues)` — -//! so a real MySQL query died with an error naming neither eval nor the real cause. +//! Before the classifier recognized it, a runtime-built argument list fell through +//! to the generic lowering and evaluated to `undefined`; the caller then invoked +//! `.apply` on that `undefined` and failed several frames away with a misleading +//! "Function.prototype.apply was called on a value that is not a function". +//! mysql2's row-parser codegen is exactly this shape — +//! `Function.apply(null, argNames.concat(body)).apply(null, argValues)`. +//! +//! The classified site then compiled to a stub that always threw "cannot run in an +//! ahead-of-time compiled binary", even though `new Function(body)` with the same +//! runtime body already ran on the #6559 interpreter. #10422: every call spelling +//! now builds the function at runtime through that interpreter, so the mysql2 shape +//! produces the function Node produces. //! //! Literal-source forms must keep working: those are const-folded and compiled AOT. @@ -137,7 +141,7 @@ fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output } #[test] -fn function_apply_with_runtime_args_defers_to_a_located_aot_error() { +fn function_apply_with_runtime_args_builds_the_function_at_runtime() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); write_fixture(root); @@ -168,8 +172,9 @@ fn function_apply_with_runtime_args_defers_to_a_located_aot_error() { "literal-source Function/apply/call must still be compiled AOT; got:\n{stdout}" ); - // Reaching it throws a catchable, descriptive Error — NOT `undefined` flowing on - // into a bogus "apply was called on a value that is not a function". + // Reaching it builds the function from the runtime-assembled arguments and + // calls it, as node does — not `undefined` flowing on into a bogus "apply was + // called on a value that is not a function", and not the always-throwing stub. let run2 = Command::new(&bin) .arg("--dynamic") .output() @@ -183,19 +188,11 @@ fn function_apply_with_runtime_args_defers_to_a_located_aot_error() { ); let stdout2 = String::from_utf8_lossy(&run2.stdout); assert!( - stdout2.contains("CAUGHT:"), - "the runtime-assembled Function.apply must throw a catchable Error; got:\n{stdout2}" - ); - assert!( - stdout2.contains("cannot run in an ahead-of-time compiled binary"), - "the thrown Error must name the AOT limitation; got:\n{stdout2}" - ); - assert!( - !stdout2.contains("was called on a value that is not a function"), - "must NOT degrade into `undefined` and fail later inside `.apply`; got:\n{stdout2}" + stdout2.contains("NO_THROW:101"), + "the runtime-assembled Function.apply must build a working function; got:\n{stdout2}" ); assert!( - !stdout2.contains("NO_THROW"), - "the dynamic Function site must not silently produce a value; got:\n{stdout2}" + !stdout2.contains("CAUGHT:"), + "the dynamic Function site must not throw; got:\n{stdout2}" ); } diff --git a/test-files/_helpers/add_minutes_10463.ts b/test-files/_helpers/add_minutes_10463.ts new file mode 100644 index 0000000000..ff31942fa1 --- /dev/null +++ b/test-files/_helpers/add_minutes_10463.ts @@ -0,0 +1,9 @@ +// Helper for test_gap_10463_entry_block_allocas.ts: the date-fns 4.4.0 +// `addMinutes` shape (`addMinutes.js:31-34`). Imported, so that the +// cross-module inliner copies its `setTime` call into the caller's loop. +export function addMinutes(date: Date | number, amount: number): Date { + const _date = new Date(date instanceof Date ? date.getTime() : date); + if (isNaN(amount)) return new Date(NaN); + _date.setTime(_date.getTime() + amount * 60_000); + return _date; +} diff --git a/test-files/_helpers/export_default_fn_10434/alias.ts b/test-files/_helpers/export_default_fn_10434/alias.ts new file mode 100644 index 0000000000..eae18fd182 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/alias.ts @@ -0,0 +1,10 @@ +// Control: `export { F as default }` after the declaration. +function Alias(this: any, value?: unknown) { + this.value = value; +} +Alias.prototype.read = function (this: any) { + return "alias:" + typeof this.value; +}; +(Alias as any).kind = "alias-static"; +export const holder = { Alias }; +export { Alias as default }; diff --git a/test-files/_helpers/export_default_fn_10434/arrow.ts b/test-files/_helpers/export_default_fn_10434/arrow.ts new file mode 100644 index 0000000000..189d18fcab --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/arrow.ts @@ -0,0 +1,5 @@ +// Control: an arrow function held in a const. +const arrow: any = (a?: unknown, b?: unknown) => typeof a + "," + typeof b; +arrow.kind = "arrow-static"; +export const holder = { arrow }; +export default arrow; diff --git a/test-files/_helpers/export_default_fn_10434/ctor.ts b/test-files/_helpers/export_default_fn_10434/ctor.ts new file mode 100644 index 0000000000..da0476e22b --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/ctor.ts @@ -0,0 +1,20 @@ +// `function F(){}` + expandos, then `export default F;` (#10434). +function Point(this: any, x?: unknown, y?: unknown) { + this.x = x; + this.y = y; +} +Point.prototype.describe = function (this: any) { + return "Point(" + String(this.x) + "," + String(this.y) + ")"; +}; +(Point as any).origin = "static-origin"; +(Point as any).create = function (x: unknown) { + return new (Point as any)(x, "via-static"); +}; +export const holder = { Point }; +export function isPoint(value: unknown) { + return value instanceof Point; +} +export function makeLocal() { + return new (Point as any)("local", 1); +} +export default Point; diff --git a/test-files/_helpers/export_default_fn_10434/cycle_a.ts b/test-files/_helpers/export_default_fn_10434/cycle_a.ts new file mode 100644 index 0000000000..536f1c8e22 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/cycle_a.ts @@ -0,0 +1,10 @@ +import Beta, { betaHolder } from "./cycle_b.ts"; +function Alpha() { + return "alpha"; +} +(Alpha as any).tag = "alpha-tag"; +export const alphaHolder = { Alpha }; +export function describeBeta() { + return Beta() + ":" + (Beta as any).tag + ":" + String(Beta === betaHolder.Beta); +} +export default Alpha; diff --git a/test-files/_helpers/export_default_fn_10434/cycle_b.ts b/test-files/_helpers/export_default_fn_10434/cycle_b.ts new file mode 100644 index 0000000000..6f61c44349 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/cycle_b.ts @@ -0,0 +1,10 @@ +import Alpha, { alphaHolder } from "./cycle_a.ts"; +function Beta() { + return "beta"; +} +(Beta as any).tag = "beta-tag"; +export const betaHolder = { Beta }; +export function describeAlpha() { + return Alpha() + ":" + (Alpha as any).tag + ":" + String(Alpha === alphaHolder.Alpha); +} +export default Beta; diff --git a/test-files/_helpers/export_default_fn_10434/decl.ts b/test-files/_helpers/export_default_fn_10434/decl.ts new file mode 100644 index 0000000000..61898da86d --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/decl.ts @@ -0,0 +1,9 @@ +// Control: `export default function F() {}`. +export default function Decl(this: any, value?: unknown) { + this.value = value; +} +Decl.prototype.read = function (this: any) { + return "decl:" + typeof this.value; +}; +(Decl as any).kind = "decl-static"; +export const holder = { Decl }; diff --git a/test-files/_helpers/export_default_fn_10434/fexpr.ts b/test-files/_helpers/export_default_fn_10434/fexpr.ts new file mode 100644 index 0000000000..67b4df3569 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/fexpr.ts @@ -0,0 +1,10 @@ +// Control: a function expression held in a const. +const Expr: any = function (this: any, value?: unknown) { + this.value = value; +}; +Expr.prototype.read = function (this: any) { + return "expr:" + typeof this.value; +}; +Expr.kind = "expr-static"; +export const holder = { Expr }; +export default Expr; diff --git a/test-files/_helpers/export_default_fn_10434/hoisted.ts b/test-files/_helpers/export_default_fn_10434/hoisted.ts new file mode 100644 index 0000000000..b4672beb86 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/hoisted.ts @@ -0,0 +1,10 @@ +// The export clause precedes the hoisted declaration it names. +export default Later; +export const holder = { Later }; +function Later(this: any, value?: unknown) { + this.value = value; +} +Later.prototype.read = function (this: any) { + return "later:" + typeof this.value; +}; +(Later as any).kind = "later-static"; diff --git a/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts b/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts new file mode 100644 index 0000000000..e791bb9d34 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts @@ -0,0 +1,10 @@ +// Same, through an alias clause ahead of the declaration. +export { Aliased as default }; +export const holder = { Aliased }; +function Aliased(this: any, value?: unknown) { + this.value = value; +} +Aliased.prototype.read = function (this: any) { + return "aliased:" + typeof this.value; +}; +(Aliased as any).kind = "aliased-static"; diff --git a/test-files/_helpers/export_default_fn_10434/klass.ts b/test-files/_helpers/export_default_fn_10434/klass.ts new file mode 100644 index 0000000000..b360539c7a --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/klass.ts @@ -0,0 +1,10 @@ +// Control: `export default K` for a class. +class Klass { + static kind = "class-static"; + read() { + return "class-read"; + } +} +(Klass as any).extra = "class-extra"; +export const holder = { Klass }; +export default Klass; diff --git a/test-files/_helpers/export_default_fn_10434/params.ts b/test-files/_helpers/export_default_fn_10434/params.ts new file mode 100644 index 0000000000..c4798bf896 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/params.ts @@ -0,0 +1,5 @@ +// Default and rest parameters behind `export default `. +function withDefaults(a?: unknown, b: number = 5, ...rest: unknown[]) { + return String(a) + "," + b + "," + rest.length; +} +export default withDefaults; diff --git a/test-files/_helpers/export_default_fn_10434/parens.ts b/test-files/_helpers/export_default_fn_10434/parens.ts new file mode 100644 index 0000000000..2038af548e --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/parens.ts @@ -0,0 +1,10 @@ +// Parenthesized / type-asserted identifier. +function Wrapped(this: any, value?: unknown) { + this.value = value; +} +Wrapped.prototype.read = function (this: any) { + return "wrapped:" + typeof this.value; +}; +(Wrapped as any).kind = "wrapped-static"; +export const holder = { Wrapped }; +export default (Wrapped as unknown as typeof Wrapped); diff --git a/test-files/_helpers/export_default_fn_10434/plain.ts b/test-files/_helpers/export_default_fn_10434/plain.ts new file mode 100644 index 0000000000..a14ae72d8d --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/plain.ts @@ -0,0 +1,7 @@ +// A plain (non-constructor) declared function, default-exported by name. +function kinds(a?: unknown, b?: unknown, c?: unknown) { + return typeof a + "," + typeof b + "," + typeof c; +} +(kinds as any).label = "kinds-label"; +export const holder = { kinds }; +export default kinds; diff --git a/test-files/_helpers/export_default_fn_10434/second_importer.ts b/test-files/_helpers/export_default_fn_10434/second_importer.ts new file mode 100644 index 0000000000..72eb92bc61 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/second_importer.ts @@ -0,0 +1,11 @@ +// A second importer of the same default exports, plus a barrel re-export. +import Point from "./ctor.ts"; +import kinds from "./plain.ts"; +export { default as PointViaBarrel } from "./ctor.ts"; +export { default as kindsViaBarrel } from "./plain.ts"; +export function pointSeenHere() { + return Point; +} +export function kindsSeenHere() { + return kinds; +} diff --git a/test-files/test_gap_10421_function_ctor_as_value.ts b/test-files/test_gap_10421_function_ctor_as_value.ts new file mode 100644 index 0000000000..1333a6a79d --- /dev/null +++ b/test-files/test_gap_10421_function_ctor_as_value.ts @@ -0,0 +1,69 @@ +// #10421 / #10423: the `Function` constructor reached as a VALUE, with bodies +// built at runtime. The file has no literal `new Function(...)` or +// `Function(...)` call with a runtime body, so in an auto-optimized build only +// the value uses themselves can keep the runtime interpreter linked (#10421). +// Calling the value without `new` returned `undefined`, and +// `fn.constructor(...)` returned an empty object (#10423). +const body = ["return a", "+", "b"].join(" "); + +function show(label: string, make: () => any): void { + try { + const f = make(); + console.log(label, typeof f, typeof f === "function" ? f(2, 3) : String(f)); + } catch (e) { + console.log(label, "threw", (e as Error).name); + } +} + +const F: any = Function; +show("alias new", () => new F("a", "b", body)); +show("alias call", () => F("a", "b", body)); +show("alias call, constant body", () => F("a", "b", "return a + b")); +show("alias call, one param list", () => F("a, b", body)); +show("bind", () => Function.bind(null, "a", "b")(body)); +show("Reflect.construct", () => Reflect.construct(Function, ["a", "b", body])); +show("Reflect.apply", () => Reflect.apply(Function, undefined, ["a", "b", body])); +show("computed global key", () => new (globalThis as any)["Func" + "tion"]("a", "b", body)); +show("destructured global", () => { + const { Function: G } = globalThis as any; + return new G("a", "b", body); +}); +show("fn.constructor call", () => (function () {}).constructor("a", "b", body)); +show("fn.constructor new", () => new ((function () {}).constructor as any)("a", "b", body)); +show("arrow.constructor call", () => ((() => 0) as any).constructor("a", "b", body)); +show("getPrototypeOf(fn).constructor", () => { + const C = Object.getPrototypeOf(function () {}).constructor; + return C("a", "b", body); +}); + +// lodash 4.18.1 `runInContext` rebinds the global (`lodash.js:1456`). +function runInContext(context: any) { + var Function = context.Function; + return Function("a", "b", body); +} +show("lodash context.Function", () => runInContext(globalThis)); + +// A CommonJS module whose export is the constructor itself. +const moduleLike: any = { exports: {} }; +moduleLike.exports = Function; +show("module.exports = Function", () => moduleLike.exports("a", "b", body)); + +console.log( + "identity", + F === Function, + (function () {}).constructor === Function, + typeof F, + F.name, + F.length, +); + +const twice = F("x", ["return", "x * 2"].join(" ")); +console.log( + "built function", + twice(21), + twice.length, + twice.name, + twice.call(null, 4), + twice.apply(null, [5]), +); +show("syntax error", () => F("a", ["return", ")"].join(" "))); diff --git a/test-files/test_gap_10422_function_call_runtime_body.ts b/test-files/test_gap_10422_function_call_runtime_body.ts new file mode 100644 index 0000000000..18569dbe66 --- /dev/null +++ b/test-files/test_gap_10422_function_call_runtime_body.ts @@ -0,0 +1,61 @@ +// #10422: `Function(...)` called without `new` — directly, through +// `Function.apply` and through `Function.call` — with a body built at runtime. +// These call forms compiled to a stub that always threw "new Function() cannot +// run in an ahead-of-time compiled binary", while `new Function(...)` with the +// same arguments ran on the runtime interpreter. +const body = ["return a", "+", "b"].join(" "); + +function show(label: string, make: () => any): void { + try { + const f = make(); + console.log(label, typeof f, typeof f === "function" ? f(2, 3) : String(f)); + } catch (e) { + console.log(label, "threw", (e as Error).name); + } +} + +show("new Function(a, b, body)", () => new Function("a", "b", body)); +show("Function(a, b, body)", () => Function("a", "b", body)); +show("Function(body)", () => Function(["return", "7"].join(" "))); +show("Function.apply(null, [a, b, body])", () => Function.apply(null, ["a", "b", body])); +show("Function.call(null, a, b, body)", () => Function.call(null, "a", "b", body)); +show("Function.apply, runtime list", () => { + const keys = "a b".split(" "); + return Function.apply(undefined, keys.concat(body)); +}); +show("Function(...spread)", () => Function(...["a", "b", body])); +show("Function.call(null, ...spread)", () => Function.call(null, ...["a", "b", body])); +show("Function.apply(null)", () => (Function as any).apply(null)); +show("syntax error", () => Function("a", ["return", ")"].join(" "))); + +// generate-function 2.3.1 `toFunction`, which mysql2 3.23.2 uses for every +// row parser (`generate-function/index.js:172`): +// return Function.apply(null, keys.concat(src)).apply(null, vals) +function genfun() { + const lines: string[] = []; + const gen: any = function (line: string) { + lines.push(line); + return gen; + }; + gen.toFunction = function (scope: any) { + const src = "return (" + lines.join("\n") + ")"; + const keys = Object.keys(scope || {}); + const vals = keys.map((key) => scope[key]); + return Function.apply(null, keys.concat(src)).apply(null, vals); + }; + return gen; +} +const gen = genfun(); +gen("function add(a, b) {")("return a + b + offset")("}"); +const add = gen.toFunction({ offset: 100 }); +console.log("generate-function", typeof add, add(2, 3)); + +// A row parser in the same style: generated code over a runtime column list. +const columns = ["id", "name", "score"]; +const parserSource = [ + "var row = {};", + ...columns.map((c, i) => "row[" + JSON.stringify(c) + "] = values[" + i + "];"), + "return row;", +].join("\n"); +const parseRow = Function.apply(null, ["values", parserSource]); +console.log("row", JSON.stringify(parseRow([1, "ann", 9.5]))); diff --git a/test-files/test_gap_10424_function_ctor_to_string_args.ts b/test-files/test_gap_10424_function_ctor_to_string_args.ts new file mode 100644 index 0000000000..d46bd93a8f --- /dev/null +++ b/test-files/test_gap_10424_function_ctor_to_string_args.ts @@ -0,0 +1,95 @@ +// #10424: the Function constructor applies ToString to every argument, left +// to right, and a spread argument list passes its elements. A non-string +// argument used to read as "" (an array of parameter names lost its +// parameters) and a spread array became a single argument (an empty body). +const body = ["return a", "+", "b"].join(" "); + +function show(label: string, make: () => any): void { + try { + const f = make(); + console.log(label, typeof f, typeof f === "function" ? f(2, 3) : String(f)); + } catch (e) { + console.log(label, "threw", (e as Error).name); + } +} + +show("string params (control)", () => new Function("a,b", body)); +show("array params", () => new Function(["a", "b"] as any, body)); +show("array params, constant body", () => new Function(["a", "b"] as any, "return a + b")); +show("call, array params", () => Function(["a", "b"] as any, "return a + b")); +show("toString params", () => new Function({ toString: () => "a,b" } as any, body)); +show("numeric param", () => new Function(5 as any, body)); +show("array body", () => new Function("a", "b", ["return a * b"] as any)); +show("spread all", () => new Function(...["a", "b", body])); +show("spread tail", () => new Function("a", ...["b", body])); +show("spread head", () => new Function(...["a"], "b", body)); +show("spread runtime list", () => { + const names = "a,b".split(","); + return new Function(...names, body); +}); +show("array params length", () => { + const f = new Function(["a", "b", "c"] as any, body); + return f.length; +}); + +// Conversion is left to right and happens before the source is parsed. +const order: string[] = []; +function tracked(name: string, value: string): any { + return { + toString() { + order.push(name); + return value; + }, + }; +} +show("tracked", () => new Function(tracked("p1", "a"), tracked("p2", "b"), tracked("body", body))); +console.log("order", order.join(",")); +order.length = 0; +show("tracked syntax error", () => new Function(tracked("p1", "a"), tracked("body", "return )"))); +console.log("order before syntax error", order.join(",")); + +try { + new Function( + { + toString() { + throw new RangeError("from toString"); + }, + } as any, + body, + ); + console.log("toString throw: none"); +} catch (e) { + console.log("toString throw", (e as Error).name, (e as Error).message); +} +try { + new Function(Symbol("s") as any, body); + console.log("symbol: none"); +} catch (e) { + console.log("symbol", (e as Error).name); +} + +// Rest parameters with a runtime body. +show("rest param", () => new Function("...xs", ["return xs.length * 10 +", "xs[1]"].join(" "))); +show("param then rest", () => new Function("a", "...rest", ["return a +", "rest.length"].join(" "))); + +// lodash 4.18.1 `_.template` builds its render function this way +// (`lodash.js:14992`, `lodash/template.js:271`): +// Function(importsKeys, sourceURL + 'return ' + source).apply(undefined, importsValues) +const importsKeys = ["_", "escape"]; +const importsValues = [ + { upper: (s: string) => s.toUpperCase() }, + (s: string) => s.split("<").join("<"), +]; +const source = [ + "function(obj) {", + "var __p = '';", + "__p += 'Hello ' + escape(_.upper(obj.user)) + '!';", + "return __p", + "}", +].join("\n"); +const sourceURL = "//# sourceURL=lodash.templateSources[0]\n"; +const render = (Function as any)(importsKeys, sourceURL + "return " + source).apply( + undefined, + importsValues, +); +console.log("template", typeof render, render({ user: "" })); diff --git a/test-files/test_gap_10430_stream_module_constructor.ts b/test-files/test_gap_10430_stream_module_constructor.ts new file mode 100644 index 0000000000..d36238d527 --- /dev/null +++ b/test-files/test_gap_10430_stream_module_constructor.ts @@ -0,0 +1,267 @@ +// #10430 / #10431: Node's `stream` module value — `require('stream')` and the +// default import `import Stream from "node:stream"` — IS the legacy `Stream` +// constructor, and that constructor extends EventEmitter +// (`lib/internal/streams/legacy.js`: `ObjectSetPrototypeOf(Stream.prototype, +// EE.prototype)` + `ObjectSetPrototypeOf(Stream, EE)`). The module's exports +// (`Readable`, `pipeline`, `promises`, …) hang off it as statics. +// +// Perry used to hand back a separate namespace object: `typeof` said +// "function", but `x instanceof Stream` threw "Right-hand side of 'instanceof' +// is not callable" (node-fetch's `body instanceof Stream`), `Stream !== +// NamedStream`, and nothing inherited from EventEmitter — +// `require('stream').EventEmitter` was undefined, so redis's +// `class ClientSideCacheProvider extends stream_1.EventEmitter` threw +// "Class extends value is not a constructor" at module init. +import { createRequire } from "node:module"; +import StreamDefault, { + Duplex, + PassThrough, + Readable, + Stream, + Stream as AliasedStream, + Transform, + Writable, + pipeline, +} from "node:stream"; +import BareStreamDefault from "stream"; +import * as streamNs from "node:stream"; +import EventsDefault, { EventEmitter } from "node:events"; + +const req = createRequire(import.meta.url); +const cjsStream: any = req("stream"); +const cjsEvents: any = req("events"); +const S: any = StreamDefault; + +const t = (name: string, f: () => unknown) => { + try { + console.log(name, f()); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + } +}; + +// ── 1. the module value is the named `Stream` constructor (#10431) ── +t("typeof default import:", () => typeof StreamDefault); +t("typeof require('stream'):", () => typeof cjsStream); +t("typeof namespace:", () => typeof streamNs); +t("default === Stream:", () => StreamDefault === Stream); +t("bare default === Stream:", () => BareStreamDefault === Stream); +t("aliased === Stream:", () => AliasedStream === Stream); +t("require('stream') === Stream:", () => cjsStream === Stream); +t("require('stream') === default:", () => cjsStream === StreamDefault); +t("ns.default === Stream:", () => (streamNs as any).default === Stream); +t("ns.Stream === Stream:", () => streamNs.Stream === Stream); +t("require('stream').Stream === require('stream'):", () => cjsStream.Stream === cjsStream); +t("default.Stream === default:", () => S.Stream === S); +t("typeof Stream.prototype:", () => typeof S.prototype); +t("Stream.prototype.constructor === Stream:", () => S.prototype.constructor === Stream); + +// ── 2. instanceof with the module value on the right-hand side ── +const readable = new Readable({ read() {} }); +const writable = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, +}); +const duplex = new Duplex({ + read() {}, + write(_chunk, _enc, cb) { + cb(); + }, +}); +const transform = new Transform({ + transform(chunk, _enc, cb) { + cb(null, chunk); + }, +}); +const passThrough = new PassThrough(); +const instances = [ + ["Readable", readable], + ["Writable", writable], + ["Duplex", duplex], + ["Transform", transform], + ["PassThrough", passThrough], +] as const; +for (const [name, value] of instances) { + t(`${name} instanceof default:`, () => value instanceof StreamDefault); + t(`${name} instanceof require('stream'):`, () => value instanceof cjsStream); + t(`${name} instanceof Stream:`, () => value instanceof Stream); + t(`${name} instanceof EventEmitter:`, () => value instanceof EventEmitter); +} +t("null instanceof default:", () => (null as any) instanceof StreamDefault); +t("{} instanceof require('stream'):", () => ({}) instanceof cjsStream); +t("EventEmitter instance instanceof Stream:", () => new EventEmitter() instanceof StreamDefault); +t("inline instanceof require:", () => passThrough instanceof req("stream")); + +// ── 3. `new Stream()` is an EventEmitter-backed legacy stream ── +function listen(name: string, emitter: any) { + let got = 0; + try { + emitter.on("tick", (n: number) => { + got += n; + }); + emitter.emit("tick", 2); + emitter.emit("tick", 3); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + return; + } + console.log( + name, + got, + emitter instanceof Stream, + emitter instanceof cjsStream, + emitter instanceof EventEmitter, + ); +} +listen("new Stream():", new Stream()); +listen("new default():", new StreamDefault()); +listen("new aliased():", new AliasedStream()); +listen("new require('stream')():", new cjsStream()); +listen("new (any local)():", new S()); +listen("Object.create(Stream.prototype):", Object.create(S.prototype)); + +// ── 4. EventEmitter inheritance (#10430) ── +t("getPrototypeOf(require('stream')) === require('events'):", () => + Object.getPrototypeOf(cjsStream) === cjsEvents, +); +t("getPrototypeOf(default) === EventEmitter:", () => Object.getPrototypeOf(StreamDefault) === EventEmitter); +t("require('stream').EventEmitter === require('events'):", () => cjsStream.EventEmitter === cjsEvents); +t("default.EventEmitter === EventEmitter:", () => S.EventEmitter === EventEmitter); +t("typeof default.EventEmitter:", () => typeof (StreamDefault as any).EventEmitter); +t("hasOwn(require('stream'), 'EventEmitter'):", () => Object.hasOwn(cjsStream, "EventEmitter")); +t("typeof require('stream').defaultMaxListeners:", () => typeof cjsStream.defaultMaxListeners); +t("require('stream').once === require('events').once:", () => cjsStream.once === cjsEvents.once); +t("getPrototypeOf(Stream.prototype) === EventEmitter.prototype:", () => + Object.getPrototypeOf(S.prototype) === EventEmitter.prototype, +); +t("Stream.prototype instanceof EventEmitter:", () => S.prototype instanceof EventEmitter); + +// ── 5. subclassing the module value ── +for (const [name, make] of [ + [ + "class extends require('stream'):", + () => { + class Legacy extends req("stream") {} + return new Legacy(); + }, + ], + [ + "class extends default import:", + () => { + class Legacy extends StreamDefault {} + return new Legacy(); + }, + ], + [ + "class extends named Stream:", + () => { + class Legacy extends Stream {} + return new Legacy(); + }, + ], + [ + "class extends (any local):", + () => { + class Legacy extends S {} + return new Legacy(); + }, + ], + [ + "class extends require('stream').EventEmitter:", + () => { + // redis `@redis/client/dist/lib/client/cache.js` shape. + const stream_1 = req("stream"); + class ClientSideCacheProvider extends stream_1.EventEmitter {} + return new ClientSideCacheProvider(); + }, + ], +] as const) { + try { + const emitter: any = make(); + let got = 0; + emitter.on("tick", (n: number) => { + got += n; + }); + emitter.emit("tick", 4); + console.log(name, got, typeof emitter.once, emitter instanceof EventEmitter); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + } +} +{ + class Legacy extends req("stream") {} + const legacy = new Legacy(); + t("subclass instanceof require('stream'):", () => legacy instanceof cjsStream); + t("subclass instanceof default:", () => legacy instanceof StreamDefault); +} + +// ── 6. module exports reached through the constructor ── +for (const key of [ + "Readable", + "Writable", + "Duplex", + "Transform", + "PassThrough", + "pipeline", + "finished", + "compose", + "addAbortSignal", + "isReadable", + "getDefaultHighWaterMark", + "_isUint8Array", +] as const) { + t(`require('stream').${key} === ns.${key}:`, () => cjsStream[key] === (streamNs as any)[key]); + t(`default.${key} === ns.${key}:`, () => S[key] === (streamNs as any)[key]); +} +t("default.Readable === Readable (static member):", () => StreamDefault.Readable === Readable); +t("default.pipeline === pipeline (static member):", () => StreamDefault.pipeline === pipeline); +t("typeof require('stream').promises:", () => typeof cjsStream.promises); +t("typeof require('stream').promises.pipeline:", () => typeof cjsStream.promises.pipeline); +t("typeof default.promises.finished:", () => typeof S.promises.finished); +t("keys include module exports:", () => + ["Readable", "pipeline", "promises", "Stream", "_isArrayBufferView"].every((k) => + Object.keys(cjsStream).includes(k), + ), +); + +// ── 7. the module value drives real streams ── +const collected: string[] = []; +await new Promise((resolve) => { + cjsStream.pipeline( + cjsStream.Readable.from(["a", "b", "c"]), + new cjsStream.Transform({ + transform(chunk: any, _enc: string, cb: (err: Error | null, data?: string) => void) { + cb(null, String(chunk).toUpperCase()); + }, + }), + new S.Writable({ + write(chunk: any, _enc: string, cb: () => void) { + collected.push(String(chunk)); + cb(); + }, + }), + (err: Error | null | undefined) => { + console.log("callback pipeline:", err ?? null, collected.join("")); + resolve(); + }, + ); +}); +const promised: string[] = []; +await cjsStream.promises.pipeline( + S.Readable.from(["x", "y"]), + new cjsStream.Writable({ + write(chunk: any, _enc: string, cb: () => void) { + promised.push(String(chunk)); + cb(); + }, + }), +); +console.log("promises pipeline:", promised.join("")); +console.log("default.Readable.from:", (await StreamDefault.Readable.from([1, 2, 3]).toArray()).join(",")); + +// ── 8. controls: `events` already behaved ── +t("events default === EventEmitter:", () => EventsDefault === EventEmitter); +t("require('events') === EventEmitter:", () => cjsEvents === EventEmitter); +t("require('events').EventEmitter === require('events'):", () => cjsEvents.EventEmitter === cjsEvents); +t("typeof require('events').defaultMaxListeners:", () => typeof cjsEvents.defaultMaxListeners); diff --git a/test-files/test_gap_10434_export_default_fn_identity.ts b/test-files/test_gap_10434_export_default_fn_identity.ts new file mode 100644 index 0000000000..f861825f3c --- /dev/null +++ b/test-files/test_gap_10434_export_default_fn_identity.ts @@ -0,0 +1,95 @@ +// #10434: `function F(){}; export default F;` must export the very function +// object the module calls `F` — prototype methods, statics, identity across +// importers, `instanceof`, and argument padding through a function value. +import Point, { holder as pointHolder, isPoint, makeLocal } from "./_helpers/export_default_fn_10434/ctor.ts"; +import kinds, { holder as kindsHolder } from "./_helpers/export_default_fn_10434/plain.ts"; +import Later, { holder as laterHolder } from "./_helpers/export_default_fn_10434/hoisted.ts"; +import Aliased, { holder as aliasedHolder } from "./_helpers/export_default_fn_10434/hoisted_alias.ts"; +import Wrapped, { holder as wrappedHolder } from "./_helpers/export_default_fn_10434/parens.ts"; +import Alias, { holder as aliasHolder } from "./_helpers/export_default_fn_10434/alias.ts"; +import Decl, { holder as declHolder } from "./_helpers/export_default_fn_10434/decl.ts"; +import Klass, { holder as klassHolder } from "./_helpers/export_default_fn_10434/klass.ts"; +import arrow, { holder as arrowHolder } from "./_helpers/export_default_fn_10434/arrow.ts"; +import Expr, { holder as exprHolder } from "./_helpers/export_default_fn_10434/fexpr.ts"; +import withDefaults from "./_helpers/export_default_fn_10434/params.ts"; +import { + PointViaBarrel, + kindsViaBarrel, + pointSeenHere, + kindsSeenHere, +} from "./_helpers/export_default_fn_10434/second_importer.ts"; +import * as pointNs from "./_helpers/export_default_fn_10434/ctor.ts"; +import { describeBeta } from "./_helpers/export_default_fn_10434/cycle_a.ts"; +import { describeAlpha } from "./_helpers/export_default_fn_10434/cycle_b.ts"; + +// 1. Constructor function with prototype methods and statics. +const P: any = Point; +const p = new P(1); +console.log("ctor identity:", P === pointHolder.Point); +console.log("ctor statics:", P.origin, typeof P.create); +console.log("ctor prototype:", typeof P.prototype.describe, P.prototype.constructor === P); +console.log("ctor instance:", p.describe(), typeof p.y, p instanceof P, isPoint(p)); +const local = makeLocal(); +console.log("ctor local instance:", local instanceof P, local.describe()); +console.log("ctor static factory:", P.create("s").describe(), isPoint(P.create(0))); +console.log("ctor two importers:", P === pointSeenHere(), P === PointViaBarrel, P === pointNs.default); + +// 2. Plain function: identity, expando, argument padding through a value. +const k: any = kinds; +console.log("plain identity:", k === kindsHolder.kinds, k === kindsSeenHere(), k === kindsViaBarrel); +console.log("plain label:", k.label); +console.log("plain direct:", kinds(), kinds(1)); +console.log("plain via value:", k(), k(1), k(1, "b")); +console.log("plain call/apply:", k.call(null), k.apply(null, [1]), k.call(null, 1, 2, 3)); +let padded = ""; +for (let i = 0; i < 3; i++) { + const fn: any = i % 2 === 0 ? kinds : kindsSeenHere(); + padded += fn(i) + ";"; +} +console.log("plain loop:", padded); + +// Default and rest parameters, called directly and through a value. +const wd: any = withDefaults; +console.log("params direct:", withDefaults(), withDefaults(1, undefined, 7, 8)); +console.log("params via value:", wd(), wd(1), wd(1, 2, 3, 4), wd.call(null, "c")); + +// 3. Export clause ahead of the hoisted declaration, plain and aliased. +for (const [label, C, held] of [ + ["hoisted default", Later, laterHolder.Later], + ["hoisted alias", Aliased, aliasedHolder.Aliased], + ["parenthesized", Wrapped, wrappedHolder.Wrapped], +] as [string, any, any][]) { + const o = new C(); + console.log(label + ":", C === held, C.kind, typeof C.prototype.read, o.read(), o instanceof C); +} + +// 4. Forms that already worked (controls). +for (const [label, C, held] of [ + ["alias control", Alias, aliasHolder.Alias], + ["decl control", Decl, declHolder.Decl], + ["fexpr control", Expr, exprHolder.Expr], +] as [string, any, any][]) { + const o = new C(); + console.log(label + ":", C === held, C.kind, typeof C.prototype.read, o.read(), o instanceof C); +} +const K: any = Klass; +console.log("class control:", K === klassHolder.Klass, K.kind, K.extra, new K().read(), new K() instanceof K); +const a: any = arrow; +console.log("arrow control:", a === arrowHolder.arrow, a.kind, a(), a.call(null, 1)); + +// 5. Cyclic imports: each side reads the other's default after evaluation. +console.log("cycle:", describeAlpha(), describeBeta()); + +// 6. A dynamic import's `default` is the same binding as the static import. +async function viaDynamicImport() { + const ctorNs: any = await import("./_helpers/export_default_fn_10434/ctor.ts"); + const plainNs: any = await import("./_helpers/export_default_fn_10434/plain.ts"); + console.log( + "dynamic import:", + ctorNs.default === Point, + ctorNs.default === ctorNs.holder.Point, + plainNs.default === kinds, + plainNs.default.label, + ); +} +viaDynamicImport().then(() => console.log("done")); diff --git a/test-files/test_gap_10452_fs_read_error_shapes.ts b/test-files/test_gap_10452_fs_read_error_shapes.ts new file mode 100644 index 0000000000..2094d8ef81 --- /dev/null +++ b/test-files/test_gap_10452_fs_read_error_shapes.ts @@ -0,0 +1,183 @@ +// Gap test: #10452 / #10451 — node:fs read failures must surface Node's error. +// +// #10452: the Buffer-mode reads (no encoding) swallowed the failure. +// `fs.readFileSync(missing)` returned null, `readFileSync(missing, {})` +// returned undefined and `fs.promises.readFile(missing)` resolved undefined, +// so `try { readFileSync(optional) } catch { defaults }` took the wrong +// branch. A directory read succeeded or reported `open` instead of Node's +// `EISDIR ... read`. +// #10451: a `fs.createReadStream` open/read failure emitted a bare Error with +// no code/errno/syscall/path and the Rust `(os error N)` message text, and a +// failed stream handed to `fs.promises.writeFile` reported EBADF for its +// missing fd instead of the constructor's failure. +// +// Every failure prints code/errno/syscall/path/message and the own-key order; +// the successful reads are the controls. +import * as fs from "node:fs"; +import fsDefault from "node:fs"; +import { readFileSync } from "node:fs"; +import * as fsp from "node:fs/promises"; +import { readFile as readFileP } from "node:fs/promises"; + +const base = "/tmp/perry_gap_10452_fs_read_error_shapes"; +fs.rmSync(base, { recursive: true, force: true }); +fs.mkdirSync(base + "/dir", { recursive: true }); +const missing = base + "/missing.txt"; +const missingParent = base + "/no-such-dir/child.txt"; +const dir = base + "/dir"; +const ok = base + "/ok.txt"; +fs.writeFileSync(ok, "hello"); + +function shape(e: any): string { + const fields = { code: e.code, errno: e.errno, syscall: e.syscall, path: e.path, message: e.message }; + return JSON.stringify(fields) + " keys=" + Object.keys(e).join(",") + " isError=" + (e instanceof Error); +} + +function show(v: any): string { + if (Buffer.isBuffer(v)) return "Buffer<" + v.toString() + ">"; + return typeof v + " " + String(v); +} + +function sync(label: string, f: () => any): void { + try { + console.log(label, "returned", show(f())); + } catch (e: any) { + console.log(label, "threw", shape(e)); + } +} + +async function promised(label: string, f: () => Promise): Promise { + try { + console.log(label, "resolved", show(await f())); + } catch (e: any) { + console.log(label, "rejected", shape(e)); + } +} + +function callback(label: string, start: (cb: (err: any, data?: any) => void) => void): Promise { + return new Promise((resolve) => { + start((err: any, data?: any) => { + if (err) console.log(label, "err", shape(err), "data", String(data)); + else console.log(label, "err", String(err), "data", show(data)); + resolve(); + }); + }); +} + +function readStream(label: string, target: string): Promise { + return new Promise((resolve) => { + const s = fs.createReadStream(target); + const chunks: string[] = []; + s.on("error", (e: any) => { + console.log(label, "error", shape(e)); + resolve(); + }); + s.on("data", (chunk: any) => chunks.push(String(chunk))); + s.on("end", () => { + console.log(label, "end", chunks.join("")); + resolve(); + }); + }); +} + +// --- sync --- +sync("readFileSync(missing)", () => fs.readFileSync(missing)); +sync("readFileSync(missing, {})", () => fs.readFileSync(missing, {})); +sync("readFileSync(missing, {flag:'r'})", () => fs.readFileSync(missing, { flag: "r" })); +sync("readFileSync(missing, 'utf8')", () => fs.readFileSync(missing, "utf8")); +sync("readFileSync(missing, {encoding})", () => fs.readFileSync(missing, { encoding: "utf8" })); +sync("readFileSync(missing, 'latin1')", () => fs.readFileSync(missing, "latin1")); +sync("readFileSync(missingParent, {flag:'a+'})", () => fs.readFileSync(missingParent, { flag: "a+" })); +sync("named readFileSync(missing)", () => readFileSync(missing)); +sync("default fs.readFileSync(missing)", () => fsDefault.readFileSync(missing)); +sync("readFileSync(dir)", () => fs.readFileSync(dir)); +sync("readFileSync(dir, 'utf8')", () => fs.readFileSync(dir, "utf8")); +const dirFd = fs.openSync(dir, "r"); +sync("readFileSync(dirFd)", () => fs.readFileSync(dirFd)); +fs.closeSync(dirFd); +sync("readFileSync(ok)", () => fs.readFileSync(ok)); +sync("readFileSync(ok, {})", () => fs.readFileSync(ok, {})); +sync("readFileSync(ok, 'utf8')", () => fs.readFileSync(ok, "utf8")); +sync("named readFileSync(ok)", () => readFileSync(ok)); +sync("default fs.readFileSync(ok)", () => fsDefault.readFileSync(ok)); +const okFd = fs.openSync(ok, "r"); +sync("readFileSync(okFd)", () => fs.readFileSync(okFd)); +fs.closeSync(okFd); +let config: any; +try { + config = fs.readFileSync(missing); +} catch { + config = "defaults"; +} +console.log("optional-file fallback:", show(config)); + +async function main(): Promise { + // --- callback --- + await callback("readFile(missing, cb)", (cb) => fs.readFile(missing, cb)); + await callback("readFile(missing, {}, cb)", (cb) => fs.readFile(missing, {}, cb)); + await callback("readFile(missing, 'utf8', cb)", (cb) => fs.readFile(missing, "utf8", cb)); + await callback("readFile(dir, cb)", (cb) => fs.readFile(dir, cb)); + await callback("readFile(dir, 'utf8', cb)", (cb) => fs.readFile(dir, "utf8", cb)); + await callback("readFile(ok, cb)", (cb) => fs.readFile(ok, cb)); + await callback("readFile(ok, 'utf8', cb)", (cb) => fs.readFile(ok, "utf8", cb)); + + // --- promises --- + await promised("fs.promises.readFile(missing)", () => fs.promises.readFile(missing)); + await promised("fsp.readFile(missing, {})", () => fsp.readFile(missing, {})); + await promised("fsp.readFile(missing, 'utf8')", () => fsp.readFile(missing, "utf8")); + await promised("named readFile(missing)", () => readFileP(missing)); + await promised("fsp.readFile(dir)", () => fsp.readFile(dir)); + await promised("fsp.readFile(dir, 'utf8')", () => fsp.readFile(dir, "utf8")); + await promised("fsp.readFile(ok)", () => fsp.readFile(ok)); + await promised("named readFile(ok, 'utf8')", () => readFileP(ok, "utf8")); + const missingIsEnoent = await fsp.readFile(missing).then( + () => false, + (e: any) => e.code === "ENOENT", + ); + console.log("readFile(missing).catch sees ENOENT:", missingIsEnoent); + + // --- FileHandle --- + await promised("fsp.open(missing)", () => fsp.open(missing)); + const dirHandle = await fsp.open(dir); + await promised("dirHandle.readFile()", () => dirHandle.readFile()); + await dirHandle.close(); + const okHandle = await fsp.open(ok); + await promised("okHandle.readFile()", () => okHandle.readFile()); + await okHandle.close(); + + // --- streams --- + await readStream("createReadStream(missing)", missing); + await readStream("createReadStream(dir)", dir); + await readStream("createReadStream(ok)", ok); + await new Promise((resolve) => { + fs.createWriteStream(missingParent).on("error", (e: any) => { + console.log("createWriteStream(missingParent) error", shape(e)); + resolve(); + }); + }); + + // A read stream consumed by fs.promises.writeFile must report the failure the + // constructor saw, not a later EBADF for its missing fd. Node's callback and + // sync writeFile reject a stream outright (ERR_INVALID_ARG_TYPE), so only the + // promise form is covered; the `'error'` listener keeps the failure handled, + // which is what makes the ordering deterministic. + const handled = (target: string) => { + const stream = fs.createReadStream(target); + stream.on("error", () => {}); + return stream; + }; + await promised("writeFile(out, readStream(missing))", () => + fsp.writeFile(base + "/copy-missing.txt", handled(missing)), + ); + await promised("writeFile(out, readStream(dir))", () => + fsp.writeFile(base + "/copy-dir.txt", handled(dir)), + ); + await promised("writeFile(out, readStream(ok))", () => + fsp.writeFile(base + "/copy-ok.txt", handled(ok)), + ); + console.log("copy-ok.txt:", show(fs.readFileSync(base + "/copy-ok.txt"))); + + fs.rmSync(base, { recursive: true, force: true }); +} + +main(); diff --git a/test-files/test_gap_10463_entry_block_allocas.ts b/test-files/test_gap_10463_entry_block_allocas.ts new file mode 100644 index 0000000000..5e0480a432 --- /dev/null +++ b/test-files/test_gap_10463_entry_block_allocas.ts @@ -0,0 +1,123 @@ +// #10463: several lowerings emitted their argument buffer (`alloca [N x double]`) +// or out-parameter (`alloca i64`) into whatever block was current instead of +// the function's entry block. Inside a loop that is a stack bump on every +// iteration, released only when the function returns, so these loops died +// with SIGSEGV once they had consumed the stack (~2^19 iterations for one +// 16-byte buffer at the default 8 MB). Each loop below would take at least +// 19 MB of stack that way on its own; with the buffers in the entry block it +// runs in constant stack. + +import { addMinutes } from "./_helpers/add_minutes_10463.ts"; + +function dateSetters(n: number): number { + const d = new Date(0); + let acc = 0; + for (let i = 0; i < n; i++) { + d.setTime(i * 1000); + acc = (acc + d.getUTCSeconds()) % 1_000_003; + d.setUTCMinutes(i % 60); + d.setUTCFullYear(2000 + (i % 30), i % 12, 1 + (i % 28)); + d.setUTCHours(i % 24, i % 60, i % 60, i % 1000); + acc = (acc + d.getUTCHours() + d.getUTCMonth() + d.getUTCMinutes()) % 1_000_003; + } + return acc + d.getTime(); +} + +function dateUtc(n: number): number { + let acc = 0; + for (let i = 0; i < n; i++) { + acc = (acc + (Date.UTC(2000 + (i % 50), i % 12) % 7919)) % 1_000_003; + acc = (acc + (Date.UTC(1970, 0, 1 + (i % 28), i % 24) % 7907)) % 1_000_003; + } + return acc; +} + +function toSpliced(n: number): number { + const a = [1, 2, 3]; + let acc = 0; + for (let i = 0; i < n; i++) { + const b = a.toSpliced(1, 1, i); + const c = b.toSpliced(0, 2, i, i + 1, i + 2); + acc = (acc + b[1] + c[2] + b.length + c.length) % 1_000_003; + } + return acc; +} + +function concat(n: number): number { + const a = [1, 2, 3]; + let acc = 0; + for (let i = 0; i < n; i++) { + const b = a.concat(i); + const c = b.concat([i + 1], i + 2); + acc = (acc + b[3] + c[5] + b.length + c.length) % 1_000_003; + } + return acc; +} + +function spliceLocal(n: number): number { + const a = [1, 2, 3]; + let acc = 0; + for (let i = 0; i < n; i++) { + const removed = a.splice(1, 1, i); + const none = a.splice(1, 0); + acc = (acc + removed[0] + none.length + a[1] + a.length) % 1_000_003; + } + return acc + a[0] + a[2]; +} + +class Holder { + arr: number[] = [1, 2, 3]; +} + +function unshiftSpliceField(n: number): number { + const h = new Holder(); + let acc = 0; + for (let i = 0; i < n; i++) { + h.arr.unshift(i); + h.arr.shift(); + const removed = h.arr.splice(0, 1, i); + acc = (acc + removed[0] + h.arr[0] + h.arr.length) % 1_000_003; + } + return acc; +} + +function arrayPrototypeCall(n: number): number { + const a: number[] = []; + let acc = 0; + for (let i = 0; i < n; i++) { + Array.prototype.push.call(a, i, i + 1); + Array.prototype.unshift.call(a, i + 2); + const removed: any = Array.prototype.splice.call(a, 0, 3); + const joined: any = Array.prototype.concat.call(removed, i); + acc = (acc + removed[1] + joined.length + joined[3] + a.length) % 1_000_003; + } + // The same generic lowerings over a plain array-like object. + const o: any = { length: 0 }; + Array.prototype.push.call(o, 1, 2); + Array.prototype.unshift.call(o, 0); + const r: any = Array.prototype.splice.call(o, 1, 1); + const j: any = Array.prototype.concat.call([7], o); + return acc + o.length * 10 + r[0] + o[1] + j.length; +} + +// The date-fns 4.4.0 `addMinutes` shape, imported: the cross-module inliner +// copies the helper's `setTime` call into this loop. +function addMinutesLoop(n: number): number { + let d = new Date(Date.UTC(2020, 0, 1)); + let acc = 0; + for (let i = 0; i < n; i++) { + const next = addMinutes(d, 1); + d = next; + acc = (acc + d.getUTCMinutes()) % 1_000_003; + } + return acc + d.getTime(); +} + +console.log("date setters", dateSetters(250_000)); +console.log("Date.UTC", dateUtc(600_000)); +console.log("toSpliced", toSpliced(600_000)); +console.log("concat", concat(600_000)); +console.log("splice (local)", spliceLocal(400_000)); +console.log("unshift/splice (field)", unshiftSpliceField(400_000)); +console.log("Array.prototype.*.call", arrayPrototypeCall(300_000)); +console.log("addMinutes in a loop", addMinutesLoop(1_200_000));