diff --git a/CLAUDE.md b/CLAUDE.md index ec3ed18294..4cac7805fc 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.1598 +**Current Version:** 0.5.1599 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 1f0159a2c9..26751c66d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1598" +version = "0.5.1599" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "lru", "perry-ffi", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "chrono", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "bson", "futures-util", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "nanoid", "perry-ffi", @@ -6078,7 +6078,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "bytes", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "lettre", "perry-ffi", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "notify", "perry-ffi", @@ -6134,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "printpdf", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "sqlx", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "perry-runtime", @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "governor", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "fast_image_resize", "image", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "lazy_static", "perry-ffi", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-ffi", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "perry-runtime", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "uuid", @@ -6225,7 +6225,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-ffi", "perry-validation", @@ -6234,7 +6234,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "futures-util", "lazy_static", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "brotli", "flate2", @@ -6257,7 +6257,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6267,7 +6267,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-api-manifest", @@ -6287,11 +6287,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1598" +version = "0.5.1599" [[package]] name = "perry-parser" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "perry-diagnostics", @@ -6304,7 +6304,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perex", "regex", @@ -6312,7 +6312,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "ahash", "base64 0.22.1", @@ -6370,14 +6370,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6465,21 +6465,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "dirs", "perry-ffi", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "jni", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "rand 0.10.2", "serde", @@ -6514,7 +6514,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6537,7 +6537,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "block2", @@ -6554,7 +6554,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "block2", @@ -6571,7 +6571,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1598" +version = "0.5.1599" [[package]] name = "perry-ui-test" @@ -6582,11 +6582,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1598" +version = "0.5.1599" [[package]] name = "perry-ui-tvos" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "block2", @@ -6603,7 +6603,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "block2", @@ -6620,7 +6620,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "block2", "libc", @@ -6634,7 +6634,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "libc", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "base64 0.22.1", "libc", @@ -6666,7 +6666,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "anyhow", "base64 0.22.1", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "idna", "regex", @@ -6691,7 +6691,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1598" +version = "0.5.1599" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index aaee929951..8f4a5a3a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -337,7 +337,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1598" +version = "0.5.1599" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10636-implicit-ctor-native-super-forward.md b/changelog.d/10636-implicit-ctor-native-super-forward.md new file mode 100644 index 0000000000..26f7109546 --- /dev/null +++ b/changelog.d/10636-implicit-ctor-native-super-forward.md @@ -0,0 +1,16 @@ +Fixed a constructor-less subclass of a native base (`AsyncResource`, +`AsyncLocalStorage`, `EventEmitter`, `EventEmitterAsyncResource`, `LRUCache`, +`WebSocketServer`, the genuine `node:stream` classes) losing its `super()` +argument forwarding and native-surface install inside a CommonJS-wrapped +module — the shape real npm packages use. `const { AsyncResource } = +require("node:async_hooks")` is a genuine local there (the whole module body +runs inside the CJS wrap's IIFE), which class-heritage resolution could not +tell apart from a real user shadow of the same name, so it fell back to a +generic dynamic-value dispatch. For a base whose runtime value is a real ES +`class` (`AsyncResource`, `AsyncLocalStorage`), that dispatch called the value +without `new` and threw; for an old-style-function base (`EventEmitter`, the +stream classes) it happened to work, through a much slower indirect path +(measured ~5.5x more instructions per construction than the direct native +path). Class-heritage resolution now tracks a `require()`-destructured +binding's provenance and only treats it as shadowing when it did NOT come +from the real native module. diff --git a/changelog.d/10656-codepointat-linear.md b/changelog.d/10656-codepointat-linear.md new file mode 100644 index 0000000000..31a8d597fa --- /dev/null +++ b/changelog.d/10656-codepointat-linear.md @@ -0,0 +1,5 @@ +`String.prototype.codePointAt` is no longer O(n) per call on strings containing +non-ASCII characters. It now uses the same lazy UTF-16 index `charCodeAt` and +bracket indexing were moved to in #10067, so a sequential scan is linear rather +than quadratic. A natively compiled `tsc` goes from 658 s to 85 s on a two-line +input (#10656). diff --git a/changelog.d/10658-net-socket-surface-cluster.md b/changelog.d/10658-net-socket-surface-cluster.md new file mode 100644 index 0000000000..a0bf5d2b11 --- /dev/null +++ b/changelog.d/10658-net-socket-surface-cluster.md @@ -0,0 +1,23 @@ +Fix a cluster of four `net.Socket` gaps the package audit hit while compiling +real socket-backed npm packages (mysql2, pg, redis, ws) natively: missing +`prependListener`/`prependOnceListener` (#10441), `on()`/`addListener()` +returning `undefined` on a typed `net.Socket` receiver instead of the socket, +breaking `.on(...).on(...)` chaining (#10442), a missing `pipe()` (#10444), +and missing/incorrect `writable`/`readable`/`readyState`/`connecting`/ +`pending`/`destroyed`/`_writableState`/`_readableState` (#10465). + +Root cause was shared shape (an incomplete dispatch table, both the untyped +dynamic-dispatch path and the typed `net.Socket` codegen table), but not a +single shared fix: #10441/#10442 were table-completion, #10444 needed a new +`pipe()` implementation (`crates/perry-ext-net/src/pipe.rs`, via the same +generic `Get("write")`+call duck-typed dispatch the runtime already uses for +thenables), and #10465 needed new lifecycle state tracking +(`SocketState::connecting`/`writable_ended`/`readable_ended`/`has_opened`). + +Validating #10465 against Node byte-for-byte surfaced two additional bugs, +fixed here: `destroyed`/`is_open` were flipped on the tokio task thread as +soon as teardown started, before the main thread had processed the `'end'` +event that same teardown queued, so a `pending`/`destroyed` read from inside +an `'end'` listener disagreed with Node; and `pipe()`'s own route-tracking +table cached a socket-destination pointer outside every GC root scanner, +which a copying GC cycle between `pipe()` and `unpipe()` could turn stale. diff --git a/changelog.d/10666-collection-poll-stride.md b/changelog.d/10666-collection-poll-stride.md new file mode 100644 index 0000000000..a2cafec7ba --- /dev/null +++ b/changelog.d/10666-collection-poll-stride.md @@ -0,0 +1,3 @@ +### Faster + +- A global regular-expression replace spends 1.4-3.5% fewer instructions. The loop that collects the matches asked the collector whether it was due to run once per match, which costs about 436 instructions and could never do anything there — that loop writes its matches into a native buffer and creates nothing the collector can free. It now asks once per 64 matches, which is the same bound each search was already keeping. Peak memory is unchanged, measured over nine interleaved rounds of a replace at n=1,000,000 (#10165). diff --git a/changelog.d/10668-http-client-response-surface.md b/changelog.d/10668-http-client-response-surface.md new file mode 100644 index 0000000000..2f6787ba6b --- /dev/null +++ b/changelog.d/10668-http-client-response-surface.md @@ -0,0 +1,25 @@ +Fixed three `node:http`/`node:https` client-side defects. The client `IncomingMessage` now exposes +`rawHeaders`/`httpVersion`/`httpVersionMajor`/`httpVersionMinor`/`complete` (previously `undefined` on both the +typed and dynamically-dispatched surface); `httpVersion*`/`complete` fall back to the server-side accessor when +the handle is a server `IncomingMessage`, since the codegen native table shares one `class_filter` namespace +across client and server (#10467 — `rawHeaders` header-name casing on the pooled reqwest transport is a known +remaining gap, documented in the PR). `http.request`'s client now fires `req.on('upgrade', (res, socket, head) => +...)` on a `101 Switching Protocols` response instead of delivering it as an ordinary `'response'`: an upgrade +request speaks HTTP/1.1 over a raw socket (mirroring the existing trailer-aware bypass), and on `101` adopts the +stream as a `net.Socket` via `perry_ext_net::adopt_upgraded_tcp_stream` — write, inbound data delivery, and the +`head` Buffer (always a Buffer, never `undefined`, even zero-length) all match Node (#10468). The request option +`options.createConnection` (distinct from `agent.createConnection`) is now honored when the request has no +explicit Agent, taking the same raw-socket path the Agent-level override already used (#10469). + +Follow-up (unrooted-local-shape ratchet, caught before merge): `build_raw_headers_array` +(`res.rawHeaders`, added for #10467 above) held its result array's raw pointer in a plain local across +`alloc_string`/`js_array_push` calls that can allocate and therefore collect — a stale-pointer-after-collection +shape (`scripts/unrooted_local_shape.py`), not merely a scanner nit. Rooted it through +`perry_ffi::TransientRootScope::root_nanbox` and re-derive the pointer via `.get()` after each allocating call +instead of reusing the pre-call copy, matching the pattern already used throughout this crate (e.g. +`agent.rs`, `client_events.rs`). Found and fixed the same pre-existing shape in the neighboring +`set-cookie` array builder in `build_response_headers_object` (unrelated to this PR's diff, same file); extracted +it into its own top-level `build_set_cookie_array` so the rooting lines aren't deep enough for `rustfmt` to wrap a +`let` binding across lines, which had been hiding the second half of the binding from the ratchet's +line-oriented scanner. `scripts/unrooted_local_shape.py --check` now reports 558 (down from the pre-PR baseline +of 561; response_headers.rs's own ceiling drops from 1 to 0). diff --git a/changelog.d/10685-slice-linear.md b/changelog.d/10685-slice-linear.md new file mode 100644 index 0000000000..207f47a863 --- /dev/null +++ b/changelog.d/10685-slice-linear.md @@ -0,0 +1,5 @@ +`String.prototype.substring` / `slice` / `substr` no longer walk from byte 0 to +resolve their start offset on strings containing non-ASCII characters. They now +use the same lazy UTF-16 index the other accessors use, so slicing at increasing +offsets — every tokenizer's access pattern — is linear rather than quadratic. A +natively compiled `tsc` goes from 85 s to 7.8 s (#10685). diff --git a/changelog.d/10688-per-string-index.md b/changelog.d/10688-per-string-index.md new file mode 100644 index 0000000000..1c45c5380a --- /dev/null +++ b/changelog.d/10688-per-string-index.md @@ -0,0 +1,6 @@ +The UTF-16 index no longer lives in a fixed four-slot cache that evicted +round-robin. Interleaving indexed access across more strings than it held +rebuilt the index from scratch on every access — 1,224x slower from the fifth +string onward, as a step function. Entries now live until their string dies and +are reclaimed by the collector's existing prune hook, so the cliff cannot occur +at any number of strings (#10688). diff --git a/changelog.d/10694-array-index-gate.md b/changelog.d/10694-array-index-gate.md new file mode 100644 index 0000000000..5c533ffc2d --- /dev/null +++ b/changelog.d/10694-array-index-gate.md @@ -0,0 +1,5 @@ +Plain-array element reads and writes no longer probe the buffer and typed-array +registries. A `GC_TYPE_ARRAY` header can never be either, and the iteration +helpers already gated on that; the indexing path did not. On a `tsc --noEmit` +of a two-line file this halves `is_registered_buffer` probes, 79.7M to 39.8M +(#10694). diff --git a/changelog.d/10699-native-binding-import-provenance.md b/changelog.d/10699-native-binding-import-provenance.md new file mode 100644 index 0000000000..59a9a158fe --- /dev/null +++ b/changelog.d/10699-native-binding-import-provenance.md @@ -0,0 +1,23 @@ +**A `perry.compilePackages` copy of commander, lru-cache, or decimal.js is no +longer overridden by their bundled native bindings.** `new Command()`, +`new LRUCache()`, and `new Decimal()` chained directly onto a method call +(`new Command().name(...)`, `new LRUCache(...).set(...)`, +`new Decimal(...).dividedBy(...)`) matched those class names unconditionally +and routed straight to the native handle, even when the user asked for the +real package to be compiled from source — the only way to opt out was to +rename the import. Construction and method dispatch now resolve through the +same compilePackages-aware provenance table `is_native_module` already +consults, so a compiled copy of the real package runs its own code at its +documented import name. The (unmodified) native binding still installs when +the package is not opted into `compilePackages`. Fixes #10439. + +`crates/perry-hir/tests/fluent_chain_lowering.rs`'s +`native_fluent_chain_still_dispatches_through_native_methods` asserted the +pre-fix, ambient/no-import, spelling-based dispatch this change deliberately +tightens (a bare `new Decimal(1)` with no import now correctly falls through +to an unresolved-global reference, matching Node's `ReferenceError`, instead +of silently reaching the native handle). That test predates this change and +was never updated for it, so it went red on this same commit without this +diff touching its file — caught by the sweep's `cargo test --workspace`, +not by any diff-scoped gate. Removed here, with the rationale recorded +inline, rather than left for a descendant PR to patch around a third time. diff --git a/crates/perry-codegen/src/lower_call/native_table/http_server.rs b/crates/perry-codegen/src/lower_call/native_table/http_server.rs index f32c3f8d47..5876c5a9f3 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_server.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_server.rs @@ -691,7 +691,12 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "httpVersion", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_http_version", + // #10467 — route through the client accessor (falls back to the + // server one internally, `server_incoming_property`) so a + // client-side `res.httpVersion` resolves instead of reading the + // server-only registry and returning the "1.1" default for every + // client response. + runtime: "js_http_response_http_version", args: &[], ret: NR_STR, }, @@ -732,12 +737,14 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_STR, }, + // #10467 — same client-accessor-with-server-fallback shape as the + // bare `httpVersion` entry above. NativeModSig { module: "http", has_receiver: true, method: "__get_httpVersion", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_http_version", + runtime: "js_http_response_http_version", args: &[], ret: NR_STR, }, @@ -746,7 +753,16 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "__get_httpVersionMajor", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_http_version_major", + runtime: "js_http_response_http_version_major", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "http", + has_receiver: true, + method: "httpVersionMajor", + class_filter: Some("IncomingMessage"), + runtime: "js_http_response_http_version_major", args: &[], ret: NR_F64, }, @@ -755,18 +771,58 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "__get_httpVersionMinor", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_http_version_minor", + runtime: "js_http_response_http_version_minor", args: &[], ret: NR_F64, }, + NativeModSig { + module: "http", + has_receiver: true, + method: "httpVersionMinor", + class_filter: Some("IncomingMessage"), + runtime: "js_http_response_http_version_minor", + args: &[], + ret: NR_F64, + }, + // `js_http_response_complete` already returns a boxed JS boolean (f64 + // bit pattern), not a raw C `i32` like the server-only accessor this + // replaced — hence `NR_F64`, matching `headers`/`trailers`/`socket` + // below (also client accessors returning pre-boxed values). NativeModSig { module: "http", has_receiver: true, method: "__get_complete", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_complete", + runtime: "js_http_response_complete", args: &[], - ret: NR_I32, + ret: NR_F64, + }, + NativeModSig { + module: "http", + has_receiver: true, + method: "complete", + class_filter: Some("IncomingMessage"), + runtime: "js_http_response_complete", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "http", + has_receiver: true, + method: "__get_rawHeaders", + class_filter: Some("IncomingMessage"), + runtime: "js_http_response_raw_headers", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "http", + has_receiver: true, + method: "rawHeaders", + class_filter: Some("IncomingMessage"), + runtime: "js_http_response_raw_headers", + args: &[], + ret: NR_F64, }, NativeModSig { module: "http", diff --git a/crates/perry-codegen/src/lower_call/native_table/net_events.rs b/crates/perry-codegen/src/lower_call/native_table/net_events.rs index c885104899..5f77f56356 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_events.rs @@ -219,9 +219,61 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "on", class_filter: Some("Socket"), + // #10442 — was `ret: NR_VOID`. `js_ext_net_socket_on` (the runtime + // symbol both this row and `addListener` below call) now returns the + // socket handle (see `perry-ext-net/src/handle_exports.rs`), so a + // typed `const sock: net.Socket` can chain `sock.on(...).on(...)` + // the same way the untyped/`once`/`setNoDelay` paths already did. runtime: "js_ext_net_socket_on", args: &[NA_STR, NA_PTR], - ret: NR_VOID, + ret: NR_HANDLE_ID, + }, + // #10441 — front-inserting variants of `on`. Absent entirely pre-fix: + // a typed `net.Socket` receiver fell through to a plain property read + // for `prependListener`/`prependOnceListener` and got `undefined`, + // matching the untyped-dispatch gap fixed in `dispatch.rs`'s + // `socket_method_name`. + NativeModSig { + module: "net", + has_receiver: true, + method: "prependListener", + class_filter: Some("Socket"), + runtime: "js_net_socket_prepend_listener", + args: &[NA_STR, NA_PTR], + ret: NR_HANDLE_ID, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "prependOnceListener", + class_filter: Some("Socket"), + runtime: "js_net_socket_prepend_once_listener", + args: &[NA_STR, NA_PTR], + ret: NR_HANDLE_ID, + }, + // #10444 — `net.Socket` is a `stream.Duplex`; `pipe`/`unpipe` had no + // typed-receiver row at all (nor an untyped one — see + // `dispatch.rs`'s `socket_method_name`). `js_net_socket_pipe` returns + // `dest` (an arbitrary JSValue, NOT a socket handle — hence NR_F64, the + // same return kind the generic `stream` table's own `pipe` row uses) + // for chaining, matching Node. + NativeModSig { + module: "net", + has_receiver: true, + method: "pipe", + class_filter: Some("Socket"), + runtime: "js_net_socket_pipe", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "unpipe", + class_filter: Some("Socket"), + runtime: "js_net_socket_unpipe", + args: &[NA_F64], + ret: NR_HANDLE_ID, }, // Issue #1852 — chainable no-op `net.Socket` option setters. Perry's // TCP transport doesn't model Nagle/keep-alive/idle-timeout or read @@ -385,6 +437,66 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_STR, }, + // #10465 — `writable`/`readable`/`writableEnded`/`readableEnded`/ + // `_writableState`/`_readableState` were entirely absent from this + // table (a typed `net.Socket` read `undefined` for all six; pg's + // `Connection._send` gates every protocol write on `this.stream.writable` + // being truthy, so the audit's client silently dropped its startup + // message and hung until the connection timeout). + NativeModSig { + module: "net", + has_receiver: true, + method: "writable", + class_filter: None, + runtime: "js_net_socket_get_writable", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "readable", + class_filter: None, + runtime: "js_net_socket_get_readable", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "writableEnded", + class_filter: None, + runtime: "js_net_socket_get_writable_ended", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "readableEnded", + class_filter: None, + runtime: "js_net_socket_get_readable_ended", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "_writableState", + class_filter: None, + runtime: "js_net_socket_get_writable_state", + args: &[], + ret: NR_OBJ_FROM_JSON_STR, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "_readableState", + class_filter: None, + runtime: "js_net_socket_get_readable_state", + args: &[], + ret: NR_OBJ_FROM_JSON_STR, + }, NativeModSig { module: "net", has_receiver: true, @@ -511,9 +623,10 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "addListener", class_filter: Some("Socket"), + // #10442 — same fix as the `on` row above (same runtime symbol). runtime: "js_ext_net_socket_on", args: &[NA_STR, NA_PTR], - ret: NR_VOID, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs index 4628dbdfec..60109cc7cb 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs @@ -141,6 +141,12 @@ pub(crate) fn declare_net_http(module: &mut LlModule) { ); module.declare_function("js_http_response_headers", DOUBLE, &[I64]); module.declare_function("js_http_response_trailers", DOUBLE, &[I64]); + // #10467 — client rawHeaders / httpVersion* / complete accessors. + module.declare_function("js_http_response_raw_headers", DOUBLE, &[I64]); + module.declare_function("js_http_response_http_version", I64, &[I64]); + module.declare_function("js_http_response_http_version_major", DOUBLE, &[I64]); + module.declare_function("js_http_response_http_version_minor", DOUBLE, &[I64]); + module.declare_function("js_http_response_complete", DOUBLE, &[I64]); module.declare_function("js_http_incoming_message_socket", DOUBLE, &[I64]); module.declare_function("js_http_incoming_message_req", DOUBLE, &[I64]); module.declare_function("js_http_incoming_message_set_encoding", I64, &[I64, I64]); diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index f53f626d7c..08307331b9 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -478,6 +478,15 @@ unsafe fn read_closure_field(obj_f64: f64, field: &str) -> i64 { } } +/// Extract `options.createConnection` (#10469) — the request-level socket +/// override Node honors when the caller does not pass an explicit `agent`. +/// Like `options.agent`, a closure doesn't survive the `options` JSON +/// round-trip (`parse_options_object`), so this reads the NaN-boxed field +/// straight off the original object instead. +pub(crate) unsafe fn request_create_connection_from_options(options_f64: f64) -> i64 { + read_closure_field(options_f64, "createConnection") +} + /// Extract an `options.agent` handle from `options_f64`. Returns `None` /// when the field is missing, not a pointer, or doesn't resolve to an /// AgentHandle. @@ -1675,9 +1684,43 @@ pub(crate) unsafe fn try_create_connection_socket( if cc == 0 { return None; } + invoke_create_connection_closure(cc, Some(handle), host, port, path) +} + +/// #10469 — invoke the request option's own `createConnection` override +/// (no explicit Agent involved, so there's no `AgentHandle` to pull +/// `keepAlive` defaults from — Node's own default Agent has `keepAlive: +/// false`, matched by `build_connect_options(None, ...)`). +pub(crate) unsafe fn try_request_create_connection_socket( + closure_ptr: i64, + host: &str, + port: u16, + path: &str, +) -> Option { + if closure_ptr == 0 { + return None; + } + invoke_create_connection_closure(closure_ptr, None, host, port, path) +} + +/// Shared tail of both `createConnection` invocation paths: root +/// `closure_ptr` *before* calling `build_connect_options` (it allocates — +/// without rooting first, a GC during that allocation could move the +/// closure out from under the raw `i64` copy, matching the ordering the +/// original #2154 code used), call it with `{ host, port, path, keepAlive, +/// keepAliveInitialDelay }`, and extract the `net.Socket` handle id it +/// returns. Main thread only — JS closure calls must not run on a tokio +/// worker. +unsafe fn invoke_create_connection_closure( + closure_ptr: i64, + agent_handle: Option, + host: &str, + port: u16, + path: &str, +) -> Option { let scope = perry_ffi::TransientRootScope::enter(); - let cc = scope.root_addr(cc); - let options = scope.root_nanbox(build_connect_options(handle, host, port, path)); + let cc = scope.root_addr(closure_ptr); + let options = scope.root_nanbox(build_connect_options(agent_handle, host, port, path)); let closure = JsClosure::from_raw(cc.get() as *const RawClosureHeader); let ret = closure.call1(options.get()); @@ -1711,7 +1754,7 @@ pub(crate) fn create_socket_override(handle: Handle) -> i64 { /// Returns a NaN-boxed object pointer as `f64`, or NaN-boxed `undefined` on /// allocation failure. pub(crate) unsafe fn build_connect_options( - handle: Handle, + handle: Option, host: &str, port: u16, path: &str, @@ -1750,9 +1793,12 @@ pub(crate) unsafe fn build_connect_options( 2, JsValue::from_string_ptr(path_s.as_raw()), ); - let (keep_alive, keep_alive_msecs) = agent_field(handle, (false, 1000.0), |agent| { - (agent.keep_alive, agent.keep_alive_msecs) - }); + let (keep_alive, keep_alive_msecs) = match handle { + Some(h) => agent_field(h, (false, 1000.0), |agent| { + (agent.keep_alive, agent.keep_alive_msecs) + }), + None => (false, 1000.0), + }; perry_ffi::js_object_set_field( JsValue::from_bits(obj.get().to_bits()).as_pointer(), 3, diff --git a/crates/perry-ext-http/src/client_connect_override.rs b/crates/perry-ext-http/src/client_connect_override.rs new file mode 100644 index 0000000000..bbe5bbf7e1 --- /dev/null +++ b/crates/perry-ext-http/src/client_connect_override.rs @@ -0,0 +1,211 @@ +//! Client requests routed over a caller-supplied raw socket instead of +//! reqwest: both `agent.createConnection`/`agent.createSocket` (#2154) and +//! the request option's own `createConnection` (#10469, honored only when +//! `agent_handle == 0`) end up here. Split out of `lib.rs` to stay under +//! the file-size cap; the closure storage/invocation and the `{ host, port, +//! path, keepAlive, keepAliveInitialDelay }` options object still live in +//! `agent.rs` alongside the pre-existing Agent-level override. + +use std::collections::HashMap; + +use perry_ffi::{spawn_blocking_with_reactor as spawn_blocking, Handle}; + +use super::agent; +use crate::{parse_http_response, push_event, ClientInflightGuard, PendingHttpEvent}; + +/// Look up `request_handle`'s own `createConnection` (if any) and, when +/// set, dispatch over it. `None` means "not set / not usable" — the +/// caller (only reached when `agent_handle == 0`) falls back to reqwest. +pub(crate) fn dispatch_for_handle(request_handle: Handle, url: &str) -> Option { + let cc = perry_ffi::with_handle_mut::(request_handle, |r| { + r.request_create_connection + }) + .unwrap_or(0); + if cc == 0 { + return None; + } + request_create_connection_socket(cc, url) +} + +/// The whole "no explicit Agent, but the request's own `createConnection` +/// is set" path: resolve `(host, port, path)` from `url`, invoke the +/// override on the main thread, and attach raw mode on the socket it +/// returns (so no inbound byte gets dispatched as a JS `'data'` event +/// before `dispatch_request_over_socket`'s task takes over — mirrors the +/// Agent-override path in `dispatch_request_snapshot`). `None` means "not +/// handled", so the caller falls back to the reqwest path. +pub(crate) fn request_create_connection_socket( + request_create_connection: i64, + url: &str, +) -> Option { + let (host, port, path) = super::socket_connect_target(url)?; + let socket_id = unsafe { + agent::try_request_create_connection_socket(request_create_connection, &host, port, &path) + }?; + if let Some(vt) = perry_ffi::raw_net() { + (vt.attach)(socket_id); + } + Some(socket_id) +} + +/// Serialize an HTTP/1.1 request (request line + headers + body) into the +/// bytes to write onto a socket. Forces `Connection: close` (the raw socket +/// path reads until EOF), drops any caller-supplied `Connection`/`Host` +/// header (we set `Host` from the URL), and adds `Content-Length` when a +/// body is present and the caller didn't. +fn serialize_http_request( + method: &str, + path: &str, + host_header: &str, + headers: &HashMap, + body: &[u8], +) -> Vec { + let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); + let mut has_content_length = false; + for (k, v) in headers { + if k.eq_ignore_ascii_case("content-length") { + has_content_length = true; + } + if k.eq_ignore_ascii_case("connection") || k.eq_ignore_ascii_case("host") { + continue; + } + req.push_str(k); + req.push_str(": "); + req.push_str(v); + req.push_str("\r\n"); + } + req.push_str("Connection: close\r\n"); + if !body.is_empty() && !has_content_length { + req.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + req.push_str("\r\n"); + let mut out = req.into_bytes(); + out.extend_from_slice(body); + out +} + +/// #2154 — run an HTTP exchange over a socket that a `createConnection` +/// override (Agent-level or, since #10469, request-level) produced +/// (`socket_id`), instead of through reqwest. Writes the serialized +/// request, reads the response until the peer closes (we force +/// `Connection: close`), parses it with [`parse_http_response`], and pushes +/// the same `Response` / `Error` event the reqwest path produces — so the +/// IncomingMessage surface is identical. +/// +/// The socket I/O goes through perry-ffi's raw-net vtable (published by +/// perry-ext-net), so this crate needs no link edge to perry-ext-net. If no +/// net backend is linked the request errors out (the override couldn't have +/// produced a socket without `net`, so this is a defensive guard). +pub(crate) fn dispatch_request_over_socket( + request_handle: Handle, + method: String, + url: String, + headers: HashMap, + body: Vec, + timeout_ms: Option, + socket_id: i64, +) { + let parsed = match reqwest::Url::parse(&url) { + Ok(u) => u, + Err(e) => { + push_event(PendingHttpEvent::Error { + request_handle, + error_message: e.to_string(), + }); + return; + } + }; + let host = parsed.host_str().unwrap_or("localhost").to_string(); + let host_header = match parsed.port() { + Some(p) => format!("{}:{}", host, p), + None => host, + }; + let mut path = parsed.path().to_string(); + if path.is_empty() { + path.push('/'); + } + if let Some(q) = parsed.query() { + path.push('?'); + path.push_str(q); + } + let req_bytes = serialize_http_request(&method, &path, &host_header, &headers, &body); + let deadline = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)); + + spawn_blocking(move || { + let try_h = tokio::runtime::Handle::try_current(); + std::hint::black_box(&try_h); + if try_h.is_err() { + push_event(PendingHttpEvent::Error { + request_handle, + error_message: "http client runtime unavailable".to_string(), + }); + return; + } + let handle = tokio::runtime::Handle::current(); + // #5779 follow-up: keep this fetch counted in-flight for its whole + // lifetime so the idle-kick recovers a lost worker-unpark. + let inflight_guard = ClientInflightGuard::new(request_handle); + let jh = handle.spawn(async move { + let _inflight = inflight_guard; + let vtable = match perry_ffi::raw_net() { + Some(v) => v, + None => { + push_event(PendingHttpEvent::Error { + request_handle, + error_message: "agent.createConnection requires node:net (not linked)" + .to_string(), + }); + return; + } + }; + // Attach is idempotent — the request path also attaches on the + // main thread before this task runs, to close any data race. + (vtable.attach)(socket_id); + if (vtable.write)(socket_id, req_bytes.as_ptr(), req_bytes.len()) == 0 { + push_event(PendingHttpEvent::Error { + request_handle, + error_message: "failed to write request to agent socket".to_string(), + }); + return; + } + + let mut raw = Vec::new(); + let mut chunk = [0u8; 16 * 1024]; + let start = tokio::time::Instant::now(); + loop { + let n = (vtable.poll_read)(socket_id, chunk.as_mut_ptr(), chunk.len()); + if n > 0 { + raw.extend_from_slice(&chunk[..n as usize]); + } else if n == 0 { + break; // clean EOF — peer closed after the response + } else { + if start.elapsed() >= deadline { + (vtable.close)(socket_id); + push_event(PendingHttpEvent::Timeout { request_handle }); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + } + (vtable.close)(socket_id); + + match parse_http_response(&raw) { + Ok(parsed) => push_event(PendingHttpEvent::Response { + request_handle, + status: parsed.status, + status_message: parsed.status_message, + headers: parsed.headers, + trailers: parsed.trailers, + body: parsed.body, + http_version: parsed.http_version, + }), + Err(error_message) => push_event(PendingHttpEvent::Error { + request_handle, + error_message, + }), + } + }); + std::hint::black_box(&jh); + std::mem::forget(jh); + }); +} diff --git a/crates/perry-ext-http/src/client_dispatch.rs b/crates/perry-ext-http/src/client_dispatch.rs index 34d418c4ab..8ce6af9ab4 100644 --- a/crates/perry-ext-http/src/client_dispatch.rs +++ b/crates/perry-ext-http/src/client_dispatch.rs @@ -20,6 +20,21 @@ use crate::{ /// fresh detached task on the same multi-thread runtime; it drives /// itself via `await` chains while we return immediately. Mirrors /// the `spawn_socket_runner` pattern in `perry-ext-net`. +/// #10467 — map a reqwest response's negotiated HTTP version to the +/// `(major, minor)` pair `IncomingMessage.httpVersion*` expects. The pooled +/// client only ever sees these five; anything else (there isn't one today) +/// falls back to `(1, 1)`. +fn reqwest_version_pair(v: reqwest::Version) -> (u8, u8) { + match v { + reqwest::Version::HTTP_09 => (0, 9), + reqwest::Version::HTTP_10 => (1, 0), + reqwest::Version::HTTP_11 => (1, 1), + reqwest::Version::HTTP_2 => (2, 0), + reqwest::Version::HTTP_3 => (3, 0), + _ => (1, 1), + } +} + pub(crate) fn dispatch_request( request_handle: Handle, method: String, @@ -79,6 +94,28 @@ pub(crate) fn dispatch_request( let inflight_guard = ClientInflightGuard::new(request_handle); let jh = handle.spawn(async move { let _inflight = inflight_guard; + // #10468 — `Connection: Upgrade` needs the raw socket handed + // back on `101`, which reqwest can't do. Checked before the + // trailer-aware bypass below (disjoint triggers: `TE: trailers` + // vs `Connection: Upgrade`, never both on the same request). + if let Some(result) = crate::client_upgrade::dispatch_upgrade_http_request( + request_handle, + method.as_str(), + &url, + &headers, + &body, + timeout_ms, + ) + .await + { + if let Err(error_message) = result { + push_event(PendingHttpEvent::Error { + request_handle, + error_message, + }); + } + return; + } if let Some(result) = dispatch_plain_http_request( request_handle, method.as_str(), @@ -148,6 +185,7 @@ pub(crate) fn dispatch_request( .canonical_reason() .unwrap_or("") .to_string(); + let http_version = reqwest_version_pair(response.version()); let mut hdrs = Vec::new(); for (k, v) in response.headers() { if let Ok(s) = v.to_str() { @@ -164,6 +202,7 @@ pub(crate) fn dispatch_request( status, status_message, headers: hdrs, + http_version, }); loop { match response.chunk().await { diff --git a/crates/perry-ext-http/src/client_events.rs b/crates/perry-ext-http/src/client_events.rs index 88b89e2e88..8d27eb392f 100644 --- a/crates/perry-ext-http/src/client_events.rs +++ b/crates/perry-ext-http/src/client_events.rs @@ -256,6 +256,7 @@ pub(crate) unsafe fn handle_response_event( headers: Vec<(String, String)>, trailers: Vec<(String, String)>, body: Vec, + http_version: (u8, u8), ) { // #4909 — a destroyed request delivers nothing (Node tears the // exchange down); `completed` also suppresses any late timeout timer. @@ -291,6 +292,10 @@ pub(crate) unsafe fn handle_response_event( pipes: Vec::new(), socket_handle, request_handle, + http_version, + // Whole body already fully received by construction time (this is + // the synchronous single-event path). + complete: true, }); // Hand the IncomingMessage handle to the user's `(res) => { ... }` @@ -391,6 +396,96 @@ pub(crate) unsafe fn handle_response_event( fire_request_close_once(request_handle); } +/// Drain handler for `PendingHttpEvent::Upgrade` (#10468): build a +/// lightweight client `IncomingMessage` (statusCode/headers only — the body +/// is the upgraded protocol now, delivered over the adopted socket instead) +/// and fire `req.on('upgrade', (res, socket, head) => ...)` with +/// `(res, socket, head)`, Node's exact argument shape. `socket` is the +/// `net.Socket` id `client_upgrade::dispatch_upgrade_http_request` already +/// adopted via `perry_ext_net::adopt_upgraded_tcp_stream`; `head` is any +/// bytes the peer sent past the header block, as a `Buffer` (never a lossy +/// string — the write side of #10471 stays server-only, this is a fresh +/// client-side implementation). +/// +/// # Safety +/// +/// Same listener-liveness contract as [`fire_request_event_listeners`]. +pub(crate) unsafe fn handle_upgrade_event( + request_handle: Handle, + status: u16, + status_message: String, + headers: Vec<(String, String)>, + socket_handle: Handle, + head: Vec, +) { + let already_done = with_handle_mut::(request_handle, |req| { + let was = req.completed; + req.completed = true; + was + }) + .unwrap_or(true); + if already_done { + return; + } + client_abort::cleanup_request_signal(request_handle); + + // Main-thread companion of `adopt_upgraded_tcp_stream` (#4973) — must + // run before user code touches the socket. + if socket_handle != 0 { + perry_ext_net::ensure_adopted_socket_dispatch(); + } + + let incoming = register_handle(IncomingMessageHandle { + status_code: status, + status_message, + headers, + trailers: HashMap::new(), + body: Vec::new(), + listeners: HashMap::new(), + encoding: None, + decoder_pending: Vec::new(), + pipes: Vec::new(), + socket_handle, + request_handle, + http_version: (1, 1), + complete: true, + }); + + let upgrade_listeners = with_handle_mut::(request_handle, |req| { + take_request_event_listeners(req, "upgrade") + }) + .unwrap_or_default(); + + let res_arg = f64::from_bits(POINTER_TAG | (incoming as u64 & PTR_MASK)); + let socket_arg = if socket_handle == 0 { + f64::from_bits(TAG_UNDEFINED) + } else { + f64::from_bits(POINTER_TAG | (socket_handle as u64 & PTR_MASK)) + }; + // Node always hands the listener a Buffer here, even when the peer sent + // no bytes past the header block (`Buffer.isBuffer(head) === true` for a + // zero-length upgrade head) — never `undefined`. + let head_arg = { + let buf = perry_ffi::alloc_buffer(&head); + f64::from_bits(POINTER_TAG | (buf as u64 & PTR_MASK)) + }; + + let scope = perry_ffi::TransientRootScope::enter(); + let res_arg = scope.root_nanbox(res_arg); + let socket_arg = scope.root_nanbox(socket_arg); + let head_arg = scope.root_nanbox(head_arg); + let listeners = scope.root_addrs(&upgrade_listeners); + for cb in listeners { + if cb.get() != 0 { + let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); + let _ = closure.call3(res_arg.get(), socket_arg.get(), head_arg.get()); + } + } + + finish_agent_request(request_handle, false); + fire_request_close_once(request_handle); +} + /// Drain handler for `PendingHttpEvent::ResponseHead` (streaming path): /// build the IncomingMessage handle with an empty body, remember it on the /// request, and fire the factory callback + `'response'` listeners. Body @@ -404,6 +499,7 @@ pub(crate) unsafe fn handle_response_head_event( status: u16, status_message: String, headers: Vec<(String, String)>, + http_version: (u8, u8), ) { // A destroyed request delivers nothing. let destroyed = @@ -428,6 +524,10 @@ pub(crate) unsafe fn handle_response_head_event( pipes: Vec::new(), socket_handle, request_handle, + http_version, + // The body streams in later (`ResponseChunk`/`ResponseEnd`); Node + // keeps `res.complete` false until the end edge. + complete: false, }); let (response_callback, response_listeners) = with_handle_mut::(request_handle, |request| { @@ -537,6 +637,11 @@ pub(crate) unsafe fn handle_response_end_event(request_handle: Handle) { return; } client_abort::cleanup_request_signal(request_handle); + // #10467 — the body has now been fully received; flip `res.complete` + // before the `'end'` listeners below observe it. + if let Some(im) = get_handle_mut::(incoming) { + im.complete = true; + } let (data_listeners, encoding, buffered, pipes) = get_handle_mut::(incoming) diff --git a/crates/perry-ext-http/src/client_surface.rs b/crates/perry-ext-http/src/client_surface.rs index 6058e89641..5dd4631863 100644 --- a/crates/perry-ext-http/src/client_surface.rs +++ b/crates/perry-ext-http/src/client_surface.rs @@ -224,6 +224,93 @@ pub extern "C" fn js_http_incoming_message_socket(handle: Handle) -> f64 { .unwrap_or_else(|| f64::from_bits(TAG_UNDEFINED)) } +/// `res.rawHeaders` (#10467) — see `build_raw_headers_array` for the +/// header-casing caveat on the pooled reqwest path. +#[no_mangle] +pub extern "C" fn js_http_response_raw_headers(handle: Handle) -> f64 { + let mut out = f64::from_bits(TAG_UNDEFINED); + with_handle_mut::(handle, |res| { + out = crate::response_headers::build_raw_headers_array(&res.headers); + }); + if out.to_bits() == TAG_UNDEFINED { + if let Some(server_out) = server_incoming_property(handle, "rawHeaders") { + return server_out; + } + } + out +} + +/// `res.httpVersion` — `"{major}.{minor}"` (#10467). The codegen native +/// table routes both client responses and server `IncomingMessage`s +/// through this entry (shared `class_filter`), so a registry miss here +/// falls back to the server accessor rather than defaulting blindly — +/// otherwise every server-side `req.httpVersion` would read back "1.1" +/// regardless of the real negotiated version. +#[no_mangle] +pub extern "C" fn js_http_response_http_version(handle: Handle) -> *mut StringHeader { + let mut out: Option = None; + with_handle_mut::(handle, |res| { + out = Some(format!("{}.{}", res.http_version.0, res.http_version.1)); + }); + if let Some(s) = out { + return alloc_string(&s).as_raw(); + } + if let Some(server_out) = server_incoming_property(handle, "httpVersion") { + let bits = server_out.to_bits(); + if bits >> 48 == 0x7FFF || bits >> 48 == 0x7FFD { + return (bits & PTR_MASK) as *mut StringHeader; + } + } + alloc_string("1.1").as_raw() +} + +/// `res.httpVersionMajor` (#10467). Same shared-`class_filter` fallback as +/// `js_http_response_http_version` — a server `req.httpVersionMajor` must +/// still resolve through the server accessor. +#[no_mangle] +pub extern "C" fn js_http_response_http_version_major(handle: Handle) -> f64 { + if let Some(v) = + with_handle_mut::(handle, |res| res.http_version.0 as f64) + { + return v; + } + server_incoming_property(handle, "httpVersionMajor").unwrap_or(1.0) +} + +/// `res.httpVersionMinor` (#10467). Same shared-`class_filter` fallback as +/// `js_http_response_http_version`. +#[no_mangle] +pub extern "C" fn js_http_response_http_version_minor(handle: Handle) -> f64 { + if let Some(v) = + with_handle_mut::(handle, |res| res.http_version.1 as f64) + { + return v; + } + server_incoming_property(handle, "httpVersionMinor").unwrap_or(1.0) +} + +/// `res.complete` (#10467) — `true` once the body has been fully received +/// (Node's aborted-download check). Same shared-`class_filter` fallback as +/// `js_http_response_http_version` — a server `req.complete` must still +/// resolve through the server accessor (`js_node_http_im_complete`, via +/// the dynamic dispatcher, which already returns a boxed JS boolean here). +#[no_mangle] +pub extern "C" fn js_http_response_complete(handle: Handle) -> f64 { + if let Some(v) = with_handle_mut::(handle, |res| { + if res.complete { + TAG_TRUE + } else { + TAG_FALSE + } + }) { + return f64::from_bits(v); + } + if let Some(server_out) = server_incoming_property(handle, "complete") { + return server_out; + } + f64::from_bits(TAG_UNDEFINED) +} + /// `res.req` — the ClientRequest paired with a client IncomingMessage. #[no_mangle] pub extern "C" fn js_http_incoming_message_req(handle: Handle) -> f64 { diff --git a/crates/perry-ext-http/src/client_upgrade.rs b/crates/perry-ext-http/src/client_upgrade.rs new file mode 100644 index 0000000000..f97975a492 --- /dev/null +++ b/crates/perry-ext-http/src/client_upgrade.rs @@ -0,0 +1,183 @@ +//! #10468 — client-side protocol upgrade (`Connection: Upgrade`). A `101 +//! Switching Protocols` response hands the caller the raw socket through +//! `req.on('upgrade', (res, socket, head) => ...)` instead of an ordinary +//! `'response'`. reqwest consumes the connection as a normal response body +//! and never exposes it, so an upgrade request speaks HTTP/1.1 over a raw +//! `TcpStream` instead — the same shape as the trailer-aware bypass in +//! `plain_client.rs` — and, on a `101`, adopts the stream into +//! `perry_ext_net` as a `net.Socket` (mirrors the server's +//! `server/raw_upgrade.rs`). +//! +//! Scope: plain `http://` only — TLS upgrade needs a different transport +//! and falls through to the normal path (pre-#10468 behavior: no upgrade), +//! same as when this module isn't triggered at all (no `Connection: +//! Upgrade`, or an Agent/`createConnection` override already claimed the +//! connection before `dispatch_request` runs). + +use std::collections::HashMap; + +use perry_ffi::Handle; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::{push_event, PendingHttpEvent}; + +/// `true` if `headers` asks for a protocol upgrade — `Connection: Upgrade` +/// as one token of a comma list (RFC 7230 §6.1; Node/undici send it as a +/// bare `Upgrade` value in practice). +pub(crate) fn wants_upgrade(headers: &HashMap) -> bool { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("connection") + && value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case("upgrade")) + }) +} + +/// Speak the request over a raw `TcpStream` when it wants a protocol +/// upgrade. `None` means "not applicable" (not an upgrade request, or +/// `https://` — fall through to the normal reqwest path); `Some(Ok(()))` +/// once the exchange has been fully handed off to a `PendingHttpEvent` +/// (`Upgrade` on `101`, `Response` otherwise); `Some(Err(_))` on a +/// transport failure. Mirrors `plain_client::dispatch_plain_http_request`'s +/// bypass contract. +pub(crate) async fn dispatch_upgrade_http_request( + request_handle: Handle, + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + timeout_ms: Option, +) -> Option> { + if !wants_upgrade(headers) { + return None; + } + let parsed = match reqwest::Url::parse(url) { + Ok(u) if u.scheme() == "http" => u, + // https:// upgrade isn't implemented — let the caller fall through + // rather than mishandle it here (matches pre-#10468 behavior for TLS). + _ => return None, + }; + let host = match parsed.host_str() { + Some(h) => h.to_string(), + None => return Some(Err("missing host".to_string())), + }; + let port = parsed.port_or_known_default().unwrap_or(80); + let mut path = parsed.path().to_string(); + if path.is_empty() { + path.push('/'); + } + if let Some(q) = parsed.query() { + path.push('?'); + path.push_str(q); + } + + let deadline = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)); + let fut = async { + let mut stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; + let host_header = if parsed.port().is_some() { + format!("{}:{}", host, port) + } else { + host.clone() + }; + let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); + let mut has_content_length = false; + for (k, v) in headers { + if k.eq_ignore_ascii_case("content-length") { + has_content_length = true; + } + req.push_str(k); + req.push_str(": "); + req.push_str(v); + req.push_str("\r\n"); + } + if !body.is_empty() && !has_content_length { + req.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + req.push_str("\r\n"); + stream.write_all(req.as_bytes()).await?; + if !body.is_empty() { + stream.write_all(body).await?; + } + + // Read only up to the end of the header block — a `101` keeps the + // connection open for the upgraded protocol, so (unlike + // `plain_client`'s trailer-aware bypass) this must not read to EOF. + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + Ok::<_, std::io::Error>((stream, buf)) + }; + + let (stream, buf) = match tokio::time::timeout(deadline, fut).await { + Ok(Ok(v)) => v, + Ok(Err(e)) => return Some(Err(e.to_string())), + Err(_) => return Some(Err("request timed out".to_string())), + }; + + let Some(header_end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { + return Some(Err( + "invalid HTTP response (no header terminator)".to_string() + )); + }; + let head_text = String::from_utf8_lossy(&buf[..header_end]); + let mut lines = head_text.split("\r\n"); + let status_line = lines.next().unwrap_or_default(); + let mut parts = status_line.splitn(3, ' '); + let http_version = parts + .next() + .and_then(|v| v.strip_prefix("HTTP/")) + .and_then(|v| v.split_once('.')) + .and_then(|(maj, min)| Some((maj.parse::().ok()?, min.parse::().ok()?))) + .unwrap_or((1, 1)); + let status: u16 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let status_message = parts.next().unwrap_or("").to_string(); + let mut hdrs = Vec::new(); + for line in lines { + if let Some((name, value)) = line.split_once(':') { + hdrs.push((name.trim().to_ascii_lowercase(), value.trim().to_string())); + } + } + let rest = buf[header_end + 4..].to_vec(); + + if status == 101 { + let socket_id = perry_ext_net::adopt_upgraded_tcp_stream(stream); + push_event(PendingHttpEvent::Upgrade { + request_handle, + status, + status_message, + headers: hdrs, + socket_handle: socket_id, + head: rest, + }); + return Some(Ok(())); + } + + // Server declined the upgrade — deliver an ordinary `'response'`. Read + // the remainder to EOF like the trailer-aware bypass (a non-101 reply + // to an Upgrade request has no further framing guarantee here). + let mut stream = stream; + let mut full = rest; + let mut chunk = [0u8; 16 * 1024]; + loop { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => full.extend_from_slice(&chunk[..n]), + } + } + push_event(PendingHttpEvent::Response { + request_handle, + status, + status_message, + headers: hdrs, + trailers: Vec::new(), + body: full, + http_version, + }); + Some(Ok(())) +} diff --git a/crates/perry-ext-http/src/continue_client.rs b/crates/perry-ext-http/src/continue_client.rs index b0822a088e..baed123cdf 100644 --- a/crates/perry-ext-http/src/continue_client.rs +++ b/crates/perry-ext-http/src/continue_client.rs @@ -273,6 +273,7 @@ async fn run_exchange( status: parsed.status, status_message: parsed.status_message, headers: parsed.headers, + http_version: parsed.http_version, }); if !parsed.body.is_empty() { push_event(PendingHttpEvent::ResponseChunk { diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index 758a572f87..227f19825d 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -58,6 +58,8 @@ mod tls_client; // Raw-socket trailer-aware HTTP/1.1 client (`TE: trailers` bypass) + // response parser, extracted to keep `lib.rs` under the 2000-line lint cap. +mod client_connect_override; +mod client_upgrade; mod plain_client; use plain_client::{dispatch_plain_http_request, parse_http_response}; @@ -152,6 +154,7 @@ pub(crate) enum PendingHttpEvent { headers: Vec<(String, String)>, trailers: Vec<(String, String)>, body: Vec, + http_version: (u8, u8), }, /// Streaming delivery (reqwest path): the response head arrived — fire /// the `http.request` callback / `'response'` listeners now; body @@ -163,6 +166,7 @@ pub(crate) enum PendingHttpEvent { status: u16, status_message: String, headers: Vec<(String, String)>, + http_version: (u8, u8), }, /// One streamed body chunk following a `ResponseHead`. Carried as a /// refcounted `Bytes` (reqwest hands `chunk()` out this way) so the @@ -175,6 +179,15 @@ pub(crate) enum PendingHttpEvent { /// The streamed body finished — `'end'` on the message, `'close'` on /// the request. ResponseEnd { request_handle: Handle }, + /// #10468 — a `101` fires `'upgrade'` instead of `'response'` (`client_upgrade.rs`). + Upgrade { + request_handle: Handle, + status: u16, + status_message: String, + headers: Vec<(String, String)>, + socket_handle: Handle, + head: Vec, + }, Error { request_handle: Handle, error_message: String, @@ -494,6 +507,8 @@ pub struct ClientRequestHandle { /// options object. HTTPS TLS identity fields are lost if this is /// reconstructed from the URL at release time. agent_key: String, + /// `options.createConnection` (#10469, only when `agent_handle == 0`). + request_create_connection: i64, /// Agent pool bookkeeping. Exactly one of these is true after `end()` /// admits the request; terminal events clear `agent_active`, while a /// maxSockets waiter stays queued until the active request releases it. @@ -576,6 +591,10 @@ pub struct IncomingMessageHandle { /// ClientRequest that produced this response (`res.req`). Server-side /// IncomingMessages live in a separate registry and never populate this. pub request_handle: Handle, + /// `res.httpVersion*` (#10467); `(major, minor)`, default `(1, 1)`. + pub http_version: (u8, u8), + /// `res.complete` (#10467) — set once the body is fully received. + pub complete: bool, } unsafe impl Send for IncomingMessageHandle {} @@ -695,6 +714,7 @@ fn make_request_handle( callback: i64, agent_handle: Handle, agent_key: String, + request_create_connection: i64, ) -> Handle { let async_id = unsafe { js_async_hooks_provider_init(b"HTTPCLIENTREQUEST".as_ptr(), b"HTTPCLIENTREQUEST".len()) @@ -718,6 +738,7 @@ fn make_request_handle( close_emitted: false, agent_handle, agent_key, + request_create_connection, agent_active: false, agent_queued: false, reused_socket: false, @@ -795,6 +816,7 @@ fn pending_request_handle(event: &PendingHttpEvent) -> Handle { match event { PendingHttpEvent::Socket { request_handle } | PendingHttpEvent::SignalAbort { request_handle } + | PendingHttpEvent::Upgrade { request_handle, .. } | PendingHttpEvent::Response { request_handle, .. } | PendingHttpEvent::ResponseHead { request_handle, .. } | PendingHttpEvent::ResponseChunk { request_handle, .. } @@ -814,6 +836,7 @@ fn terminal_http_event(event: &PendingHttpEvent) -> bool { matches!( event, PendingHttpEvent::SignalAbort { .. } + | PendingHttpEvent::Upgrade { .. } | PendingHttpEvent::Response { .. } | PendingHttpEvent::ResponseEnd { .. } | PendingHttpEvent::Error { .. } @@ -926,166 +949,6 @@ fn tls_servername_from_host_header(value: &str) -> Option { } } -/// Serialize an HTTP/1.1 request (request line + headers + body) into the -/// bytes to write onto a socket. Forces `Connection: close` (the raw socket -/// path reads until EOF), drops any caller-supplied `Connection`/`Host` -/// header (we set `Host` from the URL), and adds `Content-Length` when a -/// body is present and the caller didn't. -fn serialize_http_request( - method: &str, - path: &str, - host_header: &str, - headers: &HashMap, - body: &[u8], -) -> Vec { - let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); - let mut has_content_length = false; - for (k, v) in headers { - if k.eq_ignore_ascii_case("content-length") { - has_content_length = true; - } - if k.eq_ignore_ascii_case("connection") || k.eq_ignore_ascii_case("host") { - continue; - } - req.push_str(k); - req.push_str(": "); - req.push_str(v); - req.push_str("\r\n"); - } - req.push_str("Connection: close\r\n"); - if !body.is_empty() && !has_content_length { - req.push_str(&format!("Content-Length: {}\r\n", body.len())); - } - req.push_str("\r\n"); - let mut out = req.into_bytes(); - out.extend_from_slice(body); - out -} - -/// #2154 — run an HTTP exchange over a socket that the agent's -/// `createConnection` override produced (`socket_id`), instead of through -/// reqwest. Writes the serialized request, reads the response until the peer -/// closes (we force `Connection: close`), parses it with -/// [`parse_http_response`], and pushes the same `Response` / `Error` event -/// the reqwest path produces — so the IncomingMessage surface is identical. -/// -/// The socket I/O goes through perry-ffi's raw-net vtable (published by -/// perry-ext-net), so this crate needs no link edge to perry-ext-net. If no -/// net backend is linked the request errors out (the override couldn't have -/// produced a socket without `net`, so this is a defensive guard). -fn dispatch_request_over_socket( - request_handle: Handle, - method: String, - url: String, - headers: HashMap, - body: Vec, - timeout_ms: Option, - socket_id: i64, -) { - let parsed = match reqwest::Url::parse(&url) { - Ok(u) => u, - Err(e) => { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: e.to_string(), - }); - return; - } - }; - let host = parsed.host_str().unwrap_or("localhost").to_string(); - let host_header = match parsed.port() { - Some(p) => format!("{}:{}", host, p), - None => host, - }; - let mut path = parsed.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(q) = parsed.query() { - path.push('?'); - path.push_str(q); - } - let req_bytes = serialize_http_request(&method, &path, &host_header, &headers, &body); - let deadline = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)); - - spawn_blocking(move || { - let try_h = tokio::runtime::Handle::try_current(); - std::hint::black_box(&try_h); - if try_h.is_err() { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "http client runtime unavailable".to_string(), - }); - return; - } - let handle = tokio::runtime::Handle::current(); - // #5779 follow-up: keep this fetch counted in-flight for its whole - // lifetime so the idle-kick recovers a lost worker-unpark. - let inflight_guard = ClientInflightGuard::new(request_handle); - let jh = handle.spawn(async move { - let _inflight = inflight_guard; - let vtable = match perry_ffi::raw_net() { - Some(v) => v, - None => { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "agent.createConnection requires node:net (not linked)" - .to_string(), - }); - return; - } - }; - // Attach is idempotent — the request path also attaches on the - // main thread before this task runs, to close any data race. - (vtable.attach)(socket_id); - if (vtable.write)(socket_id, req_bytes.as_ptr(), req_bytes.len()) == 0 { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "failed to write request to agent socket".to_string(), - }); - return; - } - - let mut raw = Vec::new(); - let mut chunk = [0u8; 16 * 1024]; - let start = tokio::time::Instant::now(); - loop { - let n = (vtable.poll_read)(socket_id, chunk.as_mut_ptr(), chunk.len()); - if n > 0 { - raw.extend_from_slice(&chunk[..n as usize]); - } else if n == 0 { - break; // clean EOF — peer closed after the response - } else { - if start.elapsed() >= deadline { - (vtable.close)(socket_id); - push_event(PendingHttpEvent::Timeout { request_handle }); - return; - } - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } - } - (vtable.close)(socket_id); - - match parse_http_response(&raw) { - Ok(parsed) => push_event(PendingHttpEvent::Response { - request_handle, - status: parsed.status, - status_message: parsed.status_message, - headers: parsed.headers, - trailers: parsed.trailers, - body: parsed.body, - }), - Err(error_message) => push_event(PendingHttpEvent::Error { - request_handle, - error_message, - }), - } - }); - std::hint::black_box(&jh); - std::mem::forget(jh); - }); -} - /// #2154 — invoke a user `createSocket(req, options, cb)` override on the /// request path (Node's `Agent.prototype.addRequest` semantics). Builds the /// three arguments Node passes: @@ -1136,7 +999,12 @@ unsafe fn invoke_create_socket( let cb = (cb_val.get().to_bits() & PTR_MASK) as *mut perry_ffi::ClosureHeader; perry_ffi::set_closure_capture_f64(cb, 0, request_handle as f64); let req_val = f64::from_bits(POINTER_TAG | (request_handle as u64 & PTR_MASK)); - let options = scope.root_nanbox(agent::build_connect_options(agent_handle, host, port, path)); + let options = scope.root_nanbox(agent::build_connect_options( + Some(agent_handle), + host, + port, + path, + )); let closure = JsClosure::from_raw(cs.get() as *const RawClosureHeader); closure.call3(req_val, options.get(), cb_val.get()); @@ -1210,7 +1078,7 @@ unsafe extern "C" fn http_create_socket_cb( if let Some(vt) = perry_ffi::raw_net() { (vt.attach)(socket_id); } - dispatch_request_over_socket( + client_connect_override::dispatch_request_over_socket( request_handle, method, url, @@ -1262,6 +1130,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> agent_handle }; let agent_key = agent::request_key_from_options(agent_handle, arg_f64, &url); + let request_create_connection = agent::request_create_connection_from_options(arg_f64); // #10469 let handle = make_request_handle( method, url, @@ -1270,6 +1139,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> callback, agent_handle, agent_key, + request_create_connection, ); client_abort::attach_request_signal(handle, arg_f64); attach_tls_options(handle, arg_f64); // #4906 @@ -1327,6 +1197,7 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han agent_handle }; let agent_key = agent::request_key_from_options(agent_handle, arg_f64, &url); + let request_create_connection = agent::request_create_connection_from_options(arg_f64); // #10469 let handle = make_request_handle( "GET".to_string(), url, @@ -1335,6 +1206,7 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han callback, agent_handle, agent_key, + request_create_connection, ); client_abort::attach_request_signal(handle, arg_f64); attach_tls_options(handle, arg_f64); // #4906 @@ -1388,6 +1260,7 @@ unsafe fn request_overload(args_array: i64, default_protocol: &str, force_get: b agent_handle }; let agent_key = agent::request_key_from_options(agent_handle, parsed.opts, &url); + let request_create_connection = agent::request_create_connection_from_options(parsed.opts); // #10469 let handle = make_request_handle( method, url, @@ -1396,6 +1269,7 @@ unsafe fn request_overload(args_array: i64, default_protocol: &str, force_get: b parsed.callback, agent_handle, agent_key, + request_create_connection, ); client_abort::attach_request_signal(handle, parsed.opts); attach_tls_options(handle, parsed.opts); // #4906 — TLS options ride on the options bag @@ -1706,7 +1580,7 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { if let Some(vt) = perry_ffi::raw_net() { (vt.attach)(socket_id); } - dispatch_request_over_socket( + client_connect_override::dispatch_request_over_socket( handle, method, url, headers, body, timeout_ms, socket_id, ); return; @@ -1714,6 +1588,16 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { } } + // #10469 — request-level `createConnection` (no explicit Agent). + if agent_handle == 0 { + if let Some(socket_id) = client_connect_override::dispatch_for_handle(handle, &url) { + client_connect_override::dispatch_request_over_socket( + handle, method, url, headers, body, timeout_ms, socket_id, + ); + return; + } + } + dispatch_request( handle, method, diff --git a/crates/perry-ext-http/src/pending_dispatch.rs b/crates/perry-ext-http/src/pending_dispatch.rs index c2a1bb24cd..27b50380ca 100644 --- a/crates/perry-ext-http/src/pending_dispatch.rs +++ b/crates/perry-ext-http/src/pending_dispatch.rs @@ -48,6 +48,7 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { headers, trailers, body, + http_version, } => client_events::handle_response_event( request_handle, status, @@ -55,17 +56,35 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { headers, trailers, body, + http_version, ), PendingHttpEvent::ResponseHead { request_handle, status, status_message, headers, + http_version, } => client_events::handle_response_head_event( request_handle, status, status_message, headers, + http_version, + ), + PendingHttpEvent::Upgrade { + request_handle, + status, + status_message, + headers, + socket_handle, + head, + } => client_events::handle_upgrade_event( + request_handle, + status, + status_message, + headers, + socket_handle, + head, ), PendingHttpEvent::ResponseChunk { request_handle, diff --git a/crates/perry-ext-http/src/plain_client.rs b/crates/perry-ext-http/src/plain_client.rs index 2f0d3cd304..d1b2939ddf 100644 --- a/crates/perry-ext-http/src/plain_client.rs +++ b/crates/perry-ext-http/src/plain_client.rs @@ -112,6 +112,7 @@ pub(crate) async fn dispatch_plain_http_request( headers: parsed.headers, trailers: parsed.trailers, body: parsed.body, + http_version: parsed.http_version, }); Some(Ok(())) } @@ -127,6 +128,10 @@ pub(crate) struct ParsedHttpResponse { pub(crate) headers: Vec<(String, String)>, pub(crate) trailers: Vec<(String, String)>, pub(crate) body: Vec, + /// `(major, minor)` parsed from the status line (`HTTP/1.1 200 OK`). + /// Falls back to `(1, 1)` on anything that doesn't parse as + /// `HTTP/.` (#10467). + pub(crate) http_version: (u8, u8), } /// Parse a raw HTTP/1.1 response (the bytes read off a socket) into status / @@ -144,7 +149,12 @@ pub(crate) fn parse_http_response(raw: &[u8]) -> Result().ok()?, min.parse::().ok()?))) + .unwrap_or((1, 1)); let status = status_parts .next() .and_then(|s| s.parse::().ok()) @@ -215,5 +225,6 @@ pub(crate) fn parse_http_response(raw: &[u8]) -> Result bool { ) } +/// Build `res.rawHeaders` (#10467) — the flattened `[name, value, name, +/// value, ...]` array in wire arrival order, duplicates preserved (unlike +/// the combined `headers` view above, which merges/collapses per +/// `matchKnownFields`). +/// +/// Caveat: header name casing here is whatever the transport captured. The +/// pooled reqwest path normalizes names to lower case before Perry ever +/// sees them (`http::HeaderName` only stores lower case), so this does not +/// reproduce Node's original wire casing on that path — only the raw-socket +/// paths (`plain_client`/`agent.createConnection`) could preserve it, and +/// today they lower-case on parse too. Tracked as a known gap, not silently +/// papered over. +pub(crate) fn build_raw_headers_array(raw: &[(String, String)]) -> f64 { + let arr = unsafe { perry_ffi::js_array_alloc((raw.len() * 2) as u32) }; + if arr.is_null() { + return f64::from_bits(TAG_UNDEFINED); + } + // #10668-followup: `arr` is a raw heap pointer. `alloc_string` below can + // allocate (and therefore collect), which can move the array this + // pointer refers to before the next `js_array_push` reads it back. Root + // it through a `TransientRootScope` and re-derive the pointer via + // `.get()` after every allocating call instead of reusing the pre-call + // copy (see `docs/src/internals/gc-rooting-invariant.md`). Each pointer + // is materialized on its own line and consumed immediately by the + // `js_array_push` call on the very next line -- keep it that shape + // (not folded into a multi-line call) so no raw pointer is ever bound + // across the loop's next `alloc_string`. + let scope = TransientRootScope::enter(); + let mut arr = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(arr).bits())); + for (name, value) in raw { + let name_s = alloc_string(name); + let arr_ptr = JsValue::from_bits(arr.get().to_bits()).as_pointer(); + let name_value = JsValue::from_string_ptr(name_s.as_raw()); + let pushed = unsafe { js_array_push(arr_ptr, name_value) }; + arr = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(pushed).bits())); + + let value_s = alloc_string(value); + let arr_ptr = JsValue::from_bits(arr.get().to_bits()).as_pointer(); + let value_value = JsValue::from_string_ptr(value_s.as_raw()); + let pushed = unsafe { js_array_push(arr_ptr, value_value) }; + arr = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(pushed).bits())); + } + arr.get() +} + +/// Build the `set-cookie` array for [`build_response_headers_object`] -- +/// always a string array, even for a single cookie (Node's +/// `matchKnownFields` never collapses `set-cookie`). Same GC-rooting shape +/// as [`build_raw_headers_array`]: `alloc_string` can collect and move +/// `arr` before `js_array_push` reads it back, so root and re-derive the +/// pointer at each use instead of reusing the pre-call copy. Kept as its +/// own top-level function (rather than nested inside the caller's +/// `if key == "set-cookie"` arm) so these lines stay short enough that +/// rustfmt doesn't wrap a `let` binding across lines and defeat the +/// ratchet scanner's line-oriented binding detection (#10668-followup). +fn build_set_cookie_array(set_cookie: &[String]) -> f64 { + let arr = unsafe { js_array_alloc(set_cookie.len() as u32) }; + let scope = TransientRootScope::enter(); + let mut arr = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(arr).bits())); + for cookie in set_cookie { + let cookie_s = alloc_string(cookie); + let arr_ptr = JsValue::from_bits(arr.get().to_bits()).as_pointer(); + let cookie_value = JsValue::from_string_ptr(cookie_s.as_raw()); + let pushed = unsafe { js_array_push(arr_ptr, cookie_value) }; + arr = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(pushed).bits())); + } + arr.get() +} + /// Build the combined `IncomingMessage.headers` object from the raw /// `(name, value)` pairs, applying Node's `matchKnownFields` rules /// (#5079): @@ -95,13 +166,7 @@ pub(crate) fn build_response_headers_object(raw: &[(String, String)]) -> f64 { if !obj.is_null() { for (i, key) in order.iter().enumerate() { let v = if key == "set-cookie" { - let mut arr = unsafe { js_array_alloc(set_cookie.len() as u32) }; - for cookie in &set_cookie { - arr = unsafe { - js_array_push(arr, JsValue::from_string_ptr(alloc_string(cookie).as_raw())) - }; - } - JsValue::from_object_ptr(arr) + JsValue::from_bits(build_set_cookie_array(&set_cookie).to_bits()) } else if let Some(val) = combined.get(key) { let s = alloc_string(val); JsValue::from_string_ptr(s.as_raw()) diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index dc7625ebff..00b4691138 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -99,6 +99,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { close_emitted: false, agent_handle: 0, agent_key: "localhost::".to_string(), + request_create_connection: 0, agent_active: false, agent_queued: false, reused_socket: false, @@ -126,6 +127,8 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { pipes: Vec::new(), socket_handle: 0, request_handle, + http_version: (1, 1), + complete: true, }); let _ = perry_runtime::gc::gc_collect_minor(); @@ -187,6 +190,7 @@ fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { close_emitted: false, agent_handle: 0, agent_key: "localhost::".to_string(), + request_create_connection: 0, agent_active: false, agent_queued: false, reused_socket: false, @@ -208,6 +212,7 @@ fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { 200, "OK".to_string(), Vec::new(), + (1, 1), ); // Each production chunk is a refcounted `Bytes` (reqwest's // `response.chunk()` shape) — build the input the same way so the @@ -359,6 +364,7 @@ fn dispatch_request_stays_visible_to_exit_gate_until_response_queued() { close_emitted: false, agent_handle: 0, agent_key: "localhost::".to_string(), + request_create_connection: 0, agent_active: false, agent_queued: false, reused_socket: false, diff --git a/crates/perry-ext-net/src/adopt.rs b/crates/perry-ext-net/src/adopt.rs index a100e75136..64be09a5e2 100644 --- a/crates/perry-ext-net/src/adopt.rs +++ b/crates/perry-ext-net/src/adopt.rs @@ -62,6 +62,10 @@ pub fn adopt_upgraded_tcp_stream(stream: tokio::net::TcpStream) -> i64 { remote_addr: remote, raw: None, destroyed: false, + connecting: false, + has_opened: true, + writable_ended: false, + readable_ended: false, bytes_read: 0, bytes_written: 0, bytes_queued: 0, diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs index dc63586ca4..a4aa357dba 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -72,7 +72,7 @@ pub(crate) fn ensure_runtime_dispatch_registered() { }); } -fn undefined() -> f64 { +pub(crate) fn undefined() -> f64 { f64::from_bits(TAG_UNDEFINED) } @@ -80,7 +80,7 @@ fn null() -> f64 { f64::from_bits(TAG_NULL) } -fn nanbox_handle(handle: i64) -> f64 { +pub(crate) fn nanbox_handle(handle: i64) -> f64 { f64::from_bits(POINTER_TAG | (handle as u64 & POINTER_MASK)) } @@ -176,6 +176,16 @@ fn socket_method_name(prop: &str) -> Option<&'static [u8]> { "on" => Some(b"on"), "addListener" => Some(b"addListener"), "once" => Some(b"once"), + // #10441 — front-inserting variants of `on`/`once`. Missing here + // meant the untyped dispatch fell through to the generic property + // read for these names, which returned `undefined`: calling it was + // a silent no-op instead of a `TypeError`. + "prependListener" => Some(b"prependListener"), + "prependOnceListener" => Some(b"prependOnceListener"), + // #10444 — `net.Socket` is a `stream.Duplex`; `pipe`/`unpipe` were + // entirely absent from this table. + "pipe" => Some(b"pipe"), + "unpipe" => Some(b"unpipe"), "off" => Some(b"off"), "removeListener" => Some(b"removeListener"), "removeAllListeners" => Some(b"removeAllListeners"), @@ -275,6 +285,38 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option crate::js_net_socket_on(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } + // #10441 — same shape as `once` below, but inserted at the FRONT of + // the listener list. + "prependListener" if args.len() >= 2 => { + crate::js_net_socket_prepend_listener( + handle, + unbox_to_i64(args[0]), + unbox_to_i64(args[1]), + ); + nanbox_handle(handle) + } + "prependOnceListener" if args.len() >= 2 => { + crate::js_net_socket_prepend_once_listener( + handle, + unbox_to_i64(args[0]), + unbox_to_i64(args[1]), + ); + nanbox_handle(handle) + } + // #10444 — forward socket data to `dest` via the same generic + // Get("write")+call duck-typed dispatch the runtime already uses to + // resolve thenables (`crate::pipe`), so `dest` can be any Writable + // representation (another handle-backed socket, a node:stream + // object, …), not just one specific one. + "pipe" if !args.is_empty() => crate::pipe::socket_pipe( + handle, + args[0], + args.get(1).copied().unwrap_or_else(undefined), + ), + "unpipe" => { + crate::pipe::socket_unpipe(handle, args.first().copied().unwrap_or_else(undefined)); + nanbox_handle(handle) + } "connect" if !args.is_empty() => { let arg2 = args.get(1).copied().unwrap_or_else(undefined); let arg3 = args.get(2).copied().unwrap_or_else(undefined); @@ -559,6 +601,42 @@ pub unsafe extern "C" fn js_ext_net_handle_property_dispatch( Some(null()) } else if prop == "destroyed" && crate::js_ext_net_is_socket_handle(handle) != 0 { Some(crate::js_net_socket_get_destroyed(handle)) + } else if crate::js_ext_net_is_socket_handle(handle) != 0 + && matches!( + prop, + "writable" + | "readable" + | "readyState" + | "connecting" + | "pending" + | "writableEnded" + | "readableEnded" + ) + { + // #10465 — the untyped (`(sock: any)`/plain-JS-driver) dispatch path + // had NO arm at all for these; every driver holds its socket through + // an untyped field (`this.stream`), so this — not the typed-receiver + // table in `net_events.rs` — is the path pg/ioredis/iovalkey/ + // @redis/client actually hit. + Some(match prop { + "writable" => crate::js_net_socket_get_writable(handle), + "readable" => crate::js_net_socket_get_readable(handle), + "connecting" => crate::js_net_socket_get_connecting(handle), + "pending" => crate::js_net_socket_get_pending(handle), + "writableEnded" => crate::js_net_socket_get_writable_ended(handle), + "readableEnded" => crate::js_net_socket_get_readable_ended(handle), + _ => f64::from_bits( + JsValue::from_string_ptr(crate::js_net_socket_get_ready_state(handle)).bits(), + ), + }) + } else if prop == "_writableState" && crate::js_ext_net_is_socket_handle(handle) != 0 { + Some(json_str_to_value(crate::js_net_socket_get_writable_state( + handle, + ))) + } else if prop == "_readableState" && crate::js_ext_net_is_socket_handle(handle) != 0 { + Some(json_str_to_value(crate::js_net_socket_get_readable_state( + handle, + ))) } else if crate::js_ext_net_is_socket_handle(handle) != 0 && matches!( prop, diff --git a/crates/perry-ext-net/src/gc_roots.rs b/crates/perry-ext-net/src/gc_roots.rs index af2e4c248c..334dd00d95 100644 --- a/crates/perry-ext-net/src/gc_roots.rs +++ b/crates/perry-ext-net/src/gc_roots.rs @@ -69,4 +69,8 @@ pub(crate) fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) { // #8259 — the pump's in-flight dispatch frames (snapshotted callbacks + // parked payloads), which the table walks above cannot see. dispatch_custody::scan(visitor); + // #10444 — `pipe()`'s own closure-pointer bookkeeping (see the doc on + // `pipe::scan_roots` for why it needs its own visit despite the SAME + // pointers already being visited via `statics::listeners()` above). + crate::pipe::scan_roots(visitor); } diff --git a/crates/perry-ext-net/src/handle_exports.rs b/crates/perry-ext-net/src/handle_exports.rs index 4c8612da70..ae2fe569a1 100644 --- a/crates/perry-ext-net/src/handle_exports.rs +++ b/crates/perry-ext-net/src/handle_exports.rs @@ -54,9 +54,17 @@ pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) entry.entry(event).or_default().push(cb); } +/// #10442 — returns the socket handle so the TYPED `net.Socket` codegen +/// table (`net_events.rs`'s `on`/`addListener` rows, which call this +/// runtime symbol as their `ret: NR_HANDLE_ID` carrier) can chain +/// `sock.on(...).on(...)` instead of reading back `undefined`. The +/// underlying `js_net_socket_on` stays void — it is also the untyped +/// dynamic-dispatch path's registration call in `dispatch.rs`, which +/// already supplies its own handle return separately. #[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) { - js_net_socket_on(handle, event_ptr, cb) +pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) -> i64 { + js_net_socket_on(handle, event_ptr, cb); + handle } #[no_mangle] diff --git a/crates/perry-ext-net/src/ipc.rs b/crates/perry-ext-net/src/ipc.rs index 159b7230ba..1c2eee4cbf 100644 --- a/crates/perry-ext-net/src/ipc.rs +++ b/crates/perry-ext-net/src/ipc.rs @@ -44,6 +44,10 @@ fn allocate_socket() -> (i64, mpsc::UnboundedReceiver) { remote_addr: None, raw: None, destroyed: false, + connecting: true, + has_opened: false, + writable_ended: false, + readable_ended: false, bytes_read: 0, bytes_written: 0, bytes_queued: 0, @@ -116,6 +120,10 @@ pub(crate) fn register_accepted_transport( remote_addr, raw: None, destroyed: false, + connecting: false, + has_opened: true, + writable_ended: false, + readable_ended: false, bytes_read: 0, bytes_written: 0, bytes_queued: 0, @@ -150,9 +158,14 @@ pub(crate) fn connect_existing(handle: i64, path: String) { let mut sockets = statics::sockets().lock().unwrap(); match sockets .get_mut(&handle) - .and_then(|socket| socket.pending_rx.take()) + .and_then(|socket| socket.pending_rx.take().map(|rx| (socket, rx))) { - Some(rx) => rx, + Some((socket, rx)) => { + // #10465 — `socket.connect(path)` on a `new net.Socket()` + // starts connecting synchronously, same as the TCP path. + socket.connecting = true; + rx + } None => { push_event(PendingNetEvent::Error( handle, @@ -187,6 +200,8 @@ fn spawn_connect(id: i64, path: String, mut rx: mpsc::UnboundedReceiver i64 { remote_addr: None, raw: None, destroyed: false, + connecting: false, + has_opened: false, + writable_ended: false, + readable_ended: false, bytes_read: 0, bytes_written: 0, bytes_queued: 0, @@ -995,6 +1047,10 @@ pub unsafe extern "C" fn js_net_socket_method_connect( let connect_async_id = init_provider_with_trigger(b"TCPCONNECTWRAP", tcp_async_id); if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { socket.connect_async_id = connect_async_id; + // #10465 — `socket.connect(...)` on a `new net.Socket()` starts + // connecting synchronously from the caller's point of view, same as + // the eager `net.connect()` factory. + socket.connecting = true; } let local_server = server_state::begin_local_connect(&host, port); @@ -1020,6 +1076,8 @@ pub unsafe extern "C" fn js_net_socket_method_connect( let remote = tcp.peer_addr().ok(); if let Some(s) = statics::sockets().lock().unwrap().get_mut(&handle) { s.is_open = true; + s.has_opened = true; + s.connecting = false; s.local_addr = local; s.remote_addr = remote; } @@ -1084,6 +1142,10 @@ where remote_addr: None, raw: None, destroyed: false, + connecting: true, + has_opened: false, + writable_ended: false, + readable_ended: false, bytes_read: 0, bytes_written: 0, bytes_queued: 0, @@ -1145,6 +1207,8 @@ where if let Some(s) = statics::sockets().lock().unwrap().get_mut(&id) { s.is_open = true; + s.has_opened = true; + s.connecting = false; s.local_addr = local; s.raw_fd = raw_fd; s.remote_addr = remote; diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs index b7b1ed1d97..85aa85d6af 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -123,46 +123,168 @@ fn with_socket(handle: i64, default: T, f: impl FnOnce(&crate::SocketState) - /// `handle` must be a registered socket id (raw, NOT NaN-boxed). #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_pending(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, true, |s| !s.is_open && !s.destroyed)) + // #10465 — Node's real getter is `!this._handle`: once there is no live + // handle (never connected, still connecting, OR fully closed/destroyed) + // `pending` reads `true` again — it is NOT simply the complement of + // `destroyed`. A handle already reaped from the registry (see the + // `'close'` teardown in `socket_events.rs`, which removes the + // `SocketState` entry once the `'close'` event has fired) falls through + // to the `true` default below, which is what we want for that case too. + // + // Deliberately keyed on `has_opened`/`destroyed`, NOT `is_open`: + // `is_open` flips false via `server_state::mark_socket_closed`, called + // from the tokio task thread as soon as teardown STARTS (before the main + // thread has processed the `'end'`/`'close'` events that same teardown + // just queued), while `destroyed` only flips at `'close'`-processing + // time — the one point that actually agrees with Node's own timing (see + // the `Close` arm in `socket_events.rs`). Once a socket has opened at + // least once, "does it have a live handle" reduces to "has it been + // destroyed yet", not to the (earlier-flipping) `is_open` flag. + nanbox_bool(with_socket(handle, true, |s| { + if s.has_opened { + s.destroyed + } else { + true + } + })) } -/// `socket.connecting` — `true` only while a connection attempt is in flight. -/// Perry resolves connects synchronously inside the tokio task, so from the -/// JS side this is `false` before connect and `false` once open — matching -/// Node for the construct-then-inspect path this getter targets. +/// `socket.connecting` — `true` from `net.connect()`/`socket.connect()` +/// until the attempt resolves (open, error, or destroy). Backed by +/// `SocketState::connecting` (#10465); pre-fix this was hardcoded `false`, +/// so `readyState` could never report `"opening"` and any caller polling +/// `connecting` during the handshake window saw the wrong value. /// /// # Safety /// /// See [`js_net_socket_get_pending`]. #[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_connecting(_handle: i64) -> f64 { - nanbox_bool(false) +pub unsafe extern "C" fn js_net_socket_get_connecting(handle: i64) -> f64 { + nanbox_bool(with_socket(handle, false, |s| s.connecting)) } /// `socket.destroyed` — `true` once `.destroy()` ran or the peer closed. +/// Defaults to `true` for a handle with no live `SocketState` — the +/// `'close'` teardown removes the entry once its listeners have run, and by +/// then the socket is unambiguously destroyed (#10465; pre-fix this +/// defaulted `false`, so `destroyed` read `false` again after `'close'`). /// /// # Safety /// /// See [`js_net_socket_get_pending`]. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_destroyed(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, false, |s| s.destroyed)) + nanbox_bool(with_socket(handle, true, |s| s.destroyed)) +} + +/// `socket.writable` — `true` until `.end()`/`.destroy()` flips +/// `writable_ended`. Independent of connect state, matching Node (a fresh +/// `new net.Socket()` is `writable` before it has ever connected). #10465 — +/// pre-fix this property didn't exist at all (read `undefined`). +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable(handle: i64) -> f64 { + nanbox_bool(with_socket(handle, false, |s| { + !s.destroyed && !s.writable_ended + })) +} + +/// `socket.readable` — `true` until the peer's EOF has been observed (the +/// `'end'` event) or the socket is destroyed. #10465 companion to +/// [`js_net_socket_get_writable`]. +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable(handle: i64) -> f64 { + nanbox_bool(with_socket(handle, false, |s| { + !s.destroyed && !s.readable_ended + })) +} + +/// `socket.writableEnded` — `true` immediately once `.end()` is called +/// (before the FIN even flushes), matching Node's documented timing. #10465. +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_ended(handle: i64) -> f64 { + nanbox_bool(with_socket(handle, true, |s| s.writable_ended)) +} + +/// `socket.readableEnded` — `true` once the `'end'` event has fired. +/// #10465 companion to [`js_net_socket_get_writable_ended`]. +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable_ended(handle: i64) -> f64 { + nanbox_bool(with_socket(handle, true, |s| s.readable_ended)) +} + +/// `socket._writableState` / `socket._readableState` — Node internals expose +/// a full `WritableState`/`ReadableState` object; drivers that reach into it +/// (pg, ioredis, `@redis/client`) mostly just check `typeof … === "object"` +/// or a couple of scalar fields. #10465: this returns a minimal object +/// carrying the two fields the audited drivers actually read +/// (`ended`/`finished` mirror `writableEnded`, kept in sync with the same +/// `SocketState` bit) rather than a full internal-stream-state shape. +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_state(handle: i64) -> *mut StringHeader { + let ended = with_socket(handle, true, |s| s.writable_ended); + let json = format!("{{\"ended\":{ended},\"finished\":{ended}}}"); + alloc_string(&json).as_raw() +} + +/// See [`js_net_socket_get_writable_state`]. +/// +/// # Safety +/// +/// See [`js_net_socket_get_pending`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable_state(handle: i64) -> *mut StringHeader { + let ended = with_socket(handle, true, |s| s.readable_ended); + let json = format!("{{\"ended\":{ended}}}"); + alloc_string(&json).as_raw() } /// `socket.readyState` — one of `"opening" | "open" | "readOnly" | -/// "writeOnly" | "closed"`. Node reports `"open"` for a freshly constructed -/// socket and `"closed"` once destroyed. +/// "writeOnly" | "closed"`. Mirrors Node's real getter (`connecting` ? +/// `"opening"` : `readable && writable` ? `"open"` : `readable` ? +/// `"readOnly"` : `writable` ? `"writeOnly"` : `"closed"`) instead of the +/// pre-#10465 two-state `destroyed ? "closed" : "open"`, which could never +/// report `"opening"` (mid-connect) or `"readOnly"` (after `.end()`, before +/// the peer's FIN). /// /// # Safety /// /// See [`js_net_socket_get_pending`]. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_ready_state(handle: i64) -> *mut StringHeader { - let state = with_socket( - handle, - "open", - |s| if s.destroyed { "closed" } else { "open" }, - ); + let state = with_socket(handle, "closed", |s| { + if s.connecting { + "opening" + } else { + let writable = !s.destroyed && !s.writable_ended; + let readable = !s.destroyed && !s.readable_ended; + match (readable, writable) { + (true, true) => "open", + (true, false) => "readOnly", + (false, true) => "writeOnly", + (false, false) => "closed", + } + } + }); alloc_string(state).as_raw() } @@ -513,6 +635,9 @@ pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { } } } + // #10465 — `writableEnded` (and `writable`) flip as soon as `.end()` + // is CALLED, per Node's docs, not once the FIN actually flushes. + s.writable_ended = true; let _ = s.cmd_tx.send(crate::SocketCommand::End(0)); } } @@ -570,6 +695,8 @@ pub unsafe extern "C" fn js_ext_net_socket_end3( socket.bytes_queued = socket.bytes_queued.saturating_add(byte_len); } } + // #10465 — see the sibling note in `js_ext_net_socket_end`. + socket.writable_ended = true; if socket .cmd_tx .send(crate::SocketCommand::End(completion)) @@ -670,18 +797,31 @@ pub(crate) fn event_name_from_ptr(event_ptr: i64) -> Option { } fn register_listener_with_flag(handle: i64, event: String, cb: i64, once: bool) { + register_listener(handle, event, cb, once, false); +} + +/// #10441 — shared by `on`/`once`/`prependListener`/`prependOnceListener`. +/// `prepend` inserts at the FRONT of the listener vector instead of pushing +/// at the back, which is the only difference Node's `prependListener` has +/// from `addListener`/`on` (same once-flag bookkeeping, same pending-data +/// release for a first `'data'` listener). +fn register_listener(handle: i64, event: String, cb: i64, once: bool, prepend: bool) { if cb == 0 { return; } let releases_pending_data = event == "data"; { let mut listeners = statics::listeners().lock().unwrap(); - listeners + let vec = listeners .entry(handle) .or_default() .entry(event.clone()) - .or_default() - .push(cb); + .or_default(); + if prepend { + vec.insert(0, cb); + } else { + vec.push(cb); + } } if once { let mut flags = statics::once_flags().lock().unwrap(); @@ -885,6 +1025,50 @@ pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64 handle } +/// `socket.prependListener(event, cb)` — like `.on()`/`.addListener()` but +/// inserts at the FRONT of the listener list, so this callback fires before +/// any listener already registered for `event`. #10441: pre-fix, neither the +/// dynamic (untyped-receiver) dispatch nor the typed `net.Socket` codegen +/// table had an entry for this method at all — it silently read `undefined` +/// and calling it was a no-op (ioredis/iovalkey's RESP parser attach via +/// `stream.prependListener("data", …)` never saw a byte). +/// +/// # Safety +/// +/// Same as [`js_net_socket_once`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_prepend_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + crate::ensure_gc_scanner_registered(); + if let Some(event) = read_event(event_ptr) { + register_listener(handle, event, cb, false, true); + } + handle +} + +/// `socket.prependOnceListener(event, cb)` — the front-inserting, one-shot +/// combination of [`js_net_socket_prepend_listener`] and +/// [`js_net_socket_once`]. #10441. +/// +/// # Safety +/// +/// Same as [`js_net_socket_once`]. +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_prepend_once_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + crate::ensure_gc_scanner_registered(); + if let Some(event) = read_event(event_ptr) { + register_listener(handle, event, cb, true, true); + } + handle +} + /// `socket.removeListener(event, cb)` — remove the first matching cb. /// /// # Safety diff --git a/crates/perry-ext-net/src/pipe.rs b/crates/perry-ext-net/src/pipe.rs new file mode 100644 index 0000000000..13518d7010 --- /dev/null +++ b/crates/perry-ext-net/src/pipe.rs @@ -0,0 +1,328 @@ +//! #10444 — `net.Socket.prototype.pipe(dest[, options])` / `.unpipe(dest?)`. +//! +//! A `net.Socket` lives behind ext-net's own handle registry +//! (`statics::sockets()` / `statics::listeners()`), a completely different +//! representation from node:stream's own object+closure model +//! (`perry-runtime`'s `node_stream` module, which backs `Readable`/ +//! `Writable`/`Duplex`/`Transform`/`PassThrough`). Bridging those two +//! independent state machines so a socket could reuse node:stream's own +//! `pipe()` implementation (with its full backpressure/unpipe-on-error/ +//! `'pipe'`+`'unpipe'` event machinery) is a much larger undertaking than +//! this cluster fix covers. +//! +//! Instead this reuses the SAME generic `Get(dest, "write")` + call +//! duck-typed dispatch the runtime already relies on to resolve thenables +//! (`crate::promise::assimilate::assimilate_via_then_property` in +//! `perry-runtime`, which does `Get(value, "then")` then invokes it with +//! `this` bound to the thenable): fetch `dest.write` / `dest.end` by name +//! through `js_dynamic_object_get_property` and invoke whatever comes back +//! through `js_native_call_value` with `dest` as the implicit receiver. +//! That resolves correctly regardless of what representation `dest` is — +//! another handle-backed socket, a node:stream object, or a plain user +//! object that overrides `write` — the same way real Node duck-types its +//! destination. +//! +//! Scope: this forwards `'data'` to `dest.write(chunk)` and (unless +//! `{ end: false }`) calls `dest.end()` once the source's `'end'` fires, and +//! returns `dest` for chaining. It does NOT implement automatic +//! unpipe-on-error, backpressure-aware pause/resume of the source, or the +//! `'pipe'`/`'unpipe'` events on the destination that Node's real +//! `Readable.prototype.pipe` fires — those are follow-up work, not part of +//! the #10444 reproduction (a `PassThrough`/`Transform` destination reading +//! everything a socket writes). + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use perry_ffi::{ + alloc_closure, closure_capture_f64, register_closure_arity, set_closure_capture_f64, + GcRootVisitor, RawClosureHeader, +}; + +use crate::statics; + +const TAG_UNDEFINED_BITS: u64 = 0x7FFC_0000_0000_0001; +const TAG_NULL_BITS: u64 = 0x7FFC_0000_0000_0002; +const TAG_FALSE_BITS: u64 = 0x7FFC_0000_0000_0003; + +// `js_dynamic_object_get_property` / `js_implicit_this_set` / +// `js_native_call_value` aren't wrapped by perry-ffi (unlike the closure +// helpers above); declare them the same way `dispatch.rs` declares its own +// direct `perry-runtime` FFI symbols (`js_class_method_bind`, +// `js_promise_resolve`, …) — resolved at final link time, not a Rust-level +// crate dependency. +extern "C" { + fn js_dynamic_object_get_property( + obj_value: f64, + property_name_ptr: *const i8, + property_name_len: usize, + ) -> f64; + fn js_implicit_this_set(value: f64) -> f64; + fn js_native_call_value(func_value: f64, args_ptr: *const f64, args_len: usize) -> f64; +} + +fn is_nullish(v: f64) -> bool { + let bits = v.to_bits(); + bits == TAG_UNDEFINED_BITS || bits == TAG_NULL_BITS +} + +fn is_callable(v: f64) -> bool { + // Native closures / bound handle methods / class methods are all + // POINTER_TAG (0x7FFD) or the handle-method-bind shape; a non-callable + // `Get` result (missing property, a plain data field) is either + // undefined or some other tag entirely. This mirrors the coarse + // callable check `assimilate_via_then_property` uses before invoking a + // fetched `then` — good enough to avoid calling `undefined()` when + // `dest` has no `write` at all, without re-implementing full + // `IsCallable`. + !is_nullish(v) && (v.to_bits() >> 48) == 0x7FFD +} + +/// `Get(dest, "write")(chunk)` with `this` bound to `dest`. +fn generic_write(dest: f64, chunk: f64) { + unsafe { + let write_fn = js_dynamic_object_get_property(dest, c"write".as_ptr(), 5); + if !is_callable(write_fn) { + return; + } + let prev = js_implicit_this_set(dest); + let args = [chunk]; + let _ = js_native_call_value(write_fn, args.as_ptr(), args.len()); + js_implicit_this_set(prev); + } +} + +/// `Get(dest, "end")()` with `this` bound to `dest`. +fn generic_end(dest: f64) { + unsafe { + let end_fn = js_dynamic_object_get_property(dest, c"end".as_ptr(), 3); + if !is_callable(end_fn) { + return; + } + let prev = js_implicit_this_set(dest); + let _ = js_native_call_value(end_fn, std::ptr::null(), 0); + js_implicit_this_set(prev); + } +} + +extern "C" fn pipe_data_forward(closure: *const RawClosureHeader, chunk: f64) -> f64 { + if !closure.is_null() { + let dest = unsafe { closure_capture_f64(closure, 0) }; + generic_write(dest, chunk); + } + f64::from_bits(TAG_UNDEFINED_BITS) +} + +extern "C" fn pipe_end_forward(closure: *const RawClosureHeader) -> f64 { + if !closure.is_null() { + let dest = unsafe { closure_capture_f64(closure, 0) }; + let end_on_finish = unsafe { closure_capture_f64(closure, 1) }; + if end_on_finish.to_bits() != TAG_FALSE_BITS { + generic_end(dest); + } + } + f64::from_bits(TAG_UNDEFINED_BITS) +} + +static ARITY_REGISTERED: std::sync::Once = std::sync::Once::new(); + +fn ensure_pipe_closure_arities_registered() { + ARITY_REGISTERED.call_once(|| { + register_closure_arity(pipe_data_forward as *const u8, 1); + register_closure_arity(pipe_end_forward as *const u8, 0); + }); +} + +/// One socket -> destination pipe route, tracked so `unpipe` can remove +/// exactly the listener closures a matching `pipe()` call installed. +/// +/// Deliberately does NOT cache `dest`'s bits here: `dest` is a NaN-boxed +/// value that can be a heap pointer, and a second, un-rooted copy of it +/// would go stale the moment a GC cycle moves the object — the closure's +/// OWN capture slot 0 (scanned automatically once `data_cb` is reachable +/// via `statics::listeners()`, see `install_pipe_listeners`) is the only +/// copy this module keeps, and `matches_dest` below reads it back live at +/// comparison time instead of trusting a cache the collector cannot see. +struct PipeRoute { + data_cb: i64, + end_cb: i64, +} + +impl PipeRoute { + fn matches_dest(&self, dest_bits: u64) -> bool { + let live_dest = unsafe { closure_capture_f64(self.data_cb as *const RawClosureHeader, 0) }; + live_dest.to_bits() == dest_bits + } +} + +fn pipe_routes() -> &'static Mutex>> { + static ROUTES: OnceLock>>> = OnceLock::new(); + ROUTES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Register `data_cb`/`end_cb` as normal `'data'`/`'end'` listeners on +/// `handle`, reusing the SAME `statics::listeners()` registry every other +/// socket listener goes through — so they get the same GC-root scanning +/// (`gc_roots::scan_net_roots`) and the same dispatch path +/// (`socket_events::js_ext_net_drain_pending`) as a user's own `.on(...)`, +/// with no new plumbing. +fn install_pipe_listeners(handle: i64, data_cb: i64, end_cb: i64) { + let mut listeners = statics::listeners().lock().unwrap(); + let per_socket = listeners.entry(handle).or_default(); + per_socket + .entry("data".to_string()) + .or_default() + .push(data_cb); + per_socket + .entry("end".to_string()) + .or_default() + .push(end_cb); +} + +fn uninstall_pipe_listeners(handle: i64, data_cb: i64, end_cb: i64) { + let mut listeners = statics::listeners().lock().unwrap(); + if let Some(per_socket) = listeners.get_mut(&handle) { + if let Some(vec) = per_socket.get_mut("data") { + vec.retain(|cb| *cb != data_cb); + } + if let Some(vec) = per_socket.get_mut("end") { + vec.retain(|cb| *cb != end_cb); + } + } +} + +/// `socket.pipe(dest[, options])`. Returns `dest` unchanged (Node's +/// chaining contract), or `undefined` when `dest` is missing/nullish. +pub(crate) fn socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { + if is_nullish(dest) { + return f64::from_bits(TAG_UNDEFINED_BITS); + } + crate::ensure_gc_scanner_registered(); + ensure_pipe_closure_arities_registered(); + + let end_on_finish = unsafe { + if is_nullish(options) { + f64::from_bits(0x7FFC_0000_0000_0004) // default true + } else { + let v = js_dynamic_object_get_property(options, c"end".as_ptr(), 3); + if is_nullish(v) { + f64::from_bits(0x7FFC_0000_0000_0004) + } else { + v + } + } + }; + + let data_closure = alloc_closure(pipe_data_forward as *const u8, 1); + let end_closure = alloc_closure(pipe_end_forward as *const u8, 2); + if data_closure.is_null() || end_closure.is_null() { + return f64::from_bits(TAG_UNDEFINED_BITS); + } + unsafe { + set_closure_capture_f64(data_closure, 0, dest); + set_closure_capture_f64(end_closure, 0, dest); + set_closure_capture_f64(end_closure, 1, end_on_finish); + } + let data_cb = data_closure as i64; + let end_cb = end_closure as i64; + install_pipe_listeners(handle, data_cb, end_cb); + pipe_routes() + .lock() + .unwrap() + .entry(handle) + .or_default() + .push(PipeRoute { data_cb, end_cb }); + + dest +} + +/// `socket.unpipe([dest])`. Removes the pipe route(s) installed by a prior +/// `pipe()` call — all of them when `dest` is omitted, only the ones whose +/// destination matches otherwise. Always returns the socket handle. +pub(crate) fn socket_unpipe(handle: i64, dest: f64) { + let filter_bits = (!is_nullish(dest)).then(|| dest.to_bits()); + let removed: Vec<(i64, i64)> = { + let mut routes = pipe_routes().lock().unwrap(); + let Some(list) = routes.get_mut(&handle) else { + return; + }; + let mut removed = Vec::new(); + list.retain(|route| { + let matches = filter_bits.is_none_or(|bits| route.matches_dest(bits)); + if matches { + removed.push((route.data_cb, route.end_cb)); + } + !matches + }); + if list.is_empty() { + routes.remove(&handle); + } + removed + }; + for (data_cb, end_cb) in removed { + uninstall_pipe_listeners(handle, data_cb, end_cb); + } +} + +/// Drop every tracked pipe route for `handle` without touching the listener +/// registry — called from the `'close'` teardown, which already clears the +/// whole `statics::listeners()` entry for `handle` (see +/// `socket_events::js_ext_net_drain_pending`'s `Close` arm), so removing the +/// individual callbacks there would be redundant. +pub(crate) fn drop_routes(handle: i64) { + pipe_routes().lock().unwrap().remove(&handle); +} + +/// GC root scanner for `pipe_routes()` — called from +/// `gc_roots::scan_net_roots` alongside the sibling `statics::listeners()` +/// scan. `data_cb`/`end_cb` are a SECOND copy of pointers already rooted via +/// `statics::listeners()` (`install_pipe_listeners` pushes the same values +/// there), but a copying GC cycle only rewrites addresses IN PLACE at +/// wherever the scanner visits them — the two copies are independent slots +/// as far as the collector is concerned, so this copy needs its own visit or +/// it keeps the pre-evacuation address after the `statics::listeners()` copy +/// has already been updated (`matches_dest`'s capture-slot read would then +/// dereference a stale/forwarded pointer — exactly the class of bug +/// `scripts/gc_runtime_root_holders.py` exists to catch). +pub(crate) fn scan_roots(visitor: &mut GcRootVisitor<'_>) { + if let Ok(mut routes) = pipe_routes().lock() { + for per_socket in routes.values_mut() { + for route in per_socket.iter_mut() { + visitor.visit_i64_slot(&mut route.data_cb); + visitor.visit_i64_slot(&mut route.end_cb); + } + } + } +} + +// ─── FFI: typed `net.Socket.prototype.pipe`/`.unpipe` ──────────────────────── +// +// The `NativeModSig` rows in +// `crates/perry-codegen/src/lower_call/native_table/net_events.rs` call +// these two symbols directly for a statically-typed `net.Socket` receiver. +// The untyped/dynamic-dispatch path (`dispatch.rs`'s `socket_method`) calls +// `socket_pipe`/`socket_unpipe` above instead of going through here, since +// it already has its own handle-nanboxing conventions. + +/// `socket.pipe(dest[, options])` for a statically-typed `net.Socket` +/// receiver. See the module doc for what this does and does not implement. +/// +/// # Safety +/// +/// `dest`/`options` must be valid NaN-boxed JS values (or `undefined`). +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { + socket_pipe(handle, dest, options) +} + +/// `socket.unpipe([dest])` for a statically-typed `net.Socket` receiver. +/// Returns the socket handle for chaining, matching Node. +/// +/// # Safety +/// +/// `dest` must be a valid NaN-boxed JS value (or `undefined`). +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_unpipe(handle: i64, dest: f64) -> i64 { + socket_unpipe(handle, dest); + handle +} diff --git a/crates/perry-ext-net/src/socket_events.rs b/crates/perry-ext-net/src/socket_events.rs index f644506c14..f60edb82dd 100644 --- a/crates/perry-ext-net/src/socket_events.rs +++ b/crates/perry-ext-net/src/socket_events.rs @@ -220,6 +220,11 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { lifecycle::drain_once_listeners(id, "error"); } PendingNetEvent::End(id) => { + // #10465 — `readableEnded` (and `readable`) flip as part of + // emitting `'end'`, before any listener runs, matching Node. + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { + socket.readable_ended = true; + } // Issue #1852 — readable side ended (peer FIN). Fire the // `'end'` listeners; the trailing `Close` event (pushed // right after `End` in `run_socket_task`) does the actual @@ -245,6 +250,18 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { fn js_tls_client_record_closed(handle: i64); } js_tls_client_record_closed(id); + // #10465 — flip the terminal state fields synchronously with + // firing `'close'`, matching Node's own timing (its + // `'close'` listeners see `destroyed: true`; earlier events + // on the SAME socket do not). This is the common teardown + // point for every path that reaches `Close`: peer EOF + + // local end, explicit `.destroy()`, connect failure, TLS + // handshake failure. + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { + socket.destroyed = true; + socket.is_open = false; + socket.connecting = false; + } let had_error = f64::from_bits(JsValue::from_bool(false).bits()); let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "close")); for i in 0..frame.len() { @@ -261,6 +278,11 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { statics::http_agent_phases().lock().unwrap().remove(&id); statics::max_listeners().lock().unwrap().remove(&id); server_state::discard_pending_server_data(id); + // #10444 — the listener-map entry above just went away, so + // any pipe route's tracked callback pointers are dangling; + // drop the tracking table entry too (nothing left to + // uninstall from). + crate::pipe::drop_routes(id); } // Issue #1123 followup — server-side events. The // accept loop pushes `ServerConnection`/`ServerListening`/ diff --git a/crates/perry-hir/src/destructuring/var_decl_sources.rs b/crates/perry-hir/src/destructuring/var_decl_sources.rs index 9897b1719a..2bb91915ac 100644 --- a/crates/perry-hir/src/destructuring/var_decl_sources.rs +++ b/crates/perry-hir/src/destructuring/var_decl_sources.rs @@ -218,6 +218,46 @@ pub(super) fn register_destructured_stream_ctors( return Vec::new(); }; + // #10623: record the destructuring's PROVENANCE (local binding -> the + // export key it was destructured from) whenever the RHS resolves to a + // real native/Node-builtin module — regardless of the #8342 CJS-wrapper + // gate immediately below. Inside a CJS-wrapped module that gate skips the + // FULL native-module-alias registration (member reads/calls must fall + // through to the wrapper's real runtime `require(...)` there), but the + // destructured identifier is still genuinely bound FROM that native + // module at runtime. Class-heritage resolution (`class_decl.rs`) needs + // exactly that narrower fact to avoid treating `class X extends + // AsyncResource {}` as user-shadowed just because the CJS wrapper makes + // every top-level `const` a real local — without it, `super()` (explicit + // or the implicit default derived ctor) fell back to a generic + // call-the-value dispatch that neither installs the native base's surface + // nor tolerates bases whose runtime value enforces real ES `class` + // `[[Call]]` semantics (`AsyncResource` throws "cannot be invoked without + // 'new'"). + if require_resolvable_native_specifier(init).is_some() { + for prop in &obj_pat.props { + let (key, binding) = match prop { + ast::ObjectPatProp::Assign(assign) => { + let name = assign.key.sym.to_string(); + (name.clone(), name) + } + ast::ObjectPatProp::KeyValue(kv) => { + let key = match &kv.key { + ast::PropName::Ident(i) => i.sym.to_string(), + ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + _ => continue, + }; + let ast::Pat::Ident(binding) = kv.value.as_ref() else { + continue; + }; + (key, binding.id.sym.to_string()) + } + ast::ObjectPatProp::Rest(_) => continue, + }; + ctx.require_destructured_native_locals.insert(binding, key); + } + } + // #8342: inside a CJS-wrapped module the wrap's synthetic // `function require(...)` shadows the bare global `require`, and its // built-in arm resolves `require("process")` etc. via `createRequire` at diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 53f7cadf01..5b27e57f3e 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -26,7 +26,7 @@ use crate::ir::*; /// `b/util.ts` are `a_util_ts` and `b_util_ts` — distinct salts, and /// cross-module capture chains stay isolated exactly as before. Same module ⇒ /// same salt ⇒ same-module inheritance keeps sharing parent stashes. -fn stable_module_salt(module_identity: &str) -> u64 { +pub(crate) fn stable_module_salt(module_identity: &str) -> u64 { let mut h: u64 = 0xcbf29ce484222325; for b in module_identity.as_bytes() { h ^= u64::from(*b); @@ -36,213 +36,6 @@ fn stable_module_salt(module_identity: &str) -> u64 { } impl LoweringContext { - // #854: single-arg constructor (delegates to `with_class_id_start`). - // Currently only exercised from the `#[cfg(test)]` lowering tests, so it - // reads as dead in a non-test build. Kept as the canonical entry point. - #[allow(dead_code)] - pub fn new(source_file_path: impl Into) -> Self { - Self::with_class_id_start(source_file_path, 1) - } - - pub fn with_class_id_start( - source_file_path: impl Into, - start_class_id: ClassId, - ) -> Self { - // No module name available (the `#[cfg(test)]` lowering entry points). - // Salting on the path preserves the pre-#7177 behaviour for those; the - // production path below passes the module name. - let source_file_path = source_file_path.into(); - let identity = source_file_path.clone(); - Self::with_class_id_start_salted(source_file_path, identity, start_class_id) - } - - /// #7177: as [`Self::with_class_id_start`], but salts the module's - /// `__perry_cap_*` names on `salt_identity` — the module NAME — instead of - /// its absolute source path, so the emitted symbols do not change with the - /// checkout location. - pub fn with_class_id_start_salted( - source_file_path: impl Into, - salt_identity: impl Into, - start_class_id: ClassId, - ) -> Self { - let source_file_path = source_file_path.into(); - let module_identity = salt_identity.into(); - let tagged_template_site_salt = stable_module_salt(&module_identity); - Self { - next_local_id: 0, - local_source_spans: HashMap::new(), - classic_for_lexical_bindings: HashSet::new(), - next_global_id: 0, - next_func_id: 0, - next_class_id: start_class_id, // Start from the provided ID to avoid collisions across modules - next_enum_id: 0, - next_interface_id: 0, - next_type_alias_id: 0, - tagged_template_site_salt, - next_tagged_template_site_id: 0, - locals: crate::lower::Locals::new(), - globals: Vec::new(), - functions: Vec::new(), - func_defaults: Vec::new(), - classes: Vec::new(), - class_statics: Vec::new(), - class_field_names: HashMap::new(), - class_accessor_names: HashMap::new(), - class_method_names: HashMap::new(), - class_native_extends: Vec::new(), - class_field_types: HashMap::new(), - enums: Vec::new(), - pending_body_enums: Vec::new(), - interfaces: Vec::new(), - type_aliases: Vec::new(), - native_profile_type_aliases: HashMap::new(), - immutable_locals: HashSet::new(), - interface_source_keys: std::collections::HashMap::new(), - interface_object_types: std::collections::HashMap::new(), - imported_functions: Vec::new(), - builtin_named_imports: Vec::new(), - native_modules: Vec::new(), - builtin_module_aliases: Vec::new(), - subns_path_aliases: HashMap::new(), - type_param_scopes: Vec::new(), - type_param_constraints: Vec::new(), - native_instances: Vec::new(), - param_native_hints: HashMap::new(), - current_strict: false, - ui_widget_type_aliases: HashMap::new(), - deferred_unknown_native_imports: HashMap::new(), - current_class: None, - current_class_scope_depth: None, - current_class_inner_name: None, - pending_class_inner_name: None, - class_expr_self_bindings: Vec::new(), - current_class_member_is_static: false, - private_scopes: Vec::new(), - object_super_home_stack: Vec::new(), - extern_func_types: Vec::new(), - source_file_path, - empty_site_width_hints: std::collections::HashMap::new(), - exportable_object_vars: HashSet::new(), - pending_functions: Vec::new(), - closure_display_names: HashMap::new(), - class_display_names: HashMap::new(), - gen_param_prologue_len: HashMap::new(), - assignment_inferred_name: None, - inferred_class_bindings: Default::default(), - closure_source_text: HashMap::new(), - class_source_text: HashMap::new(), - func_return_native_instances: Vec::new(), - pending_classes: Vec::new(), - func_return_types: Vec::new(), - resolved_types: None, - pre_registered_module_vars: HashSet::new(), - pre_registered_module_var_decls: HashSet::new(), - script_var_decl_names: HashSet::new(), - module_level_ids: HashSet::new(), - sloppy_implicit_globals: Vec::new(), - sloppy_implicit_global_ids: HashSet::new(), - with_sloppy_implicit_ids: std::collections::HashMap::new(), - pending_with_implicit_inits: Vec::new(), - scope_depth: 0, - scope_local_marks: Vec::new(), - scope_module_shadow_marks: Vec::new(), - inside_block_scope: 0, - for_of_force_lazy: false, - namespace_vars: Vec::new(), - current_namespace: None, - module_native_instances: Vec::new(), - local_id_native_instances: HashMap::new(), - uses_fetch: false, - uses_webassembly: false, - react_default_import_local: None, - suppress_stdlib_dispatch_guard_once: false, - lowering_call_callee: false, - unresolved_ident_as_global: false, - global_intrinsic_new_once: false, - with_env_stack: Vec::new(), - var_hoisted_ids: HashSet::new(), - tdz_forward_ids: HashSet::new(), - forward_lexical_names: HashSet::new(), - forward_lexical_saves: Vec::new(), - catch_param_scopes: Vec::new(), - annexb_block_fn_var_ids: HashMap::new(), - annexb_block_fn_names_all: HashSet::new(), - block_fn_decl_bindings: HashMap::new(), - lexical_forward_decls: HashMap::new(), - nested_forward_scope_ids: HashSet::new(), - functions_index: HashMap::new(), - classes_index: HashMap::new(), - imported_functions_index: HashMap::new(), - builtin_module_aliases_index: HashMap::new(), - native_instances_index: HashMap::new(), - module_native_instances_index: HashMap::new(), - func_return_native_instances_index: HashMap::new(), - prescan_protected_native_params: std::collections::HashMap::new(), - native_modules_index: HashMap::new(), - module_shadow_stack: Vec::new(), - class_statics_index: HashMap::new(), - weakref_locals: HashSet::new(), - finreg_locals: HashSet::new(), - weakmap_locals: HashSet::new(), - weakset_locals: HashSet::new(), - namespace_import_locals: HashSet::new(), - fetch_call_response_locals: HashSet::new(), - namespace_import_sources: std::collections::HashMap::new(), - generator_func_names: HashSet::new(), - async_generator_func_names: HashSet::new(), - nested_generator_forward_referenced: HashSet::new(), - iterator_func_for_class: std::collections::HashMap::new(), - proxy_locals: HashSet::new(), - proxy_local_ids: HashSet::new(), - builtin_proto_method_locals: HashMap::new(), - plain_object_locals: HashSet::new(), - proxy_revoke_locals: HashMap::new(), - class_expr_aliases: HashMap::new(), - in_constructor_class: None, - current_class_is_derived: false, - in_class_field_init: false, - current_class_super_ident: None, - mixin_funcs: HashMap::new(), - anon_shape_classes: HashMap::new(), - anon_shape_fields: HashMap::new(), - closed_shape_literal_locals: HashMap::new(), - prefer_exported_method_shape_seed: false, - forward_class_names: std::collections::HashSet::new(), - forward_class_decl_depth: std::collections::HashMap::new(), - class_renames: std::collections::HashMap::new(), - next_class_rename_id: 0, - module_class_decl_names: std::collections::HashSet::new(), - class_decl_names_any_depth: std::collections::HashSet::new(), - next_anon_shape_id: 0, - class_method_return_types: Vec::new(), - class_captures: Vec::new(), - body_class_expr_captures: Vec::new(), - let_class_aliases: Vec::new(), - global_this_aliases: HashSet::new(), - prototype_aliases: HashMap::new(), - prototype_function_aliases: HashMap::new(), - function_valued_locals: HashSet::new(), - prototype_function_locals: HashMap::new(), - object_static_method_aliases: HashMap::new(), - array_static_method_aliases: HashMap::new(), - is_entry_module: false, - platform_globals: HashSet::new(), - saw_global_this_expr: false, - reassigned_top_level_identifiers: HashSet::new(), - module_strict: false, - strict_mode_stack: Vec::new(), - is_external_module: false, - optional_require_try_depth: 0, - require_local_is_create_require: false, - import_meta_require_local: None, - fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), - dynamic_function_subclasses: HashMap::new(), - expr_lower_depth: 0, - prelowered_member_receiver: None, - in_nonarrow_fn: false, - } - } - pub(crate) fn fresh_tagged_template_site_id(&mut self) -> u64 { let local_id = self.next_tagged_template_site_id; self.next_tagged_template_site_id = self.next_tagged_template_site_id.wrapping_add(1); diff --git a/crates/perry-hir/src/lower/context_new.rs b/crates/perry-hir/src/lower/context_new.rs new file mode 100644 index 0000000000..7bf9399924 --- /dev/null +++ b/crates/perry-hir/src/lower/context_new.rs @@ -0,0 +1,220 @@ +//! `LoweringContext::new()` / `with_class_id_start[_salted]()` — extracted +//! from `context.rs` for the 2000-line cap (#10623's `require_destructured_ +//! native_locals` field pushed it to 2001). Pure relocation: no logic +//! changes, and no visibility narrowing — `stable_module_salt` widened from +//! module-private to `pub(crate)` so this sibling module can still call it. + +use std::collections::{HashMap, HashSet}; + +use super::*; +use crate::ir::*; + +impl LoweringContext { + // #854: single-arg constructor (delegates to `with_class_id_start`). + // Currently only exercised from the `#[cfg(test)]` lowering tests, so it + // reads as dead in a non-test build. Kept as the canonical entry point. + #[allow(dead_code)] + pub fn new(source_file_path: impl Into) -> Self { + Self::with_class_id_start(source_file_path, 1) + } + + pub fn with_class_id_start( + source_file_path: impl Into, + start_class_id: ClassId, + ) -> Self { + // No module name available (the `#[cfg(test)]` lowering entry points). + // Salting on the path preserves the pre-#7177 behaviour for those; the + // production path below passes the module name. + let source_file_path = source_file_path.into(); + let identity = source_file_path.clone(); + Self::with_class_id_start_salted(source_file_path, identity, start_class_id) + } + + /// #7177: as [`Self::with_class_id_start`], but salts the module's + /// `__perry_cap_*` names on `salt_identity` — the module NAME — instead of + /// its absolute source path, so the emitted symbols do not change with the + /// checkout location. + pub fn with_class_id_start_salted( + source_file_path: impl Into, + salt_identity: impl Into, + start_class_id: ClassId, + ) -> Self { + let source_file_path = source_file_path.into(); + let module_identity = salt_identity.into(); + let tagged_template_site_salt = super::context::stable_module_salt(&module_identity); + Self { + next_local_id: 0, + local_source_spans: HashMap::new(), + classic_for_lexical_bindings: HashSet::new(), + next_global_id: 0, + next_func_id: 0, + next_class_id: start_class_id, // Start from the provided ID to avoid collisions across modules + next_enum_id: 0, + next_interface_id: 0, + next_type_alias_id: 0, + tagged_template_site_salt, + next_tagged_template_site_id: 0, + locals: crate::lower::Locals::new(), + globals: Vec::new(), + functions: Vec::new(), + func_defaults: Vec::new(), + classes: Vec::new(), + class_statics: Vec::new(), + class_field_names: HashMap::new(), + class_accessor_names: HashMap::new(), + class_method_names: HashMap::new(), + class_native_extends: Vec::new(), + class_field_types: HashMap::new(), + enums: Vec::new(), + pending_body_enums: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + native_profile_type_aliases: HashMap::new(), + immutable_locals: HashSet::new(), + interface_source_keys: std::collections::HashMap::new(), + interface_object_types: std::collections::HashMap::new(), + imported_functions: Vec::new(), + builtin_named_imports: Vec::new(), + native_modules: Vec::new(), + require_destructured_native_locals: HashMap::new(), + builtin_module_aliases: Vec::new(), + subns_path_aliases: HashMap::new(), + type_param_scopes: Vec::new(), + type_param_constraints: Vec::new(), + native_instances: Vec::new(), + param_native_hints: HashMap::new(), + current_strict: false, + ui_widget_type_aliases: HashMap::new(), + deferred_unknown_native_imports: HashMap::new(), + current_class: None, + current_class_scope_depth: None, + current_class_inner_name: None, + pending_class_inner_name: None, + class_expr_self_bindings: Vec::new(), + current_class_member_is_static: false, + private_scopes: Vec::new(), + object_super_home_stack: Vec::new(), + extern_func_types: Vec::new(), + source_file_path, + empty_site_width_hints: std::collections::HashMap::new(), + exportable_object_vars: HashSet::new(), + pending_functions: Vec::new(), + closure_display_names: HashMap::new(), + class_display_names: HashMap::new(), + gen_param_prologue_len: HashMap::new(), + assignment_inferred_name: None, + inferred_class_bindings: Default::default(), + closure_source_text: HashMap::new(), + class_source_text: HashMap::new(), + func_return_native_instances: Vec::new(), + pending_classes: Vec::new(), + func_return_types: Vec::new(), + resolved_types: None, + pre_registered_module_vars: HashSet::new(), + pre_registered_module_var_decls: HashSet::new(), + script_var_decl_names: HashSet::new(), + module_level_ids: HashSet::new(), + sloppy_implicit_globals: Vec::new(), + sloppy_implicit_global_ids: HashSet::new(), + with_sloppy_implicit_ids: std::collections::HashMap::new(), + pending_with_implicit_inits: Vec::new(), + scope_depth: 0, + scope_local_marks: Vec::new(), + scope_module_shadow_marks: Vec::new(), + inside_block_scope: 0, + for_of_force_lazy: false, + namespace_vars: Vec::new(), + current_namespace: None, + module_native_instances: Vec::new(), + local_id_native_instances: HashMap::new(), + uses_fetch: false, + uses_webassembly: false, + react_default_import_local: None, + suppress_stdlib_dispatch_guard_once: false, + lowering_call_callee: false, + unresolved_ident_as_global: false, + global_intrinsic_new_once: false, + with_env_stack: Vec::new(), + var_hoisted_ids: HashSet::new(), + tdz_forward_ids: HashSet::new(), + forward_lexical_names: HashSet::new(), + forward_lexical_saves: Vec::new(), + catch_param_scopes: Vec::new(), + annexb_block_fn_var_ids: HashMap::new(), + annexb_block_fn_names_all: HashSet::new(), + block_fn_decl_bindings: HashMap::new(), + lexical_forward_decls: HashMap::new(), + nested_forward_scope_ids: HashSet::new(), + functions_index: HashMap::new(), + classes_index: HashMap::new(), + imported_functions_index: HashMap::new(), + builtin_module_aliases_index: HashMap::new(), + native_instances_index: HashMap::new(), + module_native_instances_index: HashMap::new(), + func_return_native_instances_index: HashMap::new(), + prescan_protected_native_params: std::collections::HashMap::new(), + native_modules_index: HashMap::new(), + module_shadow_stack: Vec::new(), + class_statics_index: HashMap::new(), + weakref_locals: HashSet::new(), + finreg_locals: HashSet::new(), + weakmap_locals: HashSet::new(), + weakset_locals: HashSet::new(), + namespace_import_locals: HashSet::new(), + fetch_call_response_locals: HashSet::new(), + namespace_import_sources: std::collections::HashMap::new(), + generator_func_names: HashSet::new(), + async_generator_func_names: HashSet::new(), + nested_generator_forward_referenced: HashSet::new(), + iterator_func_for_class: std::collections::HashMap::new(), + proxy_locals: HashSet::new(), + proxy_local_ids: HashSet::new(), + builtin_proto_method_locals: HashMap::new(), + plain_object_locals: HashSet::new(), + proxy_revoke_locals: HashMap::new(), + class_expr_aliases: HashMap::new(), + in_constructor_class: None, + current_class_is_derived: false, + in_class_field_init: false, + current_class_super_ident: None, + mixin_funcs: HashMap::new(), + anon_shape_classes: HashMap::new(), + anon_shape_fields: HashMap::new(), + closed_shape_literal_locals: HashMap::new(), + prefer_exported_method_shape_seed: false, + forward_class_names: std::collections::HashSet::new(), + forward_class_decl_depth: std::collections::HashMap::new(), + class_renames: std::collections::HashMap::new(), + next_class_rename_id: 0, + module_class_decl_names: std::collections::HashSet::new(), + class_decl_names_any_depth: std::collections::HashSet::new(), + next_anon_shape_id: 0, + class_method_return_types: Vec::new(), + class_captures: Vec::new(), + body_class_expr_captures: Vec::new(), + let_class_aliases: Vec::new(), + global_this_aliases: HashSet::new(), + prototype_aliases: HashMap::new(), + prototype_function_aliases: HashMap::new(), + function_valued_locals: HashSet::new(), + prototype_function_locals: HashMap::new(), + object_static_method_aliases: HashMap::new(), + array_static_method_aliases: HashMap::new(), + is_entry_module: false, + platform_globals: HashSet::new(), + saw_global_this_expr: false, + reassigned_top_level_identifiers: HashSet::new(), + module_strict: false, + strict_mode_stack: Vec::new(), + is_external_module: false, + optional_require_try_depth: 0, + require_local_is_create_require: false, + import_meta_require_local: None, + fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), + dynamic_function_subclasses: HashMap::new(), + expr_lower_depth: 0, + prelowered_member_receiver: None, + in_nonarrow_fn: false, + } + } +} diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 1eddfaed43..3c44999141 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -275,6 +275,22 @@ pub struct LoweringContext { /// For namespace imports (import * as x), method_name is None /// For named imports (import { v4 as uuid }), method_name is Some("v4") pub(crate) native_modules: Vec<(String, String, Option)>, + /// #10623: `const { Key } = require("")` + /// destructured bindings, keyed by the LOCAL binding name -> the + /// destructured export KEY (identity for the common unaliased case). + /// Recorded unconditionally, even inside a CJS-wrapped module where + /// `register_destructured_stream_ctors` deliberately skips the full + /// `native_modules` alias registration (#8342: the wrapper's synthetic + /// `require(...)` returns a real runtime value there, so the static + /// native-namespace fast path is not safe to use for ordinary property + /// reads/calls). Class-heritage resolution (`class_decl.rs`) is a + /// narrower consumer: it only needs "was this identifier bound FROM a + /// require() of a real native module", to avoid treating `class X + /// extends AsyncResource {}` as user-shadowed merely because the CJS + /// wrapper makes every top-level `const` a genuine local. Not itself a + /// module/value resolution table — do not use it for anything requiring + /// runtime-accurate native-module semantics. + pub(crate) require_destructured_native_locals: HashMap, /// Built-in module aliases from require(): local_name -> module_name (e.g., "myFs" -> "fs") pub(crate) builtin_module_aliases: Vec<(String, String)>, /// Stack of type parameter scopes (for nested generics) diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 1831d4b68f..d6ae8d5f0d 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -37,6 +37,7 @@ pub(crate) mod ambient; pub(crate) mod builder_fold; mod context; +mod context_new; pub(crate) use context::perry_ui_factory_returns_handle; pub(crate) mod expr_assign; mod expr_call; diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index bc4c1f498f..2f3660611a 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1934,55 +1934,6 @@ fn aliased_native_imports_canonicalize_class_heritage() { assert!(watcher.extends_expr.is_none()); } -/// #8882: a module-level class constructing a sibling class that is declared -/// inside a function body lowered LATER. This is the shape the CJS wrap -/// produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of -/// the module IIFE while `SentinelNode` (whose doc comment closes on the -/// `class` line, so the textual hoister never sees it) stays inside the -/// `__perry_cjs_factory` closure. JS binds the constructor reference when the -/// `new` executes; the #8643 guard instead lowered it to an unconditional, -/// nameless `ReferenceError` that killed the application at init. -#[test] -fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { - let source = r#" - class LRUCache { - constructor() { - this.head = new SentinelNode(); - this.tail = new SentinelNode(); - } - } - const _cjs = (function () { - class SentinelNode { - constructor() { - this.prev = null; - this.next = null; - } - } - return { SentinelNode }; - })(); - "#; - let module = perry_parser::parse_typescript(source, "lru-cache.js").expect("source parses"); - let hir = super::lower_module(&module, "lru-cache", "lru-cache.js").expect("source lowers"); - let lru_cache = hir - .classes - .iter() - .find(|class| class.name == "LRUCache") - .expect("LRUCache class is lowered"); - let debug = format!("{lru_cache:?}"); - - assert!( - !debug.contains("js_throw_reference_error_unresolved_get") - && !debug.contains("js_global_get_or_throw_unresolved"), - "a sibling class declared later in the module must not lower to a \ - compile-time ReferenceError:\n{debug}" - ); - assert_eq!( - debug.matches(r#"New { class_name: "SentinelNode""#).count(), - 2, - "both `new SentinelNode()` sites must stay late-bound by-name constructs:\n{debug}" - ); -} - mod ambient_declare; mod unresolved_new_global; @@ -1998,3 +1949,7 @@ mod class_expr_subclass_captures; mod nullish_over_optional_chain; mod subclass_ctor_inherited_method; mod ui_widget_add_child; + +mod issue_10623_require_destructured_native_super; + +mod hoisted_sibling_in_later_closure; diff --git a/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs b/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs new file mode 100644 index 0000000000..c53b6028b5 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/hoisted_sibling_in_later_closure.rs @@ -0,0 +1,49 @@ +//! #8882: a module-level class constructing a sibling class that is declared +//! inside a function body lowered LATER. This is the shape the CJS wrap +//! produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of +//! the module IIFE while `SentinelNode` (whose doc comment closes on the +//! `class` line, so the textual hoister never sees it) stays inside the +//! `__perry_cjs_factory` closure. JS binds the constructor reference when the +//! `new` executes; the #8643 guard instead lowered it to an unconditional, +//! nameless `ReferenceError` that killed the application at init. + +#[test] +fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { + let source = r#" + class LRUCache { + constructor() { + this.head = new SentinelNode(); + this.tail = new SentinelNode(); + } + } + const _cjs = (function () { + class SentinelNode { + constructor() { + this.prev = null; + this.next = null; + } + } + return { SentinelNode }; + })(); + "#; + let module = perry_parser::parse_typescript(source, "lru-cache.js").expect("source parses"); + let hir = super::lower_module(&module, "lru-cache", "lru-cache.js").expect("source lowers"); + let lru_cache = hir + .classes + .iter() + .find(|class| class.name == "LRUCache") + .expect("LRUCache class is lowered"); + let debug = format!("{lru_cache:?}"); + + assert!( + !debug.contains("js_throw_reference_error_unresolved_get") + && !debug.contains("js_global_get_or_throw_unresolved"), + "a sibling class declared later in the module must not lower to a \ + compile-time ReferenceError:\n{debug}" + ); + assert_eq!( + debug.matches(r#"New { class_name: "SentinelNode""#).count(), + 2, + "both `new SentinelNode()` sites must stay late-bound by-name constructs:\n{debug}" + ); +} diff --git a/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs b/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs new file mode 100644 index 0000000000..cf831859e4 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/issue_10623_require_destructured_native_super.rs @@ -0,0 +1,148 @@ +//! #10623: `class NoCtor extends AsyncResource {}` — a constructor-less +//! subclass of a native base obtained via `const { AsyncResource } = +//! require("node:async_hooks")` — must still resolve `AsyncResource` as the +//! NATIVE parent (`native_extends`), not as a dynamically-shadowed local +//! (`extends_expr`). Split from `tests.rs` for the 2000-line cap. +//! +//! A CJS-wrapped module runs its whole body inside the wrap's synthetic +//! `require(...)` IIFE (see `test_cjs_wrapper_lru_cache_destructure_uses_ +//! static_constructor` above for the same simulated-wrapper shape), so every +//! top-level `const` there — including `const { AsyncResource } = +//! require(...)` — is a genuine local. Before the fix, `class_decl.rs`'s +//! `locally_shadowed` check could not tell that apart from a real user +//! shadow (`const AsyncResource = MyOwnClass`), so it always took the dynamic +//! `extends_expr` path and lost the native install + argument forwarding. + +fn cjs_wrapper_source(body: &str) -> String { + format!( + r#" + function __perry_cjs_require_error(kind: string, code: string, message: string): any {{ + return {{ kind, code, message }}; + }} + function __perry_cjs_require_is_builtin(specifier: string): boolean {{ + return false; + }} + function require(specifier: string): any {{ + return undefined; + }} + {body} + "# + ) +} + +/// The issue's exact shape: no own constructor. Must resolve as the native +/// parent, forwarding the `new`-site args to `super()` implicitly. +#[test] +fn cjs_destructured_async_resource_implicit_ctor_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + class NoCtor extends AsyncResource {} + const a = new NoCtor("MyResource"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "NoCtor") + .expect("NoCtor is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "a require()-destructured AsyncResource must resolve as the native \ + parent, not a dynamically-shadowed local: {class:#?}" + ); + assert!( + class.extends_expr.is_none(), + "the native parent must not ALSO be captured as a dynamic \ + extends_expr (that is the pre-fix shadowed-local path): {class:#?}" + ); +} + +/// The explicit-`super()` control: this form must keep resolving natively +/// too — before the fix it took the SAME broken dynamic path (the issue's +/// claim that the explicit form "already works" held only for an ESM import, +/// not for this CJS shape). +#[test] +fn cjs_destructured_async_resource_explicit_ctor_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + class WithCtor extends AsyncResource { + constructor(type: string) { super(type); } + } + const b = new WithCtor("MyResource2"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "WithCtor") + .expect("WithCtor is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "the explicit-ctor form must ALSO resolve as the native parent: {class:#?}" + ); +} + +/// A class EXPRESSION reaches a separate lowering arm +/// (`lower_class_from_ast`) with its own copy of the shadow check — pin it +/// too so the fix is not name-keyed to only the declaration form. +#[test] +fn cjs_destructured_async_resource_class_expr_uses_native_parent() { + let source = cjs_wrapper_source( + r#" + const { AsyncResource } = require("node:async_hooks"); + const Anon = class extends AsyncResource {}; + const inst = new Anon("AnonResource"); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "Anon") + .expect("the class expression is lowered"); + assert_eq!( + class.native_extends, + Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "a class EXPRESSION extending a require()-destructured native base \ + must ALSO resolve natively: {class:#?}" + ); +} + +/// Guards the other side: GENUINE shadowing (the user's own value, not a +/// require() re-export) must still take the dynamic `extends_expr` path — +/// the fix narrows the false positive, it does not remove the real check. +#[test] +fn cjs_local_shadowing_a_native_name_still_goes_dynamic() { + let source = cjs_wrapper_source( + r#" + class MyOwnAsyncResource { tag = "mine"; } + const AsyncResource = MyOwnAsyncResource; + class NoCtor extends AsyncResource {} + const a = new NoCtor(); + "#, + ); + 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 class = hir + .classes + .iter() + .find(|c| c.name == "NoCtor") + .expect("NoCtor is lowered"); + assert!( + class.native_extends.is_none(), + "a genuine user shadow of the native name must NOT resolve natively: {class:#?}" + ); + assert!( + class.extends_expr.is_some(), + "a genuine user shadow must still route through the dynamic parent: {class:#?}" + ); +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 862d5abdbb..ae640e409b 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -39,9 +39,11 @@ fn is_genuine_node_stream_parent(ctx: &LoweringContext, name: &str) -> bool { } mod class_heritage; +mod from_ast; mod member_helpers; mod member_registration; use class_heritage::*; +pub(crate) use from_ast::lower_class_from_ast; pub(crate) use member_helpers::capture_class_source; use member_helpers::{ generic_computed_member_key, lower_generic_computed_class_member, @@ -253,8 +255,31 @@ pub fn lower_class_decl( // path, not the native `events` parent. ESM imports are not in // `ctx.locals`, so genuine native subclassing is unchanged. Mirrors // the class-expression arm below. + // + // #10623: a CJS-wrapped module is the odd one out — EVERY + // top-level `const` there is a genuine local (the whole module + // body runs inside the wrap's IIFE), so `const { AsyncResource } = + // require("node:async_hooks")` looks identical to true user + // shadowing under the check above. Distinguish them by + // PROVENANCE, not by re-deriving the name: `parent_name` shadows + // only if it was NOT also destructured from a require() of a real + // native module with this same export key + // (`require_destructured_native_locals`, populated unconditionally + // in `var_decl_sources.rs` regardless of the #8342 CJS-wrapper + // gate that skips the FULL native-module-alias registration for + // the same binding). A class expression / indirect subclass never + // reaches this check with anything but the immediate `extends` + // identifier, so this does not change the "keyed on the literal + // extends name" failure mode described in CLAUDE.md — it only + // widens what counts as "not actually shadowed" for that one + // identifier. + let require_native_reexport = ctx + .require_destructured_native_locals + .get(&parent_name) + .is_some_and(|key| *key == canonical_parent_name); let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) - && ctx.locals.lookup(&parent_name).is_some(); + && ctx.locals.lookup(&parent_name).is_some() + && !require_native_reexport; if native_parent.is_some() && !locally_shadowed { // Keep `extends_name` populated alongside `native_extends` // so SuperCall codegen + downstream chain walks still @@ -1262,715 +1287,3 @@ pub fn lower_class_decl( specialized_from: None, }) } - -/// Lower a class expression (ast::Class) to HIR. -/// Used for anonymous class expressions like `new (class extends Command { ... })()`. -pub fn lower_class_from_ast( - ctx: &mut LoweringContext, - class: &ast::Class, - name: &str, - is_exported: bool, -) -> Result { - validate_legacy_decorator_surface(class, name)?; - validate_class_element_early_errors(class, name)?; - let class_id = match ctx.lookup_class(name) { - Some(id) => id, - None => { - let id = ctx.fresh_class(); - ctx.register_class(name.to_string(), id); - id - } - }; - capture_class_source(ctx, class_id, class); - - let old_class = ctx.current_class.take(); - ctx.current_class = Some(name.to_string()); - let old_class_scope_depth = ctx.current_class_scope_depth.replace(ctx.scope_depth); - let old_inner_name = ctx.current_class_inner_name.take(); - // A class-expression caller stashes the source ident here; fall back - // to the (possibly synthetic) registration name when absent. - let explicit_inner_name = ctx.pending_class_inner_name.take(); - ctx.current_class_inner_name = explicit_inner_name - .clone() - .or_else(|| Some(name.to_string())); - let old_is_derived = ctx.current_class_is_derived; - ctx.current_class_is_derived = class.super_class.is_some(); - - // Private-name scope for this class-expression body (see lower_class_decl). - ctx.push_private_scope(super::build_private_scope(class, name, class_id)); - - // Issue #562: same as the parallel `lower_class_decl` arm — track the - // parent class identifier so super({...}) controller-param pre-scan - // fires for stream subclasses. - let old_super_ident = ctx.current_class_super_ident.take(); - ctx.current_class_super_ident = match class.super_class.as_deref() { - Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), - _ => None, - }; - - let type_params = class - .type_params - .as_ref() - .map(|tp| extract_type_params(tp)) - .unwrap_or_default(); - - ctx.enter_type_param_scope(&type_params); - - // #5437: parent Ident shadowed by an in-scope lexical local? (See the - // matching computation in `lower_class_decl`.) Lets codegen prefer the - // dynamic local over a NAME-keyed built-in special case. - let heritage_lexically_shadowed = match class.super_class.as_deref() { - Some(ast::Expr::Ident(ident)) => { - let n = ident.sym.to_string(); - !ctx.class_renames.contains_key(&n) && ctx.locals.lookup(&n).is_some() - } - _ => false, - }; - - let (extends, extends_name, native_extends, extends_expr) = if let Some(ref super_class) = - class.super_class - { - if explicit_inner_name - .as_deref() - .is_some_and(|inner| is_class_self_heritage(super_class, inner)) - { - ( - None, - None, - None, - Some(Box::new(crate::lower::throw_reference_error_expr( - "js_throw_reference_error_this_before_super", - ))), - ) - } else if let ast::Expr::Ident(ident) = super_class.as_ref() { - let parent_name = ident.sym.to_string(); - let canonical_parent_name = canonical_native_parent_name(ctx, &parent_name) - .unwrap_or(&parent_name) - .to_string(); - let native_parent = match canonical_parent_name.as_str() { - "EventEmitter" => Some(("events".to_string(), "EventEmitter".to_string())), - "EventEmitterAsyncResource" => Some(( - "events".to_string(), - "EventEmitterAsyncResource".to_string(), - )), - "AsyncLocalStorage" => { - Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) - } - "AsyncResource" => Some(("async_hooks".to_string(), "AsyncResource".to_string())), - "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), - // #10293: lru-cache's LRUCache is a compile-time lowering with - // no runtime value; recognising it here routes `extends` to the - // subclass-init path instead of the dynamic parent registration - // that throws "Class extends value is not a constructor". - "LRUCache" => Some(("lru-cache".to_string(), "LRUCache".to_string())), - // Issue #562: keep in lockstep with the parallel arm in - // `lower_class_decl` above. - "ReadableStream" => { - Some(("readable_stream".to_string(), "ReadableStream".to_string())) - } - "WritableStream" => { - Some(("writable_stream".to_string(), "WritableStream".to_string())) - } - "TransformStream" => Some(( - "transform_stream".to_string(), - "TransformStream".to_string(), - )), - // #1545: classic node:stream base classes — keep in lockstep - // with the parallel arm in `lower_class_decl` above. Gated on - // `is_genuine_node_stream_parent` so a userland stream-shim - // binding (readable-stream's `Transform`) falls through to the - // dynamic `extends_expr` parent path. - "Readable" | "Writable" | "Duplex" | "Transform" - if is_genuine_node_stream_parent(ctx, &parent_name) => - { - Some(("node_stream".to_string(), canonical_parent_name.clone())) - } - _ => None, - }; - // A lexical local binding shadowing the parent name must win over the - // native/static parent — the in-scope local IS the real parent value. - // Check it BEFORE `native_parent` so e.g. `const EventEmitter = …; - // const C = class extends EventEmitter {}` routes through the dynamic - // `extends_expr` path (the local) instead of recording the native - // `events` parent. ESM imports are NOT in `ctx.locals`, so genuine - // `extends EventEmitter` (imported) still takes the native path. - let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) - && ctx.locals.lookup(&parent_name).is_some(); - if native_parent.is_some() && !locally_shadowed { - (None, Some(canonical_parent_name), native_parent, None) - } else if locally_shadowed { - // #5437 (Next.js p-queue `PQueue` inside a minified bundle): a - // class EXPRESSION whose parent Ident is an IN-SCOPE LOCAL - // (`const t = require("events"); … class extends t {…}`) must - // bind to that LEXICAL local — not to an unrelated module-global - // class that happens to share the (minified, single-letter) - // name. The static `lookup_class(parent_name)` path keys - // codegen's `super()` on a module-wide `HashMap`; - // in a turbopack chunk dozens of distinct webpack-factory - // classes are all named `t`/`u`/`i`, so that map keeps ONE `t` - // (whichever registered last) and `super()` inlines the WRONG - // class's constructor. The bundle's p-queue `PQueue extends t` - // (eventemitter3) resolved `t` to superstruct's `StructError` - // base, so `new PQueue()` ran StructError's destructuring ctor - // on the (undefined) options arg → "Cannot convert undefined or - // null to object" → HTTP 500 on the dynamic page routes. - // - // When the parent name is bound by a local in THIS body's scope, - // route through the dynamic `extends_expr` path: lower the Ident - // as a runtime value (the lexically-correct local), register the - // parent edge dynamically, and let `super()` invoke the real - // parent value via `js_fetch_or_value_super` (which already - // tolerates native / closure / class-ref / builtin parents). - // Gated on `!class_renames.contains_key` so the #5437 - // sibling-rename path above still wins when a scope-local class - // rename exists (that disambiguation is exact). Pure-Ident - // module-global heritage (no shadowing local) is unaffected — - // `ctx.locals.lookup` returns `None` for a class name. - // Do NOT set a static `extends` (parent_cid) OR `extends_name` - // here: the only candidate is `lookup_class(parent_name)`, the - // wrong same-named module-global class we deliberately avoid — and - // a retained `extends_name` is re-resolved back to it by the - // static parent-chain walks (layout / parent-edge / inherited- - // method / vtable / type-facts), corrupting the subclass. The - // dynamic `extends_expr` path registers the correct parent edge at - // runtime via `RegisterClassParentDynamic` + `function_class_id`. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, None, None, Some(Box::new(expr))), - Err(_) => (None, None, None, None), - } - } else { - // #5437: resolve the parent through active scope-local class - // renames so a class EXPRESSION extending a disambiguated - // same-named sibling (`f` -> `f$0`) binds to the right class. - // See the matching fix in `lower_class_decl` above. - let parent_name = ctx.resolve_class_name(&parent_name); - let parent_cid = ctx.lookup_class(&parent_name); - if parent_cid.is_none() { - // Issue #711 part 2: see the parallel arm in - // `lower_class_decl` above. Unknown Ident super-class - // falls through to extends_expr capture so a - // function-with-prototype value can be resolved at - // runtime via `function_class_id`. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (None, Some(parent_name), None, None), - } - } else { - (parent_cid, Some(parent_name), None, None) - } - } - } else if let ast::Expr::Member(member) = super_class.as_ref() { - // Refs #488 drizzle-sqlite: try cross-module class lookup. See - // the matching arm in `lower_class_decl` (above) for the full - // rationale — without this, the parent link is lost and - // inherited methods don't reach instances. - let parent_name = extract_member_class_name(member); - // Issue #4908: avoid a self-referential parent edge when the - // member's trailing property equals the subclass's own name - // (`class Agent extends http.Agent`). See the matching guard in - // `lower_class_decl` above — a self-link loops codegen's - // parent-chain walk forever. Leave the class parentless, matching - // the non-colliding native-member-base behavior. - if parent_name == name { - (None, None, None, None) - } else if parent_name == "default" { - // `class X extends _mod.default` — the interop ESM - // default-export-class pattern. Keep in lockstep with the - // matching `.default` arm in `lower_class_decl` above: route - // through `extends_expr` so `super()` re-evaluates the alias - // at construction time and the parent edge is registered. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (None, Some(parent_name), None, None), - } - } else { - // Named cross-module member-extends — route through `extends_expr` - // UNCONDITIONALLY so `super()` runs the parent ctor at runtime even - // when the parent isn't in codegen's class table / not yet lowered. - // Keep in lockstep with the matching arm in `lower_class_decl` - // (wall 48: NodeNextRequest extends _index.BaseNextRequest). - let resolved = ctx.lookup_class(&parent_name); - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (resolved, Some(parent_name), None, Some(Box::new(expr))), - Err(_) => (resolved, Some(parent_name), None, None), - } - } - } else { - // Issue #711: see the matching arm in `lower_class_decl` above - // for the full rationale. Capture the lowered extends - // expression so codegen can evaluate it at the class - // declaration site and call - // `js_register_class_parent_dynamic` at runtime. - match lower_class_heritage_expr(ctx, super_class) { - Ok(expr) => (None, None, None, Some(Box::new(expr))), - Err(_) => (None, None, None, None), - } - } - } else { - (None, None, None, None) - }; - - // Issue #10486: mirrors the capture-forwarding fallback in - // `lower_class_decl` above (see its comment for the full rationale) — - // a class EXPRESSION extending a lexically-local capture-bearing class - // EXPRESSION (`const Base = class {…}; const Sub = class extends Base - // {…}`) needs the alias-resolved heritage identifier for capture - // lookup even when `extends_name` was deliberately left None for - // class-registry resolution. - // See the matching guard in `lower_class_decl` above: skip the - // fallback when this class expression has its own explicit - // constructor (its `super(...)` already forwards correctly). - let has_own_constructor = class - .body - .iter() - .any(|m| matches!(m, ast::ClassMember::Constructor(_))); - let capture_parent_name: Option = extends_name.clone().or_else(|| { - if has_own_constructor { - return None; - } - class.super_class.as_deref().and_then(|sc| match sc { - ast::Expr::Ident(ident) => { - let raw = ident.sym.to_string(); - Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) - } - _ => None, - }) - }); - - let mut static_field_names = Vec::new(); - let mut static_method_names = Vec::new(); - for member in &class.body { - match member { - // See note above: static getters/setters are not callable methods. - ast::ClassMember::Method(method) - if method.is_static && matches!(method.kind, ast::MethodKind::Method) => - { - if let ast::PropName::Ident(ident) = &method.key { - static_method_names.push(ident.sym.to_string()); - } - } - ast::ClassMember::PrivateMethod(method) - if method.is_static && matches!(method.kind, ast::MethodKind::Method) => - { - static_method_names.push(format!("#{}", method.key.name)); - } - ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { - if let ast::PropName::Ident(ident) = &prop.key { - static_field_names.push(ident.sym.to_string()); - } - } - ast::ClassMember::PrivateProp(prop) if prop.is_static => { - static_field_names.push(format!("#{}", prop.key.name)); - } - _ => {} - } - } - ctx.register_class_statics(name.to_string(), static_field_names, static_method_names); - - let mut fields = Vec::new(); - let mut static_fields = Vec::new(); - let mut constructor = None; - let mut methods = Vec::new(); - let mut static_methods = Vec::new(); - let mut getters = Vec::new(); - let mut setters = Vec::new(); - // Parallel staticness, so `record_class_accessor` can tell a static - // accessor from an instance one with the same name. - let mut getter_statics: Vec = Vec::new(); - let mut setter_statics: Vec = Vec::new(); - let mut static_accessor_names: Vec = Vec::new(); - let mut static_accessor_fn_ids: Vec = Vec::new(); - let mut computed_members = Vec::new(); - let mut seen_generic_computed_member = false; - - for (member_index, member) in class.body.iter().enumerate() { - match member { - ast::ClassMember::Constructor(ctor) => { - constructor = Some(lower_constructor(ctx, name, ctor)?); - } - ast::ClassMember::Method(method) => { - // Skip TypeScript overload declarations (no body) - if method.function.body.is_none() { - continue; - } - if let Some(computed) = generic_computed_member_key(ctx, method) { - computed_members.push(lower_generic_computed_class_member( - ctx, - method, - computed, - member_index, - )?); - seen_generic_computed_member = true; - continue; - } - let (prop_name, can_source_order_register) = match &method.key { - ast::PropName::Ident(ident) => (ident.sym.to_string(), true), - ast::PropName::Str(s) => (s.value.as_str().unwrap_or("").to_string(), true), - // Numeric-literal member names — see the parallel arm in - // `lower_class_decl`. Canonical ToString of the value. - ast::PropName::Num(n) => (crate::lower::number_to_js_key(n.value), true), - // `[Symbol.iterator]() {}` / `*[Symbol.iterator]() {}` on a - // class *expression* — mirror the declaration path so - // `new (class { *[Symbol.iterator]() {…} })()` is iterable - // for spread, `Array.from`, destructuring, and manual - // `obj[Symbol.iterator]()` calls (#5128). The generator lift - // happens in the `Method` arm below. - ast::PropName::Computed(computed) if is_symbol_iterator_key(&computed.expr) => { - ("@@iterator".to_string(), false) - } - ast::PropName::Computed(computed) - if is_inspect_custom_key(ctx, &computed.expr) - && !method.is_static - && matches!(method.kind, ast::MethodKind::Method) => - { - // Refs #1248: see class_decl.rs Method handling above. - ("__perry_inspect_custom__".to_string(), false) - } - // Other well-known-symbol keys (`[Symbol.asyncIterator]`, - // `[Symbol.toPrimitive]`, `[Symbol.dispose]` / - // `[Symbol.asyncDispose]`, `static [Symbol.hasInstance]`, - // `get [Symbol.toStringTag]`) on a class *expression* — - // same handling as the declaration path, via the shared - // helper. Pre-fix these fell through `_ => continue` and - // were silently dropped, so e.g. `for await (… of new (C = - // class { [Symbol.asyncIterator]() {…} })())` threw - // `TypeError: value is not iterable`. - ast::PropName::Computed(_) => { - match lower_well_known_computed_method(ctx, method, name)? { - Some(WellKnownComputedMethod::Rename(renamed)) => (renamed, false), - Some( - WellKnownComputedMethod::Lifted - | WellKnownComputedMethod::Unsupported, - ) - | None => continue, - } - } - _ => continue, - }; - match method.kind { - ast::MethodKind::Getter => { - let func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_getter_method(ctx, method) - })?; - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut getters, - &mut getter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Setter => { - let func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_setter_method(ctx, method) - })?; - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut setters, - &mut setter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Method => { - let mut func = with_static_member_context(ctx, method.is_static, |ctx| { - lower_class_method(ctx, method) - })?; - // `*[Symbol.iterator]()` — lift to a top-level generator - // and register a synthetic `@@iterator` wrapper (#5128), - // exactly as the class-declaration path does above. - if prop_name == "@@iterator" && func.is_generator && !method.is_static { - let wrapper = synthesize_symbol_iterator_wrapper(ctx, name, &mut func); - let ast::PropName::Computed(computed) = &method.key else { - unreachable!("@@iterator generator key must be computed"); - }; - // The computed-symbol registration installs the - // runtime dispatch alias too. Registering the wrapper - // as a string method also exposed an own "@@iterator" - // property that the source never declared (#9788). - computed_members.push(ClassComputedMember { - key_expr: lower_expr(ctx, &computed.expr)?, - function: wrapper, - is_static: false, - kind: ClassComputedMemberKind::Method, - source_order: member_index, - }); - continue; - } - if seen_generic_computed_member && can_source_order_register { - computed_members.push(lower_noncomputed_class_member_registration( - ctx, - method, - &prop_name, - member_index, - )?); - } - if method.is_static { - static_methods.push(func); - } else { - methods.push(func); - } - } - } - } - ast::ClassMember::ClassProp(prop) => { - // `declare` and `abstract` fields are type-only: TypeScript - // erases them entirely (`node --experimental-strip-types` - // emits no runtime slot). Materializing an abstract base-class - // field creates a phantom slot that shadows the concrete - // subclass initializer of the same name — a base/union-typed - // read then resolves to the (undefined) base slot. Skip both. - if prop.declare || prop.is_abstract { - continue; - } - // Computed-key fields (`[Symbol.for("k")] = init`) flow through - // here for both instance AND static positions. - // `lower_class_prop` captures the key expression in - // `ClassField.key_expr` for runtime evaluation. Refs #420 — - // drizzle's `static [entityKind] = "Table"` is the canonical - // static-computed-key pattern; codegen's `init_static_fields` - // detects `key_expr.is_some()` and emits a runtime - // registration into the class-static-symbol side table. - let field = lower_class_prop(ctx, prop)?; - if prop.is_static { - static_fields.push(field); - } else { - fields.push(field); - } - } - ast::ClassMember::PrivateProp(prop) => { - let field = lower_private_prop(ctx, prop)?; - if prop.is_static { - static_fields.push(field); - } else { - fields.push(field); - } - } - ast::ClassMember::PrivateMethod(method) => { - if method.function.body.is_none() { - continue; - } - match method.kind { - ast::MethodKind::Method => { - let func = lower_private_method(ctx, method)?; - if method.is_static { - static_methods.push(func); - } else { - methods.push(func); - } - } - ast::MethodKind::Getter => { - let prop_name = format!("#{}", method.key.name); - let func = lower_private_getter(ctx, method)?; - // Static private accessor — register on the static - // side (see the matching arm in `lower_class_decl`). - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut getters, - &mut getter_statics, - prop_name, - func, - method.is_static, - ); - } - ast::MethodKind::Setter => { - let prop_name = format!("#{}", method.key.name); - let func = lower_private_setter(ctx, method)?; - if method.is_static { - static_accessor_names.push(prop_name.clone()); - static_accessor_fn_ids.push(func.id); - } - record_class_accessor( - &mut setters, - &mut setter_statics, - prop_name, - func, - method.is_static, - ); - } - } - } - ast::ClassMember::StaticBlock(block) => { - let scope_mark = ctx.enter_scope(); - let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; - ctx.in_nonarrow_fn = true; - // A static block is its own var-scope (OrdinaryFunctionCreate - // per ClassStaticBlockDefinitionEvaluation): `lower_block_stmt` - // only lowers nested statements without hoisting `var`s to this - // boundary, so a `var` declared in one block leaked into the - // next block/module scope instead of staying local (test262 - // static-init-scope-var-close.js). - let body = lower_fn_body_block_stmt(ctx, &block.body)?; - ctx.exit_scope(scope_mark); - ctx.in_nonarrow_fn = saved_in_nonarrow_fn; - - let block_idx = static_methods - .iter() - .filter(|m| m.name.starts_with("__perry_static_init_")) - .count(); - let synthetic_name = format!("__perry_static_init_{}", block_idx); - static_methods.push(Function { - id: ctx.fresh_func(), - name: synthetic_name, - type_params: Vec::new(), - params: Vec::new(), - return_type: Type::Void, - body, - is_async: false, - is_generator: false, - is_strict: true, - was_plain_async: false, - was_unrolled: false, - is_exported: false, - captures: Vec::new(), - decorators: Vec::new(), - }); - } - _ => {} - } - } - - // `this` in static field initializers — see the matching substitution in - // `lower_class_decl` above. - for sf in &mut static_fields { - if let Some(init) = &mut sf.init { - crate::analysis::substitute_lexical_this_in_expr( - init, - &Expr::ClassRef(name.to_string()), - ); - } - } - - ctx.exit_type_param_scope(); - // Issue #562: see the parallel site in `lower_class_decl` — register - // native_extends so subclass instances of the three Web Stream base - // classes route through the parent stream module's dispatch table. - if let Some((module, class)) = native_extends.as_ref() { - ctx.register_class_native_extends(name.to_string(), module.clone(), class.clone()); - } - ctx.current_class = old_class; - ctx.current_class_scope_depth = old_class_scope_depth; - ctx.current_class_inner_name = old_inner_name; - ctx.current_class_is_derived = old_is_derived; - ctx.pop_private_scope(); - // Issue #562: restore prior super-ident slot. - ctx.current_class_super_ident = old_super_ident; - - // Phase 4.1: register method + getter return types — see the parallel - // site in lower_class_decl. - for m in &methods { - if !matches!(m.return_type, Type::Any) { - ctx.register_class_method_return_type( - name.to_string(), - m.name.clone(), - m.return_type.clone(), - ); - } - } - for (prop_name, g) in &getters { - if !matches!(g.return_type, Type::Any) { - ctx.register_class_method_return_type( - name.to_string(), - prop_name.clone(), - g.return_type.clone(), - ); - } - } - - // Mirror `lower_class_decl`: register the union of this class's accessor - // names (own get/set, including private and the parent chain) so the - // assignment recogniser in `expr_assign.rs` treats `C.prototype. - // = v` as a setter INVOCATION instead of a prototype-method monkey-patch. - // `lower_class_decl` registers these for class declarations; without the - // parallel call here, a class EXPRESSION's instance setters (e.g. - // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently - // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. - { - let mut accessor_names = runtime_instance_accessor_names(&class.body); - if let Some(ref parent_name) = extends_name { - if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { - accessor_names.extend_from(parent_accessors); - } - } - ctx.register_class_accessor_names(name.to_string(), accessor_names); - } - - // Issue #740: synthesize __perry_cap_* capture machinery for class - // expressions that reference enclosing-fn locals (e.g. `const Inner = - // class { _tag = tag }` inside `function makeFactory(tag)`). Without - // this, anon class expressions silently dropped captures while named - // class declarations had the machinery via `lower_class_decl`. See - // the helper's doc comment for the full description. - synthesize_class_captures( - ctx, - name, - capture_parent_name.as_deref(), - extends.is_some() - || extends_name.is_some() - || native_extends.is_some() - || extends_expr.is_some(), - &mut fields, - &mut methods, - &mut getters, - &mut setters, - &mut computed_members, - &mut constructor, - &mut static_methods, - ); - - Ok(Class { - id: class_id, - name: name.to_string(), - type_params, - extends, - extends_name, - native_extends, - extends_expr, - heritage_lexically_shadowed, - fields, - constructor, - methods, - getters, - setters, - static_accessor_names, - static_accessor_fn_ids, - static_fields, - static_methods, - computed_members, - decorators: lower_decorators(ctx, &class.decorators), - is_exported, - aliases: Vec::new(), - // Declared inside a function body / non-module block → its static-field - // initializers must run on class evaluation, not at module init. - is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, - alloc_width_hint: 0, - specialized_from: None, - }) -} diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs new file mode 100644 index 0000000000..33092d56a4 --- /dev/null +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -0,0 +1,726 @@ +//! `lower_class_from_ast` — lowering a class EXPRESSION (as opposed to a +//! class declaration statement, handled in `class_decl.rs` proper) to HIR. +//! Split out of `class_decl.rs` to keep it under the 2000-line file gate. +//! Behaviour is unchanged; `use super::*` reaches the shared imports. + +use super::*; + +/// Lower a class expression (ast::Class) to HIR. +/// Used for anonymous class expressions like `new (class extends Command { ... })()`. +pub(crate) fn lower_class_from_ast( + ctx: &mut LoweringContext, + class: &ast::Class, + name: &str, + is_exported: bool, +) -> Result { + validate_legacy_decorator_surface(class, name)?; + validate_class_element_early_errors(class, name)?; + let class_id = match ctx.lookup_class(name) { + Some(id) => id, + None => { + let id = ctx.fresh_class(); + ctx.register_class(name.to_string(), id); + id + } + }; + capture_class_source(ctx, class_id, class); + + let old_class = ctx.current_class.take(); + ctx.current_class = Some(name.to_string()); + let old_class_scope_depth = ctx.current_class_scope_depth.replace(ctx.scope_depth); + let old_inner_name = ctx.current_class_inner_name.take(); + // A class-expression caller stashes the source ident here; fall back + // to the (possibly synthetic) registration name when absent. + let explicit_inner_name = ctx.pending_class_inner_name.take(); + ctx.current_class_inner_name = explicit_inner_name + .clone() + .or_else(|| Some(name.to_string())); + let old_is_derived = ctx.current_class_is_derived; + ctx.current_class_is_derived = class.super_class.is_some(); + + // Private-name scope for this class-expression body (see lower_class_decl). + ctx.push_private_scope(super::build_private_scope(class, name, class_id)); + + // Issue #562: same as the parallel `lower_class_decl` arm — track the + // parent class identifier so super({...}) controller-param pre-scan + // fires for stream subclasses. + let old_super_ident = ctx.current_class_super_ident.take(); + ctx.current_class_super_ident = match class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), + _ => None, + }; + + let type_params = class + .type_params + .as_ref() + .map(|tp| extract_type_params(tp)) + .unwrap_or_default(); + + ctx.enter_type_param_scope(&type_params); + + // #5437: parent Ident shadowed by an in-scope lexical local? (See the + // matching computation in `lower_class_decl`.) Lets codegen prefer the + // dynamic local over a NAME-keyed built-in special case. + let heritage_lexically_shadowed = match class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => { + let n = ident.sym.to_string(); + !ctx.class_renames.contains_key(&n) && ctx.locals.lookup(&n).is_some() + } + _ => false, + }; + + let (extends, extends_name, native_extends, extends_expr) = if let Some(ref super_class) = + class.super_class + { + if explicit_inner_name + .as_deref() + .is_some_and(|inner| is_class_self_heritage(super_class, inner)) + { + ( + None, + None, + None, + Some(Box::new(crate::lower::throw_reference_error_expr( + "js_throw_reference_error_this_before_super", + ))), + ) + } else if let ast::Expr::Ident(ident) = super_class.as_ref() { + let parent_name = ident.sym.to_string(); + let canonical_parent_name = canonical_native_parent_name(ctx, &parent_name) + .unwrap_or(&parent_name) + .to_string(); + let native_parent = match canonical_parent_name.as_str() { + "EventEmitter" => Some(("events".to_string(), "EventEmitter".to_string())), + "EventEmitterAsyncResource" => Some(( + "events".to_string(), + "EventEmitterAsyncResource".to_string(), + )), + "AsyncLocalStorage" => { + Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) + } + "AsyncResource" => Some(("async_hooks".to_string(), "AsyncResource".to_string())), + "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), + // #10293: lru-cache's LRUCache is a compile-time lowering with + // no runtime value; recognising it here routes `extends` to the + // subclass-init path instead of the dynamic parent registration + // that throws "Class extends value is not a constructor". + "LRUCache" => Some(("lru-cache".to_string(), "LRUCache".to_string())), + // Issue #562: keep in lockstep with the parallel arm in + // `lower_class_decl` above. + "ReadableStream" => { + Some(("readable_stream".to_string(), "ReadableStream".to_string())) + } + "WritableStream" => { + Some(("writable_stream".to_string(), "WritableStream".to_string())) + } + "TransformStream" => Some(( + "transform_stream".to_string(), + "TransformStream".to_string(), + )), + // #1545: classic node:stream base classes — keep in lockstep + // with the parallel arm in `lower_class_decl` above. Gated on + // `is_genuine_node_stream_parent` so a userland stream-shim + // binding (readable-stream's `Transform`) falls through to the + // dynamic `extends_expr` parent path. + "Readable" | "Writable" | "Duplex" | "Transform" + if is_genuine_node_stream_parent(ctx, &parent_name) => + { + Some(("node_stream".to_string(), canonical_parent_name.clone())) + } + _ => None, + }; + // A lexical local binding shadowing the parent name must win over the + // native/static parent — the in-scope local IS the real parent value. + // Check it BEFORE `native_parent` so e.g. `const EventEmitter = …; + // const C = class extends EventEmitter {}` routes through the dynamic + // `extends_expr` path (the local) instead of recording the native + // `events` parent. ESM imports are NOT in `ctx.locals`, so genuine + // `extends EventEmitter` (imported) still takes the native path. + // + // #10623: same CJS-wrapper carve-out as the class-declaration arm + // above — see its comment for the full rationale. + let require_native_reexport = ctx + .require_destructured_native_locals + .get(&parent_name) + .is_some_and(|key| *key == canonical_parent_name); + let locally_shadowed = !ctx.class_renames.contains_key(&parent_name) + && ctx.locals.lookup(&parent_name).is_some() + && !require_native_reexport; + if native_parent.is_some() && !locally_shadowed { + (None, Some(canonical_parent_name), native_parent, None) + } else if locally_shadowed { + // #5437 (Next.js p-queue `PQueue` inside a minified bundle): a + // class EXPRESSION whose parent Ident is an IN-SCOPE LOCAL + // (`const t = require("events"); … class extends t {…}`) must + // bind to that LEXICAL local — not to an unrelated module-global + // class that happens to share the (minified, single-letter) + // name. The static `lookup_class(parent_name)` path keys + // codegen's `super()` on a module-wide `HashMap`; + // in a turbopack chunk dozens of distinct webpack-factory + // classes are all named `t`/`u`/`i`, so that map keeps ONE `t` + // (whichever registered last) and `super()` inlines the WRONG + // class's constructor. The bundle's p-queue `PQueue extends t` + // (eventemitter3) resolved `t` to superstruct's `StructError` + // base, so `new PQueue()` ran StructError's destructuring ctor + // on the (undefined) options arg → "Cannot convert undefined or + // null to object" → HTTP 500 on the dynamic page routes. + // + // When the parent name is bound by a local in THIS body's scope, + // route through the dynamic `extends_expr` path: lower the Ident + // as a runtime value (the lexically-correct local), register the + // parent edge dynamically, and let `super()` invoke the real + // parent value via `js_fetch_or_value_super` (which already + // tolerates native / closure / class-ref / builtin parents). + // Gated on `!class_renames.contains_key` so the #5437 + // sibling-rename path above still wins when a scope-local class + // rename exists (that disambiguation is exact). Pure-Ident + // module-global heritage (no shadowing local) is unaffected — + // `ctx.locals.lookup` returns `None` for a class name. + // Do NOT set a static `extends` (parent_cid) OR `extends_name` + // here: the only candidate is `lookup_class(parent_name)`, the + // wrong same-named module-global class we deliberately avoid — and + // a retained `extends_name` is re-resolved back to it by the + // static parent-chain walks (layout / parent-edge / inherited- + // method / vtable / type-facts), corrupting the subclass. The + // dynamic `extends_expr` path registers the correct parent edge at + // runtime via `RegisterClassParentDynamic` + `function_class_id`. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, None, None, Some(Box::new(expr))), + Err(_) => (None, None, None, None), + } + } else { + // #5437: resolve the parent through active scope-local class + // renames so a class EXPRESSION extending a disambiguated + // same-named sibling (`f` -> `f$0`) binds to the right class. + // See the matching fix in `lower_class_decl` above. + let parent_name = ctx.resolve_class_name(&parent_name); + let parent_cid = ctx.lookup_class(&parent_name); + if parent_cid.is_none() { + // Issue #711 part 2: see the parallel arm in + // `lower_class_decl` above. Unknown Ident super-class + // falls through to extends_expr capture so a + // function-with-prototype value can be resolved at + // runtime via `function_class_id`. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } + } else { + (parent_cid, Some(parent_name), None, None) + } + } + } else if let ast::Expr::Member(member) = super_class.as_ref() { + // Refs #488 drizzle-sqlite: try cross-module class lookup. See + // the matching arm in `lower_class_decl` (above) for the full + // rationale — without this, the parent link is lost and + // inherited methods don't reach instances. + let parent_name = extract_member_class_name(member); + // Issue #4908: avoid a self-referential parent edge when the + // member's trailing property equals the subclass's own name + // (`class Agent extends http.Agent`). See the matching guard in + // `lower_class_decl` above — a self-link loops codegen's + // parent-chain walk forever. Leave the class parentless, matching + // the non-colliding native-member-base behavior. + if parent_name == name { + (None, None, None, None) + } else if parent_name == "default" { + // `class X extends _mod.default` — the interop ESM + // default-export-class pattern. Keep in lockstep with the + // matching `.default` arm in `lower_class_decl` above: route + // through `extends_expr` so `super()` re-evaluates the alias + // at construction time and the parent edge is registered. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } + } else { + // Named cross-module member-extends — route through `extends_expr` + // UNCONDITIONALLY so `super()` runs the parent ctor at runtime even + // when the parent isn't in codegen's class table / not yet lowered. + // Keep in lockstep with the matching arm in `lower_class_decl` + // (wall 48: NodeNextRequest extends _index.BaseNextRequest). + let resolved = ctx.lookup_class(&parent_name); + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (resolved, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (resolved, Some(parent_name), None, None), + } + } + } else { + // Issue #711: see the matching arm in `lower_class_decl` above + // for the full rationale. Capture the lowered extends + // expression so codegen can evaluate it at the class + // declaration site and call + // `js_register_class_parent_dynamic` at runtime. + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (None, None, None, Some(Box::new(expr))), + Err(_) => (None, None, None, None), + } + } + } else { + (None, None, None, None) + }; + + // Issue #10486: mirrors the capture-forwarding fallback in + // `lower_class_decl` above (see its comment for the full rationale) — + // a class EXPRESSION extending a lexically-local capture-bearing class + // EXPRESSION (`const Base = class {…}; const Sub = class extends Base + // {…}`) needs the alias-resolved heritage identifier for capture + // lookup even when `extends_name` was deliberately left None for + // class-registry resolution. + // See the matching guard in `lower_class_decl` above: skip the + // fallback when this class expression has its own explicit + // constructor (its `super(...)` already forwards correctly). + let has_own_constructor = class + .body + .iter() + .any(|m| matches!(m, ast::ClassMember::Constructor(_))); + let capture_parent_name: Option = extends_name.clone().or_else(|| { + if has_own_constructor { + return None; + } + class.super_class.as_deref().and_then(|sc| match sc { + ast::Expr::Ident(ident) => { + let raw = ident.sym.to_string(); + Some(ctx.resolve_class_alias(&raw).unwrap_or(raw)) + } + _ => None, + }) + }); + + let mut static_field_names = Vec::new(); + let mut static_method_names = Vec::new(); + for member in &class.body { + match member { + // See note above: static getters/setters are not callable methods. + ast::ClassMember::Method(method) + if method.is_static && matches!(method.kind, ast::MethodKind::Method) => + { + if let ast::PropName::Ident(ident) = &method.key { + static_method_names.push(ident.sym.to_string()); + } + } + ast::ClassMember::PrivateMethod(method) + if method.is_static && matches!(method.kind, ast::MethodKind::Method) => + { + static_method_names.push(format!("#{}", method.key.name)); + } + ast::ClassMember::ClassProp(prop) if prop.is_static && !prop.declare => { + if let ast::PropName::Ident(ident) = &prop.key { + static_field_names.push(ident.sym.to_string()); + } + } + ast::ClassMember::PrivateProp(prop) if prop.is_static => { + static_field_names.push(format!("#{}", prop.key.name)); + } + _ => {} + } + } + ctx.register_class_statics(name.to_string(), static_field_names, static_method_names); + + let mut fields = Vec::new(); + let mut static_fields = Vec::new(); + let mut constructor = None; + let mut methods = Vec::new(); + let mut static_methods = Vec::new(); + let mut getters = Vec::new(); + let mut setters = Vec::new(); + // Parallel staticness, so `record_class_accessor` can tell a static + // accessor from an instance one with the same name. + let mut getter_statics: Vec = Vec::new(); + let mut setter_statics: Vec = Vec::new(); + let mut static_accessor_names: Vec = Vec::new(); + let mut static_accessor_fn_ids: Vec = Vec::new(); + let mut computed_members = Vec::new(); + let mut seen_generic_computed_member = false; + + for (member_index, member) in class.body.iter().enumerate() { + match member { + ast::ClassMember::Constructor(ctor) => { + constructor = Some(lower_constructor(ctx, name, ctor)?); + } + ast::ClassMember::Method(method) => { + // Skip TypeScript overload declarations (no body) + if method.function.body.is_none() { + continue; + } + if let Some(computed) = generic_computed_member_key(ctx, method) { + computed_members.push(lower_generic_computed_class_member( + ctx, + method, + computed, + member_index, + )?); + seen_generic_computed_member = true; + continue; + } + let (prop_name, can_source_order_register) = match &method.key { + ast::PropName::Ident(ident) => (ident.sym.to_string(), true), + ast::PropName::Str(s) => (s.value.as_str().unwrap_or("").to_string(), true), + // Numeric-literal member names — see the parallel arm in + // `lower_class_decl`. Canonical ToString of the value. + ast::PropName::Num(n) => (crate::lower::number_to_js_key(n.value), true), + // `[Symbol.iterator]() {}` / `*[Symbol.iterator]() {}` on a + // class *expression* — mirror the declaration path so + // `new (class { *[Symbol.iterator]() {…} })()` is iterable + // for spread, `Array.from`, destructuring, and manual + // `obj[Symbol.iterator]()` calls (#5128). The generator lift + // happens in the `Method` arm below. + ast::PropName::Computed(computed) if is_symbol_iterator_key(&computed.expr) => { + ("@@iterator".to_string(), false) + } + ast::PropName::Computed(computed) + if is_inspect_custom_key(ctx, &computed.expr) + && !method.is_static + && matches!(method.kind, ast::MethodKind::Method) => + { + // Refs #1248: see class_decl.rs Method handling above. + ("__perry_inspect_custom__".to_string(), false) + } + // Other well-known-symbol keys (`[Symbol.asyncIterator]`, + // `[Symbol.toPrimitive]`, `[Symbol.dispose]` / + // `[Symbol.asyncDispose]`, `static [Symbol.hasInstance]`, + // `get [Symbol.toStringTag]`) on a class *expression* — + // same handling as the declaration path, via the shared + // helper. Pre-fix these fell through `_ => continue` and + // were silently dropped, so e.g. `for await (… of new (C = + // class { [Symbol.asyncIterator]() {…} })())` threw + // `TypeError: value is not iterable`. + ast::PropName::Computed(_) => { + match lower_well_known_computed_method(ctx, method, name)? { + Some(WellKnownComputedMethod::Rename(renamed)) => (renamed, false), + Some( + WellKnownComputedMethod::Lifted + | WellKnownComputedMethod::Unsupported, + ) + | None => continue, + } + } + _ => continue, + }; + match method.kind { + ast::MethodKind::Getter => { + let func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_getter_method(ctx, method) + })?; + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut getters, + &mut getter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Setter => { + let func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_setter_method(ctx, method) + })?; + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut setters, + &mut setter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Method => { + let mut func = with_static_member_context(ctx, method.is_static, |ctx| { + lower_class_method(ctx, method) + })?; + // `*[Symbol.iterator]()` — lift to a top-level generator + // and register a synthetic `@@iterator` wrapper (#5128), + // exactly as the class-declaration path does above. + if prop_name == "@@iterator" && func.is_generator && !method.is_static { + let wrapper = synthesize_symbol_iterator_wrapper(ctx, name, &mut func); + let ast::PropName::Computed(computed) = &method.key else { + unreachable!("@@iterator generator key must be computed"); + }; + // The computed-symbol registration installs the + // runtime dispatch alias too. Registering the wrapper + // as a string method also exposed an own "@@iterator" + // property that the source never declared (#9788). + computed_members.push(ClassComputedMember { + key_expr: lower_expr(ctx, &computed.expr)?, + function: wrapper, + is_static: false, + kind: ClassComputedMemberKind::Method, + source_order: member_index, + }); + continue; + } + if seen_generic_computed_member && can_source_order_register { + computed_members.push(lower_noncomputed_class_member_registration( + ctx, + method, + &prop_name, + member_index, + )?); + } + if method.is_static { + static_methods.push(func); + } else { + methods.push(func); + } + } + } + } + ast::ClassMember::ClassProp(prop) => { + // `declare` and `abstract` fields are type-only: TypeScript + // erases them entirely (`node --experimental-strip-types` + // emits no runtime slot). Materializing an abstract base-class + // field creates a phantom slot that shadows the concrete + // subclass initializer of the same name — a base/union-typed + // read then resolves to the (undefined) base slot. Skip both. + if prop.declare || prop.is_abstract { + continue; + } + // Computed-key fields (`[Symbol.for("k")] = init`) flow through + // here for both instance AND static positions. + // `lower_class_prop` captures the key expression in + // `ClassField.key_expr` for runtime evaluation. Refs #420 — + // drizzle's `static [entityKind] = "Table"` is the canonical + // static-computed-key pattern; codegen's `init_static_fields` + // detects `key_expr.is_some()` and emits a runtime + // registration into the class-static-symbol side table. + let field = lower_class_prop(ctx, prop)?; + if prop.is_static { + static_fields.push(field); + } else { + fields.push(field); + } + } + ast::ClassMember::PrivateProp(prop) => { + let field = lower_private_prop(ctx, prop)?; + if prop.is_static { + static_fields.push(field); + } else { + fields.push(field); + } + } + ast::ClassMember::PrivateMethod(method) => { + if method.function.body.is_none() { + continue; + } + match method.kind { + ast::MethodKind::Method => { + let func = lower_private_method(ctx, method)?; + if method.is_static { + static_methods.push(func); + } else { + methods.push(func); + } + } + ast::MethodKind::Getter => { + let prop_name = format!("#{}", method.key.name); + let func = lower_private_getter(ctx, method)?; + // Static private accessor — register on the static + // side (see the matching arm in `lower_class_decl`). + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut getters, + &mut getter_statics, + prop_name, + func, + method.is_static, + ); + } + ast::MethodKind::Setter => { + let prop_name = format!("#{}", method.key.name); + let func = lower_private_setter(ctx, method)?; + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } + record_class_accessor( + &mut setters, + &mut setter_statics, + prop_name, + func, + method.is_static, + ); + } + } + } + ast::ClassMember::StaticBlock(block) => { + let scope_mark = ctx.enter_scope(); + let saved_in_nonarrow_fn = ctx.in_nonarrow_fn; + ctx.in_nonarrow_fn = true; + // A static block is its own var-scope (OrdinaryFunctionCreate + // per ClassStaticBlockDefinitionEvaluation): `lower_block_stmt` + // only lowers nested statements without hoisting `var`s to this + // boundary, so a `var` declared in one block leaked into the + // next block/module scope instead of staying local (test262 + // static-init-scope-var-close.js). + let body = lower_fn_body_block_stmt(ctx, &block.body)?; + ctx.exit_scope(scope_mark); + ctx.in_nonarrow_fn = saved_in_nonarrow_fn; + + let block_idx = static_methods + .iter() + .filter(|m| m.name.starts_with("__perry_static_init_")) + .count(); + let synthetic_name = format!("__perry_static_init_{}", block_idx); + static_methods.push(Function { + id: ctx.fresh_func(), + name: synthetic_name, + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }); + } + _ => {} + } + } + + // `this` in static field initializers — see the matching substitution in + // `lower_class_decl` above. + for sf in &mut static_fields { + if let Some(init) = &mut sf.init { + crate::analysis::substitute_lexical_this_in_expr( + init, + &Expr::ClassRef(name.to_string()), + ); + } + } + + ctx.exit_type_param_scope(); + // Issue #562: see the parallel site in `lower_class_decl` — register + // native_extends so subclass instances of the three Web Stream base + // classes route through the parent stream module's dispatch table. + if let Some((module, class)) = native_extends.as_ref() { + ctx.register_class_native_extends(name.to_string(), module.clone(), class.clone()); + } + ctx.current_class = old_class; + ctx.current_class_scope_depth = old_class_scope_depth; + ctx.current_class_inner_name = old_inner_name; + ctx.current_class_is_derived = old_is_derived; + ctx.pop_private_scope(); + // Issue #562: restore prior super-ident slot. + ctx.current_class_super_ident = old_super_ident; + + // Phase 4.1: register method + getter return types — see the parallel + // site in lower_class_decl. + for m in &methods { + if !matches!(m.return_type, Type::Any) { + ctx.register_class_method_return_type( + name.to_string(), + m.name.clone(), + m.return_type.clone(), + ); + } + } + for (prop_name, g) in &getters { + if !matches!(g.return_type, Type::Any) { + ctx.register_class_method_return_type( + name.to_string(), + prop_name.clone(), + g.return_type.clone(), + ); + } + } + + // Mirror `lower_class_decl`: register the union of this class's accessor + // names (own get/set, including private and the parent chain) so the + // assignment recogniser in `expr_assign.rs` treats `C.prototype. + // = v` as a setter INVOCATION instead of a prototype-method monkey-patch. + // `lower_class_decl` registers these for class declarations; without the + // parallel call here, a class EXPRESSION's instance setters (e.g. + // `var C = class { set ''(p){…} }; C.prototype[''] = v`) were silently + // dropped to `RegisterPrototypeMethod`. Test262 accessor-name-inst setters. + { + let mut accessor_names = runtime_instance_accessor_names(&class.body); + if let Some(ref parent_name) = extends_name { + if let Some(parent_accessors) = ctx.lookup_class_accessor_names(parent_name) { + accessor_names.extend_from(parent_accessors); + } + } + ctx.register_class_accessor_names(name.to_string(), accessor_names); + } + + // Issue #740: synthesize __perry_cap_* capture machinery for class + // expressions that reference enclosing-fn locals (e.g. `const Inner = + // class { _tag = tag }` inside `function makeFactory(tag)`). Without + // this, anon class expressions silently dropped captures while named + // class declarations had the machinery via `lower_class_decl`. See + // the helper's doc comment for the full description. + synthesize_class_captures( + ctx, + name, + capture_parent_name.as_deref(), + extends.is_some() + || extends_name.is_some() + || native_extends.is_some() + || extends_expr.is_some(), + &mut fields, + &mut methods, + &mut getters, + &mut setters, + &mut computed_members, + &mut constructor, + &mut static_methods, + ); + + Ok(Class { + id: class_id, + name: name.to_string(), + type_params, + extends, + extends_name, + native_extends, + extends_expr, + heritage_lexically_shadowed, + fields, + constructor, + methods, + getters, + setters, + static_accessor_names, + static_accessor_fn_ids, + static_fields, + static_methods, + computed_members, + decorators: lower_decorators(ctx, &class.decorators), + is_exported, + aliases: Vec::new(), + // Declared inside a function body / non-module block → its static-field + // initializers must run on class evaluation, not at module init. + is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, + alloc_width_hint: 0, + specialized_from: None, + }) +} diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index b7f9e4bd39..ab3b35cb8e 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1403,6 +1403,28 @@ pub(crate) fn pre_scan_node_http_client_request_socket_params( /// the hardcoded library-name mapping — without that gate `class Big { f0=0; } /// const b = new Big(); b.f0` returned 0 because the value was routed through /// big.js's handle-based dispatch. +/// +/// #10439: those two "is it a local class" checks are not the only way this +/// name can mean something other than the native handle. `Big`/`Decimal`/ +/// `BigNumber`/`LRUCache`/`Command` are exactly the names commander, +/// lru-cache, decimal.js and big.js/bignumber.js export themselves, so an +/// import of the REAL package — resolved to real source because the user +/// listed it in `perry.compilePackages` — hits this same match arm with +/// nothing local to shadow it. Chasing the fix-lineage precedent (#10589/ +/// #10608 for an imported plain-function ctor, #10623/#10636 for a +/// require()-destructured native base): decide by what the identifier +/// resolves to, not by its spelling. `is_native_module` (consulted when this +/// module's imports were lowered) already returns `false` for a +/// compilePackages-compiled specifier, so a genuinely compiled `Decimal`/ +/// `Command`/`LRUCache` was never handed to `register_native_module`, and +/// `lookup_native_module` reports that honestly — the same positive-evidence +/// discipline `ident_may_start_native_method_call` and +/// `native_class_from_factory_call` already apply for the sibling shapes +/// just below in `expr_call/static_and_instance.rs`. A name with no native +/// import at all (a bare same-named user function, or an import of an +/// unrelated module) is rejected for the same reason: genuine Big / Decimal / +/// BigNumber / LRUCache / Command usage is always reached through an import +/// of the real package. pub(crate) fn detect_native_instance_expr( ctx: &LoweringContext, expr: &ast::Expr, @@ -1417,12 +1439,16 @@ pub(crate) fn detect_native_instance_expr( { return None; } - match class_name { - "Big" => Some("big.js"), - "Decimal" => Some("decimal.js"), - "BigNumber" => Some("bignumber.js"), - "LRUCache" => Some("lru-cache"), - "Command" => Some("commander"), + let module = match class_name { + "Big" => "big.js", + "Decimal" => "decimal.js", + "BigNumber" => "bignumber.js", + "LRUCache" => "lru-cache", + "Command" => "commander", + _ => return None, + }; + match ctx.lookup_native_module(class_name) { + Some((m, _)) if m == module => Some(module), _ => None, } } else { diff --git a/crates/perry-hir/tests/fluent_chain_lowering.rs b/crates/perry-hir/tests/fluent_chain_lowering.rs index cd3d9923c4..facc07e529 100644 --- a/crates/perry-hir/tests/fluent_chain_lowering.rs +++ b/crates/perry-hir/tests/fluent_chain_lowering.rs @@ -107,23 +107,23 @@ fn uppercase_imported_builder_chain_stays_generic() { ); } -#[test] -fn native_fluent_chain_still_dispatches_through_native_methods() { - let module = lower_result( - r#" - export const out = new Decimal(1).plus(2).times(3).toString(); - "#, - ) - .expect("native fluent chain should lower"); - let debug = format!("{module:#?}"); - assert!( - debug.contains("module: \"decimal.js\""), - "Decimal chain should dispatch through decimal.js native methods: {debug}" - ); - for method in ["plus", "times", "toString"] { - assert!( - debug.contains(&format!("method: \"{method}\"")), - "Decimal chain should preserve native method {method}: {debug}" - ); - } -} +// `native_fluent_chain_still_dispatches_through_native_methods` removed here +// (was `new Decimal(1).plus(2).times(3).toString()`, no import). +// +// It asserted ambient/no-import, spelling-based native dispatch: +// `detect_native_instance_expr` used to match a bare `Decimal`/`Big`/ +// `BigNumber`/`LRUCache`/`Command` identifier by spelling alone, with no +// import required. This commit tightens that (the #10439 fix this PR makes) +// to require `ctx.lookup_native_module(class_name)` to actually resolve to +// the expected module -- deciding by what the identifier resolves to, not +// by its bare spelling. This test was never updated for that change and +// went red on this same commit; verified against this commit's parent, +// where it still passes (with no import, `new Decimal(1)` on that side +// resolves an unknown ambient identifier by name rather than raising). +// +// With no import, `new Decimal(1)` (or `Command`/`LRUCache`/...) now lowers +// to an unresolved-global reference instead -- correct, Node-matching +// behavior (a real ReferenceError on a genuinely undefined global), not a +// regression. Deleted rather than re-pointed at a still-present native name +// because none of them retain this ambient no-import dispatch any more; +// asserting it would assert the same already-fixed bug. diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 69d15f0ace..e23aed493c 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -86,8 +86,9 @@ pub(crate) fn array_iteration_is_exotic(arr: *const ArrayHeader) -> bool { if arr.is_null() { return false; } - if crate::buffer::is_registered_buffer(arr as usize) - || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + if super::header::receiver_may_be_registered_exotic(arr as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some()) { return true; } @@ -119,8 +120,9 @@ pub(crate) unsafe fn array_iteration_is_exotic_cleaned( arr: *const ArrayHeader, flags: u16, ) -> bool { - if crate::buffer::is_registered_buffer(arr as usize) - || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + if super::header::receiver_may_be_registered_exotic(arr as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some()) { return true; } @@ -606,18 +608,30 @@ pub extern "C" fn js_array_get_f64(arr: *const ArrayHeader, index: u32) -> f64 { return f64::NAN; } let arr = cleaned; - // Check if this is actually a TypedArray — dispatch through typed array helper - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - return crate::typedarray::js_typed_array_get( - arr as *const crate::typedarray::TypedArrayHeader, - index as i32, - ); - } - // Check if this is actually a buffer (Uint8Array) — read individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - let byte_val = - crate::buffer::js_buffer_get(arr as *const crate::buffer::BufferHeader, index as i32); - return byte_val as f64; + // #10694: a `GC_TYPE_ARRAY` header can never be a registered buffer or + // typed array — every registration carries its own GC object type — so a + // plain array must not pay the thread-local registry probes. The + // iteration helpers already gate on this; the indexing path did not, and + // on a `tsc --noEmit` of a two-line file that cost **79.7 M** + // `is_registered_buffer` probes for a process that registers **9** + // buffers, hitting 90 times. One already-warm GC-header byte read and an + // integer compare replace them. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a TypedArray — dispatch through typed array helper + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + return crate::typedarray::js_typed_array_get( + arr as *const crate::typedarray::TypedArrayHeader, + index as i32, + ); + } + // Check if this is actually a buffer (Uint8Array) — read individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + let byte_val = crate::buffer::js_buffer_get( + arr as *const crate::buffer::BufferHeader, + index as i32, + ); + return byte_val as f64; + } } // The usual case cleans to the same address, so reuse the header tag read // above. A forwarded Array resolves to a different address and needs its @@ -794,23 +808,33 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 if arr.is_null() { return; } - // Check if this is actually a buffer (Uint8Array) — write individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - crate::buffer::js_buffer_set( - arr as *mut crate::buffer::BufferHeader, - index as i32, - value as i32, - ); - return; - } - // Check if this is a typed array — route through per-kind store. - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - crate::typedarray::js_typed_array_set( - arr as *mut crate::typedarray::TypedArrayHeader, - index as i32, - value, - ); - return; + // #10694: a `GC_TYPE_ARRAY` header can never be a registered buffer or + // typed array — every registration carries its own GC object type — so a + // plain array must not pay the thread-local registry probes. The + // iteration helpers already gate on this; the indexing path did not, and + // on a `tsc --noEmit` of a two-line file that cost **79.7 M** + // `is_registered_buffer` probes for a process that registers **9** + // buffers, hitting 90 times. One already-warm GC-header byte read and an + // integer compare replace them. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a buffer (Uint8Array) — write individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + crate::buffer::js_buffer_set( + arr as *mut crate::buffer::BufferHeader, + index as i32, + value as i32, + ); + return; + } + // Check if this is a typed array — route through per-kind store. + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + crate::typedarray::js_typed_array_set( + arr as *mut crate::typedarray::TypedArrayHeader, + index as i32, + value, + ); + return; + } } // SAFETY: the clean above resolved this exact plain-array head; the // Buffer/TypedArray exits precede this direct header read. @@ -857,8 +881,9 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) { let clean = clean_arr_ptr_mut(arr); if clean.is_null() - || crate::buffer::is_registered_buffer(clean as usize) - || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some() + || (super::header::receiver_may_be_registered_exotic(clean as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(clean as usize) + || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some())) { return; } @@ -1236,8 +1261,9 @@ fn js_array_set_f64_extend_strict_impl( } let clean = clean_arr_ptr_mut(arr); if clean.is_null() - || crate::buffer::is_registered_buffer(clean as usize) - || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some() + || (super::header::receiver_may_be_registered_exotic(clean as *const ArrayHeader) + && (crate::buffer::is_registered_buffer(clean as usize) + || crate::typedarray::lookup_typed_array_kind(clean as usize).is_some())) { // Preserve the existing polymorphic/subclass behavior on receivers // that are not live plain arrays. These are cold and cannot use the @@ -1463,23 +1489,27 @@ pub extern "C" fn js_array_set_f64_extend( return js_array_alloc(0); } let arr = cleaned; - // Check if this is actually a buffer (Uint8Array) — write individual bytes - if crate::buffer::is_registered_buffer(arr as usize) { - crate::buffer::js_buffer_set( - arr as *mut crate::buffer::BufferHeader, - index as i32, - value as i32, - ); - return arr; - } - // Check if this is a typed array — route through per-kind store (no extension). - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { - crate::typedarray::js_typed_array_set( - arr as *mut crate::typedarray::TypedArrayHeader, - index as i32, - value, - ); - return arr; + // #10694: skip both registry probes for a `GC_TYPE_ARRAY` header, which + // can never be a registered buffer or typed array. + if super::header::receiver_may_be_registered_exotic(arr) { + // Check if this is actually a buffer (Uint8Array) — write individual bytes + if crate::buffer::is_registered_buffer(arr as usize) { + crate::buffer::js_buffer_set( + arr as *mut crate::buffer::BufferHeader, + index as i32, + value as i32, + ); + return arr; + } + // Check if this is a typed array — route through per-kind store (no extension). + if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + crate::typedarray::js_typed_array_set( + arr as *mut crate::typedarray::TypedArrayHeader, + index as i32, + value, + ); + return arr; + } } // SAFETY: the clean above resolved this live plain-array head, and the // compatible Buffer/TypedArray receivers have exited. diff --git a/crates/perry-runtime/src/regex/perex_replace_direct.rs b/crates/perry-runtime/src/regex/perex_replace_direct.rs index dd715048ff..4dafcee987 100644 --- a/crates/perry-runtime/src/regex/perex_replace_direct.rs +++ b/crates/perry-runtime/src/regex/perex_replace_direct.rs @@ -74,6 +74,16 @@ pub(super) fn admissible(receiver: &RuntimeHandle<'_>, reuse: &Reuse<'_, '_>) -> && reuse.name_count(re) == Some(0) } +/// One collection-loop round in this many runs the GC safepoint poll. +/// +/// The value matches `PRE_SEARCH_POLL_STRIDE`, and deliberately so: both count +/// one search, so they are parallel on the same unit rather than nested. Note +/// that 64 is a chosen margin in #10494, not a derived one -- the evidence +/// there (removing the poll left `cycle_starts`, `completions` and `steps` +/// identical across 48,000,000 calls) argues for removal and does not pick a +/// stride. Nothing here relies on 64 being the right number; see the call site. +const COLLECT_POLL_STRIDE: usize = 64; + /// One piece of a template: a span of the template itself, or a part of the /// current match. Parsed once; the capture count is the program's. #[derive(Clone, Copy)] @@ -237,7 +247,27 @@ pub(super) fn replace( let next = advance(bound, index, input_length, unicode, budget)?; super::perex_dispatch::set_last_index(receiver, next)?; } - host::poll()?; + // One collection-loop round in COLLECT_POLL_STRIDE runs the safepoint. + // + // This loop writes each match's spans into a native buffer and creates + // no JS garbage, so its poll enables no collection: nulling it moves + // peak RSS by +0.0% median over nine interleaved rounds of an + // allocating replace at n=1,000,000. (For contrast, polling 8x less + // often in `Pieces::finish`, which does produce garbage, moved the same + // figure +13.2%.) + // + // Worst-case work between executed polls does not grow. Every search + // this loop performs goes through `find_near`, which either polls + // unconditionally (the owned path, and any lent fallback) or ticks + // `PRE_SEARCH_POLL_TICK` and polls on one search in 64 (#10494). That + // tick advances once per search, which is once per iteration of this + // loop, so the two strides run in parallel on the same unit rather than + // composing: the bound stays 64 searches whether this poll is strided + // or not. Striding a site whose own counter advanced on a *different* + // unit would not be safe on this argument. + if searches % COLLECT_POLL_STRIDE == 0 { + host::poll()?; + } } if spans.values.is_empty() { return Ok(boxed(input)); diff --git a/crates/perry-runtime/src/string/char_ops.rs b/crates/perry-runtime/src/string/char_ops.rs index c807f477a4..1449fb5aa8 100644 --- a/crates/perry-runtime/src/string/char_ops.rs +++ b/crates/perry-runtime/src/string/char_ops.rs @@ -104,6 +104,14 @@ fn utf16_unit_at(s: *const StringHeader, idx: usize) -> Option { utf16_index::unit_at(s, idx) } +/// Byte offset of the code point containing UTF-16 index `idx`, plus whether +/// `idx` is its low surrogate half, resolved through the same cached index. +/// `None` means the caller should fall back to its own walk (short payloads, +/// `idx == 0`, or an index past the last decodable unit). +pub(super) fn utf16_boundary_at(s: *const StringHeader, idx: usize) -> Option<(usize, bool)> { + utf16_index::boundary_at(s, idx) +} + /// SSO-safe `s[key]`: takes the receiver as a **NaN-boxed JSValue** rather than /// an already-unboxed `StringHeader*`. /// @@ -695,26 +703,45 @@ pub extern "C" fn js_string_code_point_at(s: *const StringHeader, index: i32) -> } } - // Non-ASCII: bounded WTF-8 walk (#6085) — the old `str_data.chars()` loop - // read continuation bytes past an exact-sized payload ending in a truncated - // multi-byte lead. Allocation-free either way. - let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; - let mut utf16_pos = 0usize; - let mut i = 0usize; - while i < bytes.len() { - let (advance, units, cp) = crate::string::wtf8_step(bytes, i); - if units > 0 && utf16_pos + units > idx { - if units == 1 || utf16_pos == idx { - // Either a BMP code point, or the START of a surrogate pair — - // which per spec is the whole code point. - return cp as f64; - } - // Index lands on the low surrogate half — return the bare unit. - let v = cp.wrapping_sub(0x10000); - return (0xDC00 + (v & 0x3FF)) as f64; + // Non-ASCII: go through the same lazy sparse index + cursor `charCodeAt` + // uses (#10055/#10067). This function used to walk the WTF-8 payload from + // byte 0 on every call, so a sequential scan over a string holding even one + // non-ASCII character was O(n^2) — #10656. `charCodeAt` and `s[i]` were + // moved off that walk by #10067; `codePointAt` was left on it, which is why + // a natively compiled `tsc` spent ~85% of its run in this function + // (`lib.dom.d.ts` carries 45 non-ASCII characters in 1.87 MB). + // + // No bespoke decoding is needed: `codePointAt` is *defined* on code units, + // so the spec algorithm is two indexed reads. `unit_at` keeps the bounded + // WTF-8 stepping that #6085 needs, so the truncated-payload guarantee is + // preserved — a missing continuation byte still decodes from what is + // present rather than over-reading. + let first = match utf16_unit_at(s, idx) { + Some(unit) => unit, + None => return f64::from_bits(crate::value::TAG_UNDEFINED), + }; + // A lone/low surrogate, a BMP code point, or a leading surrogate with + // nothing after it: the unit is the answer. + if !is_leading_surrogate(first) || idx + 1 >= u16len { + return first as f64; + } + match utf16_unit_at(s, idx + 1) { + Some(second) if is_trailing_surrogate(second) => { + let high = (first as u32 - 0xD800) << 10; + let low = second as u32 - 0xDC00; + (0x10000 + high + low) as f64 } - utf16_pos += units; - i += advance; + // Unpaired leading surrogate — per spec the code unit itself. + _ => first as f64, } - f64::from_bits(crate::value::TAG_UNDEFINED) +} + +#[inline] +fn is_leading_surrogate(unit: u16) -> bool { + (0xD800..0xDC00).contains(&unit) +} + +#[inline] +fn is_trailing_surrogate(unit: u16) -> bool { + (0xDC00..0xE000).contains(&unit) } diff --git a/crates/perry-runtime/src/string/char_ops/utf16_index.rs b/crates/perry-runtime/src/string/char_ops/utf16_index.rs index c66e617da1..aba383bd91 100644 --- a/crates/perry-runtime/src/string/char_ops/utf16_index.rs +++ b/crates/perry-runtime/src/string/char_ops/utf16_index.rs @@ -11,7 +11,6 @@ use super::*; use std::cell::RefCell; const CHECKPOINT_BYTES: usize = 128; -const CACHE_ENTRIES: usize = 4; #[derive(Clone, Copy, Default)] struct Position { @@ -29,7 +28,11 @@ struct Index { } impl Index { - fn unit_at(&mut self, bytes: &[u8], idx: usize) -> Option { + /// Locate the code point containing UTF-16 index `idx`, returning its + /// position along with the decoded step. Shared by `unit_at` and + /// `boundary_at` so both pay the same amortised seek and both maintain the + /// same cursor and checkpoints. + fn seek(&mut self, bytes: &[u8], idx: usize) -> Option<(Position, usize, u32)> { let mut pos = self.cursor; // Nearby forward reads use the cursor, including the second half of // an astral character. Other seeks start at the nearest checkpoint. @@ -49,7 +52,7 @@ impl Index { let (advance, units, cp) = decode_step(bytes, pos.byte as usize); if units > 0 && pos.utf16 as usize + units > idx { self.cursor = pos; - return Some(code_unit(cp, units, idx == pos.utf16 as usize)); + return Some((pos, units, cp)); } // A truncated tail can advance past byte_len; never save an // out-of-payload cursor (or narrow that offset with a wrapping cast). @@ -59,6 +62,18 @@ impl Index { self.cursor = pos; None } + + fn unit_at(&mut self, bytes: &[u8], idx: usize) -> Option { + let (pos, units, cp) = self.seek(bytes, idx)?; + Some(code_unit(cp, units, idx == pos.utf16 as usize)) + } + + /// Byte offset of the code point containing `idx`, and whether `idx` is + /// its low surrogate half — i.e. `slice_range::Boundary` in its raw parts. + fn boundary_at(&mut self, bytes: &[u8], idx: usize) -> Option<(usize, bool)> { + let (pos, units, _) = self.seek(bytes, idx)?; + Some((pos.byte as usize, units == 2 && idx != pos.utf16 as usize)) + } } #[inline] @@ -82,29 +97,67 @@ fn decode_step(bytes: &[u8], i: usize) -> (usize, usize, u32) { wtf8_step(bytes, i) } -struct IndexCache { - entries: [Index; CACHE_ENTRIES], - hot: usize, - next: usize, -} - -impl Default for IndexCache { - fn default() -> Self { - Self { - entries: std::array::from_fn(|_| Index::default()), - hot: 0, - next: 0, - } - } -} +/// #10688: an owner-keyed map rather than a fixed array of slots. +/// +/// The array held `CACHE_ENTRIES` indexes and evicted round-robin, so a +/// program interleaving indexed access across more strings than that evicted +/// the entry it was about to need on every single access and rebuilt from +/// scratch forever — measured at 1,224x once K exceeded the slot count, with +/// no gradual degradation. Capacity is the defect, so there is no capacity: +/// entries live until their string dies, and `prune_dead_utf16_indexes` +/// (already driven by the collector) reclaims them. +/// +/// The map is keyed by a string identity the GC *rewrites* when it relocates +/// an object, so `scan_utf16_index_roots_mut` must rehash after the visitor +/// runs — see there. +type IndexCache = crate::fast_hash::PtrHashMap; crate::perry_thread_local! { - static UTF16_INDEX_CACHE: RefCell = RefCell::new(IndexCache::default()); + static UTF16_INDEX_CACHE: RefCell = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); } /// Caller has validated the header and UTF-16 index. Small strings bypass the /// cache: in particular, consuming a character returned by `s[i]` must not evict /// the source string. ASCII callers retain their existing direct byte access. +/// Byte offset (and low-surrogate-half flag) for UTF-16 index `idx`, through +/// the same cache `unit_at` uses. #10685: `slice_range::copy_utf16_range` +/// resolved its start boundary with `advance(bytes, Boundary::default(), start)` +/// — a walk from byte 0 on every call — so slicing a non-ASCII string at +/// increasing offsets was O(n^2), which is the shape TypeScript's scanner has. +pub(super) fn boundary_at(s: *const StringHeader, idx: usize) -> Option<(usize, bool)> { + let byte_len = unsafe { (*s).byte_len }; + let bytes = unsafe { slice::from_raw_parts(string_data(s), byte_len as usize) }; + if bytes.len() < CHECKPOINT_BYTES || idx == 0 { + // Short strings and a zero start do not need the cache: the caller's + // own walk is already O(1)-ish, and consuming a slice must not evict + // the source string from a four-entry cache. + return None; + } + UTF16_INDEX_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let owner = s as usize; + let utf16_len = unsafe { (*s).utf16_len }; + let entry = cache.entry(owner).or_insert_with(|| Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }); + // A uniquely owned string can be appended to in place, which + // invalidates every recorded offset. + if entry.byte_len != byte_len || entry.utf16_len != utf16_len { + *entry = Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }; + } + entry.boundary_at(bytes, idx) + }) +} + pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { let byte_len = unsafe { (*s).byte_len }; let bytes = unsafe { slice::from_raw_parts(string_data(s), byte_len as usize) }; @@ -124,19 +177,16 @@ pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { UTF16_INDEX_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); let owner = s as usize; - let slot = if cache.entries[cache.hot].owner == owner { - cache.hot - } else if let Some(slot) = cache.entries.iter().position(|entry| entry.owner == owner) { - slot - } else { - let slot = cache.next; - cache.next = (slot + 1) % CACHE_ENTRIES; - slot - }; - cache.hot = slot; - let entry = &mut cache.entries[slot]; let utf16_len = unsafe { (*s).utf16_len }; - if entry.owner != owner || entry.byte_len != byte_len || entry.utf16_len != utf16_len { + let entry = cache.entry(owner).or_insert_with(|| Index { + owner, + byte_len, + utf16_len, + ..Index::default() + }); + // A uniquely owned string can be appended to in place, which + // invalidates every recorded offset. + if entry.byte_len != byte_len || entry.utf16_len != utf16_len { *entry = Index { owner, byte_len, @@ -150,19 +200,27 @@ pub(super) fn unit_at(s: *const StringHeader, idx: usize) -> Option { pub(crate) fn scan_utf16_index_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { UTF16_INDEX_CACHE.with(|cache| { - for entry in &mut cache.borrow_mut().entries { - visitor.visit_metadata_usize_slot(&mut entry.owner); + let mut cache = cache.borrow_mut(); + // The visitor may relocate the string each entry describes, which + // changes the very address the map is keyed by. Drain first, let the + // owners be rewritten, then reinsert so the keys and the `owner` + // fields agree again. + let mut moved: Vec<(usize, Index)> = cache.drain().collect(); + for (key, index) in &mut moved { + visitor.visit_metadata_usize_slot(key); + index.owner = *key; + } + for (key, index) in moved { + cache.insert(key, index); } }); } pub(crate) fn prune_dead_utf16_indexes(is_dead_owner: &dyn Fn(usize) -> bool) { UTF16_INDEX_CACHE.with(|cache| { - for entry in &mut cache.borrow_mut().entries { - if entry.owner != 0 && is_dead_owner(entry.owner) { - *entry = Index::default(); - } - } + cache + .borrow_mut() + .retain(|&owner, _| owner != 0 && !is_dead_owner(owner)); }); } @@ -174,13 +232,15 @@ thread_local! { #[cfg(test)] pub(crate) fn test_utf16_index_entries() -> Vec<(usize, usize)> { UTF16_INDEX_CACHE.with(|cache| { - cache + let mut entries: Vec<(usize, usize)> = cache .borrow() - .entries .iter() - .filter(|entry| entry.owner != 0) - .map(|entry| (entry.owner, entry.checkpoints.len())) - .collect() + .filter(|(&owner, _)| owner != 0) + .map(|(&owner, index)| (owner, index.checkpoints.len())) + .collect(); + // HashMap iteration order is not stable; callers compare snapshots. + entries.sort_unstable(); + entries }) } diff --git a/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs b/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs index fed6f718bc..13a7ae0bbd 100644 --- a/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs +++ b/crates/perry-runtime/src/string/char_ops/utf16_index/tests.rs @@ -148,10 +148,22 @@ fn cache_distinguishes_strings_and_invalidates_in_place_appends() { prune_dead_utf16_indexes(&|_| true); } +/// #10688: the index used to live in a fixed four-slot array that evicted +/// round-robin, so interleaving indexed access across more strings than that +/// rebuilt from scratch on every access — 1,224x, as a step function at the +/// fifth string. There is no capacity now, so this asserts the replacement +/// guarantee: **an index survives no matter how many other strings are +/// indexed alongside it.** +/// +/// It also keeps the original invariant this test carried, which is unrelated +/// to capacity and still load-bearing: consuming a one-character string +/// produced by `char_at` must not disturb the source string's index. #[test] -fn cache_eviction_is_bounded_and_short_strings_do_not_evict_sources() { +fn indexes_survive_any_number_of_interleaved_strings() { prune_dead_utf16_indexes(&|_| true); - for i in 0..CACHE_ENTRIES * 3 { + const STRINGS: usize = 16; // comfortably past the old four-slot capacity + let mut sources = Vec::new(); + for i in 0..STRINGS { let text = format!( "{}{}", "中".repeat(256), @@ -162,8 +174,150 @@ fn cache_eviction_is_bounded_and_short_strings_do_not_evict_sources() { let before = test_utf16_index_entries(); let ch = js_string_char_at(s, 256); assert_eq!(js_string_char_code_at(ch, 0), (0x400 + i) as f64); - assert_eq!(test_utf16_index_entries(), before); - assert!(before.len() <= CACHE_ENTRIES); + assert_eq!( + test_utf16_index_entries(), + before, + "a short string from char_at must not disturb the source's index" + ); + sources.push((s, 0x400 + i)); + } + // Every index is still resident: no eviction happened at any depth. + assert_eq!( + test_utf16_index_entries().len(), + STRINGS, + "all {STRINGS} indexes must survive; the old array held only four" + ); + // And every one still answers correctly, cheaply, in a second pass. + for (s, expected) in &sources { + assert_eq!(js_string_char_code_at(*s, 256), *expected as f64); } prune_dead_utf16_indexes(&|_| true); + assert!( + test_utf16_index_entries().is_empty(), + "the collector's prune hook must reclaim them" + ); +} + +/// #10656: `codePointAt` used to walk the WTF-8 payload from byte 0 on every +/// call, so a scan over a string holding one non-ASCII character was O(n^2). +/// These pin the spec behaviour across the cached-index path that replaced it: +/// a BMP code point, the start of a surrogate pair (the whole code point), the +/// low half (the bare trailing surrogate), and an unpaired leading surrogate. +#[test] +fn code_point_at_matches_the_spec_through_the_cached_index() { + // Long enough to exercise the checkpoint/cursor path, not the short-string + // fallback, and non-ASCII so it cannot take the ASCII fast path. + let mut text = String::new(); + for _ in 0..200 { + text.push_str("\u{e9}abcdefghij0123456789"); + } + let astral_at = text.chars().count(); + text.push('\u{1F600}'); // surrogate pair + text.push('z'); + + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let units: Vec = text.encode_utf16().collect(); + + // Walk forwards (the sequential case tsc hits) and compare every index + // against an independent UTF-16 expansion of the same text. + for (idx, &unit) in units.iter().enumerate() { + let got = crate::string::js_string_code_point_at(s, idx as i32); + let expected = if (0xD800..0xDC00).contains(&unit) && idx + 1 < units.len() { + let second = units[idx + 1]; + if (0xDC00..0xE000).contains(&second) { + 0x10000 + (((unit as u32 - 0xD800) << 10) | (second as u32 - 0xDC00)) + } else { + unit as u32 + } + } else { + unit as u32 + }; + assert_eq!(got, expected as f64, "codePointAt({idx})"); + } + + // The surrogate pair specifically: start yields the astral code point, the + // low half yields the bare trailing surrogate. + let pair_start = units.len() - 3; + assert_eq!( + crate::string::js_string_code_point_at(s, pair_start as i32), + 128512.0_f64 + ); + assert!((0xDC00..0xE000).contains( + &(crate::string::js_string_code_point_at(s, pair_start as i32 + 1) as u32 as u16) + )); + let _ = astral_at; + + // Out of bounds stays undefined. + let oob = crate::string::js_string_code_point_at(s, units.len() as i32); + assert_eq!(oob.to_bits(), crate::value::TAG_UNDEFINED); +} + +/// Random access must agree with sequential access: the cursor optimises the +/// forward case, and a backward seek must not return a stale answer. +#[test] +fn code_point_at_is_order_independent() { + let mut text = String::new(); + for _ in 0..150 { + text.push_str("x\u{e9}yz"); + } + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let n = text.encode_utf16().count(); + + let forward: Vec = (0..n) + .map(|i| crate::string::js_string_code_point_at(s, i as i32)) + .collect(); + let backward: Vec = (0..n) + .rev() + .map(|i| crate::string::js_string_code_point_at(s, i as i32)) + .collect(); + for (i, value) in backward.iter().rev().enumerate() { + assert_eq!(*value, forward[i], "index {i} differs by traversal order"); + } +} + +/// #10685: `copy_utf16_range` resolved its start boundary by walking from byte +/// 0 on every call, so slicing a non-ASCII string at increasing offsets was +/// O(n^2). `boundary_at` must agree with that walk at every index — including +/// the low half of a surrogate pair, where `low` selects the split copy path. +#[test] +fn boundary_at_matches_a_walk_from_zero() { + let mut text = String::new(); + for _ in 0..80 { + text.push_str("\u{e9}abcdefghij0123456789"); + } + text.push('\u{1F600}'); + text.push_str("tail\u{e9}"); + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let bytes = unsafe { + std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize) + }; + let n = text.encode_utf16().count(); + + for idx in 0..n { + let walked = crate::string::slice_range::advance( + bytes, + crate::string::slice_range::Boundary::default(), + idx, + ); + if let Some((byte, low)) = super::boundary_at(s, idx) { + assert_eq!(byte, walked.byte, "byte offset at {idx}"); + assert_eq!(low, walked.low, "low-surrogate flag at {idx}"); + } + } +} + +/// The cursor optimises forward seeks; a backward seek must not reuse it. +#[test] +fn boundary_at_is_order_independent() { + let mut text = String::new(); + for _ in 0..80 { + text.push_str("x\u{e9}yz"); + } + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let n = text.encode_utf16().count(); + let forward: Vec<_> = (0..n).map(|i| super::boundary_at(s, i)).collect(); + let backward: Vec<_> = (0..n).rev().map(|i| super::boundary_at(s, i)).collect(); + for (i, value) in backward.iter().rev().enumerate() { + assert_eq!(*value, forward[i], "index {i} differs by traversal order"); + } } diff --git a/crates/perry-runtime/src/string/slice_range.rs b/crates/perry-runtime/src/string/slice_range.rs index e4fbd12577..0fe5a4702f 100644 --- a/crates/perry-runtime/src/string/slice_range.rs +++ b/crates/perry-runtime/src/string/slice_range.rs @@ -33,7 +33,16 @@ pub(super) fn copy_utf16_range(s: *const StringHeader, start: u32, end: u32) -> return string_copy_range(s, start as usize, end - start, end - start, 0); } let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; - let first = advance(bytes, Boundary::default(), start as usize); + // #10685: resolve the start boundary through the cached UTF-16 index + // rather than walking from byte 0 on every call. Slicing a non-ASCII + // string at increasing offsets — TypeScript's scanner, and every other + // tokenizer — was O(n^2) because of that walk. `None` keeps the original + // behaviour for short payloads and `start == 0`, where the walk is already + // cheap and the cache should not be disturbed. + let first = match char_ops::utf16_boundary_at(s, start as usize) { + Some((byte, low)) => Boundary { byte, low }, + None => advance(bytes, Boundary::default(), start as usize), + }; // A suffix's end is already known: do not scan the entire remaining string. let last = if end == unsafe { (*s).utf16_len } { Boundary { diff --git a/crates/perry-stdlib/src/common/dispatch_http.rs b/crates/perry-stdlib/src/common/dispatch_http.rs index aae66413fd..df760d8a29 100644 --- a/crates/perry-stdlib/src/common/dispatch_http.rs +++ b/crates/perry-stdlib/src/common/dispatch_http.rs @@ -224,6 +224,12 @@ pub(super) unsafe fn dispatch_client_incoming_property( | "socket" | "connection" | "req" + // #10467 — rawHeaders / httpVersion* / complete. + | "rawHeaders" + | "httpVersion" + | "httpVersionMajor" + | "httpVersionMinor" + | "complete" ) { return None; } @@ -241,6 +247,11 @@ pub(super) unsafe fn dispatch_client_incoming_property( fn js_http_response_trailers(handle: i64) -> f64; fn js_http_incoming_message_socket(handle: i64) -> f64; fn js_http_incoming_message_req(handle: i64) -> f64; + fn js_http_response_raw_headers(handle: i64) -> f64; + fn js_http_response_http_version(handle: i64) -> *mut perry_runtime::StringHeader; + fn js_http_response_http_version_major(handle: i64) -> f64; + fn js_http_response_http_version_minor(handle: i64) -> f64; + fn js_http_response_complete(handle: i64) -> f64; } if unsafe { js_http_is_incoming_message(handle) } == 0 { @@ -267,6 +278,18 @@ pub(super) unsafe fn dispatch_client_incoming_property( "trailers" => unsafe { js_http_response_trailers(handle) }, "socket" | "connection" => unsafe { js_http_incoming_message_socket(handle) }, "req" => unsafe { js_http_incoming_message_req(handle) }, + "rawHeaders" => unsafe { js_http_response_raw_headers(handle) }, + "httpVersion" => { + let ptr = unsafe { js_http_response_http_version(handle) }; + if ptr.is_null() { + f64::from_bits(0x7FFC_0000_0000_0001) + } else { + f64::from_bits(JSValue::string_ptr(ptr).bits()) + } + } + "httpVersionMajor" => unsafe { js_http_response_http_version_major(handle) }, + "httpVersionMinor" => unsafe { js_http_response_http_version_minor(handle) }, + "complete" => unsafe { js_http_response_complete(handle) }, _ => f64::from_bits(0x7FFC_0000_0000_0001), }; Some(value) diff --git a/crates/perry/tests/issue_10439_native_binding_import_provenance.rs b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs new file mode 100644 index 0000000000..6bd71c58d8 --- /dev/null +++ b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs @@ -0,0 +1,316 @@ +//! Regression test for #10439: `new Command()` / `new LRUCache()` / +//! `new Decimal()` were intercepted by CLASS NAME and routed to Perry's +//! native binding regardless of `perry.compilePackages` — a user could not +//! opt out of the (broken) native handle by asking for real-source +//! compilation. Fixed by `detect_native_instance_expr` +//! (`crates/perry-hir/src/lower_patterns.rs`) consulting `lookup_native_module` +//! (the same compilePackages-aware provenance table `is_native_module` +//! populates at import-lowering time) instead of matching on the bare +//! identifier spelling. +//! +//! The hijack only manifested for a method CHAINED DIRECTLY onto `new +//! X(...)` (`new Command().name(...)`, `new LRUCache(...).set(...)`, `new +//! Decimal(...).dividedBy(...)`) — that shape short-circuits straight to +//! `Expr::NativeMethodCall` in `expr_call/static_and_instance.rs`, bypassing +//! every other (already provenance-aware) construction-site gate. A +//! `let`/`const`-bound receiver was never affected, which is why each fixture +//! below exercises the chained form specifically. +//! +//! Modeled on `issue_8749_compiled_package_builtin_import.rs`'s temp +//! `compilePackages` fixture pattern: a fake `node_modules/` with a real +//! ES class shaped like the collision, so compiling it from source is +//! unambiguous (no real npm registry access needed). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// Write `node_modules//{package.json,index.mjs}` under `root`, +/// exporting the given real class source (`.mjs`, so no extra transpile step +/// leaks into HIR that the real npm packages wouldn't have either). +fn write_fake_package(root: &Path, pkg_name: &str, class_source: &str) { + let pkg = root.join("node_modules").join(pkg_name); + std::fs::create_dir_all(&pkg).expect("mkdir fake package"); + std::fs::write( + pkg.join("package.json"), + format!( + r#"{{ + "name": "{pkg_name}", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +}}"# + ), + ) + .expect("write fake package.json"); + std::fs::write(pkg.join("index.mjs"), class_source).expect("write fake package source"); +} + +fn write_compile_packages_manifest(root: &Path, pkg_name: &str) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ + "name": "issue-10439-consumer", + "private": true, + "type": "module", + "perry": {{ + "compilePackages": ["{pkg_name}"], + "allow": {{ "compilePackages": ["{pkg_name}"] }} + }} +}}"# + ), + ) + .expect("write consumer package.json"); +} + +/// Compile `entry` (already written under `root`) and return its stdout, +/// asserting both the compile and the run succeeded. +fn compile_and_run(root: &Path, entry_name: &str) -> String { + let entry = root.join(entry_name); + let output = root.join(format!("{entry_name}.bin")); + let compile = Command::new(perry_bin()) + .current_dir(root) + // Auto-optimize triggers a full profile-guided workspace rebuild on + // its first invocation — expensive, and irrelevant to this test + // (which is about construction/dispatch routing, not optimization). + // Every manual repro of #10439 used the same no-auto path. + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed for {entry_name}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .output() + .unwrap_or_else(|e| panic!("run compiled binary for {entry_name}: {e}")); + assert!( + run.status.success(), + "compiled binary failed for {entry_name}\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// #10439 case: commander's `Command`. Real source, default import name, +/// chained directly onto `new` — the exact shape `builtin.rs:405`'s +/// unconditional `"Command"` arm used to reach via the unguarded +/// `detect_native_instance_expr` match, regardless of `compilePackages`. +#[test] +fn commander_default_name_reaches_real_source_under_compile_packages() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_compile_packages_manifest(root, "commander"); + write_fake_package( + root, + "commander", + r#" +export class Command { + constructor() { this.__name = ""; } + name(n) { + if (n === undefined) return this.__name; + this.__name = n; + return this; + } +} +"#, + ); + + // Default spelling, chained directly on `new` — previously hijacked. + std::fs::write( + root.join("main.ts"), + r#" +import { Command } from "commander"; +console.log(new Command().name("real-commander-source").name()); +"#, + ) + .expect("write main.ts"); + assert_eq!( + compile_and_run(root, "main.ts"), + "real-commander-source\n", + "new Command().name(...).name() must run the compiled real source, not the native handle" + ); + + // Renamed-import control: this already worked before the fix (the + // hardcoded match is keyed on the literal spelling "Command"), and must + // keep working after it. + std::fs::write( + root.join("renamed.ts"), + r#" +import { Command as Cmd } from "commander"; +console.log(new Cmd().name("renamed-control").name()); +"#, + ) + .expect("write renamed.ts"); + assert_eq!( + compile_and_run(root, "renamed.ts"), + "renamed-control\n", + "the renamed-import workaround must still work unchanged" + ); +} + +/// #10439 case: lru-cache's `LRUCache`, chained directly onto `new`. +#[test] +fn lru_cache_default_name_reaches_real_source_under_compile_packages() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_compile_packages_manifest(root, "lru-cache"); + write_fake_package( + root, + "lru-cache", + r#" +export class LRUCache { + constructor(opts) { this.__store = new Map(); this.__max = opts && opts.max || 0; } + set(k, v) { this.__store.set(k, v); return this; } + get(k) { return this.__store.get(k); } +} +"#, + ); + + std::fs::write( + root.join("main.ts"), + r#" +import { LRUCache } from "lru-cache"; +console.log(new LRUCache({ max: 3 }).set("a", 1).get("a")); +"#, + ) + .expect("write main.ts"); + assert_eq!( + compile_and_run(root, "main.ts"), + "1\n", + "new LRUCache(...).set(...).get(...) must run the compiled real source, not the native handle" + ); + + std::fs::write( + root.join("renamed.ts"), + r#" +import { LRUCache as Cache } from "lru-cache"; +console.log(new Cache({ max: 3 }).set("a", 2).get("a")); +"#, + ) + .expect("write renamed.ts"); + assert_eq!( + compile_and_run(root, "renamed.ts"), + "2\n", + "the renamed-import workaround must still work unchanged" + ); +} + +/// #10439 case: decimal.js's `Decimal`, chained directly onto `new`. This is +/// the shape #10684 (division/large-multiplication corruption) depends on: +/// the native handle's `dividedBy`/`times` are broken, and the interception +/// prevented compilePackages from ever reaching the real, correct source. +#[test] +fn decimal_default_name_reaches_real_source_under_compile_packages() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_compile_packages_manifest(root, "decimal.js"); + write_fake_package( + root, + "decimal.js", + r#" +export default class Decimal { + constructor(v) { this.__v = typeof v === "string" ? parseFloat(v) : v; } + dividedBy(n) { return new Decimal(this.__v / (n instanceof Decimal ? n.__v : n)); } + times(n) { return new Decimal(this.__v * (n instanceof Decimal ? n.__v : n)); } + toString() { return String(this.__v); } +} +"#, + ); + + std::fs::write( + root.join("main.ts"), + r#" +import Decimal from "decimal.js"; +console.log(new Decimal(1).dividedBy(4).toString()); +"#, + ) + .expect("write main.ts"); + assert_eq!( + compile_and_run(root, "main.ts"), + "0.25\n", + "new Decimal(1).dividedBy(4).toString() must run the compiled real source, not the native handle" + ); + + std::fs::write( + root.join("renamed.ts"), + r#" +import Dec from "decimal.js"; +console.log(new Dec(1).dividedBy(4).toString()); +"#, + ) + .expect("write renamed.ts"); + assert_eq!( + compile_and_run(root, "renamed.ts"), + "0.25\n", + "the renamed-import workaround must still work unchanged" + ); +} + +/// The legitimate case this issue explicitly warns against regressing: with +/// NO `perry.compilePackages` entry (and no real package installed at all — +/// there is nothing else it COULD mean), `new Command()...` must still route +/// to the native binding exactly as before. Values asserted here are the +/// native binding's own pre-existing (documented-limited) behavior, captured +/// against this same commit's pre-fix binary — this test exists to prove the +/// fix does not change them, not to bless them as correct. +#[test] +fn commander_default_name_still_uses_native_binding_without_compile_packages() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + // No package.json, no node_modules: "commander" can only resolve to + // Perry's bundled native shim. + std::fs::write( + root.join("main.ts"), + r#" +import { Command } from "commander"; +const program = new Command(); +console.log(new Command().name("x").name()); +console.log(program.constructor.name); +"#, + ) + .expect("write main.ts"); + assert_eq!( + compile_and_run(root, "main.ts"), + "{}\nundefined\n", + "the native-binding path (no compilePackages) must be byte-for-byte unchanged" + ); +} + +/// Same legitimate-case guard for lru-cache: without `compilePackages`, +/// `new LRUCache(...).set(...).get(...)` must still reach the native +/// `js_lru_cache_*` handle path (which happens to compute the right answer +/// for this simple, non-evicting case) rather than falling through to a +/// nonexistent real source. +#[test] +fn lru_cache_default_name_still_uses_native_binding_without_compile_packages() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write( + root.join("main.ts"), + r#" +import { LRUCache } from "lru-cache"; +console.log(new LRUCache({ max: 3 }).set("a", 1).get("a")); +"#, + ) + .expect("write main.ts"); + assert_eq!( + compile_and_run(root, "main.ts"), + "1\n", + "the native-binding path (no compilePackages) must be byte-for-byte unchanged" + ); +} diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index c1786de33f..947dbdac54 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -13,7 +13,6 @@ "crates/perry-ext-fetch/src/tests.rs": 14, "crates/perry-ext-http/src/agent.rs": 3, "crates/perry-ext-http/src/client_request_surface.rs": 2, - "crates/perry-ext-http/src/response_headers.rs": 1, "crates/perry-ext-http/src/server/handle_dispatch.rs": 2, "crates/perry-ext-http/src/server/request.rs": 7, "crates/perry-ext-http/src/server/response.rs": 1, @@ -54,7 +53,7 @@ "crates/perry-stdlib/src/pg/types.rs": 14, "crates/perry-stdlib/src/querystring.rs": 2, "crates/perry-stdlib/src/ratelimit.rs": 4, - "crates/perry-stdlib/src/readline/mod.rs": 5, + "crates/perry-stdlib/src/readline/mod.rs": 4, "crates/perry-stdlib/src/sqlite/backup.rs": 7, "crates/perry-stdlib/src/sqlite/better.rs": 18, "crates/perry-stdlib/src/sqlite/bind.rs": 4, @@ -69,7 +68,7 @@ "crates/perry-stdlib/src/streams/transform.rs": 8, "crates/perry-stdlib/src/streams/writable.rs": 2, "crates/perry-stdlib/src/string_decoder.rs": 4, - "crates/perry-stdlib/src/tls.rs": 4, + "crates/perry-stdlib/src/tls.rs": 3, "crates/perry-stdlib/src/webcrypto/aes.rs": 2, "crates/perry-stdlib/src/webcrypto/encapsulation.rs": 8, "crates/perry-stdlib/src/webcrypto/jwk.rs": 2, @@ -82,5 +81,5 @@ "crates/perry-stdlib/src/zlib.rs": 2 }, "schema_version": 2, - "total": 561 + "total": 558 } diff --git a/test-files/test_gap_10623_implicit_ctor_native_super.cts b/test-files/test_gap_10623_implicit_ctor_native_super.cts new file mode 100644 index 0000000000..1d53f0976f --- /dev/null +++ b/test-files/test_gap_10623_implicit_ctor_native_super.cts @@ -0,0 +1,144 @@ +// #10623: a derived class with NO explicit constructor does not forward its +// `new`-site arguments to a native base's `super(...)`. +// +// const { AsyncResource } = require("node:async_hooks"); +// class NoCtor extends AsyncResource {} +// new NoCtor("MyResource"); // threw: "type" argument must be of type string +// +// Root cause: class-heritage resolution treats ANY in-scope local binding +// with the same name as the parent as "the user shadowed the native base with +// their own value" (`locally_shadowed` in `perry-hir/src/lower_decl/ +// class_decl.rs`), which routes `super()` through a generic call-the-value +// dispatch instead of the native base's real init (`js_async_resource_ +// subclass_init` and friends). That heuristic is right for a GENUINE shadow +// (`const EventEmitter = MyOwnClass; class X extends EventEmitter {}`), but a +// CJS-wrapped module (this file) runs its ENTIRE body inside the wrap's IIFE, +// so `const { AsyncResource } = require("node:async_hooks")` is *also* a +// genuine local — indistinguishable from real shadowing under the old check. +// AsyncResource's runtime value is a real ES `class`, so the fallback dispatch +// (calling it without `new`) threw "Class constructor AsyncResource cannot be +// invoked without 'new'" — for BOTH the implicit AND the explicit `super(type)` +// form (this file's CJS/CommonJS shape is what real npm packages use; the +// issue's "explicit works" observation held only for an ESM-module variant of +// the same source, not for this one). +// +// The fix distinguishes the two by PROVENANCE instead of by re-deriving the +// name: a local is not "shadowing" if it was ALSO destructured from a +// `require()` of the real native module with a matching export key. + +const { AsyncResource, AsyncLocalStorage } = require("node:async_hooks"); +const { EventEmitter, EventEmitterAsyncResource } = require("node:events"); +const { Readable } = require("node:stream"); + +function run(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (e: any) { + console.log(label, "threw:", e.constructor.name + ":", e.message); + } +} + +// ── the issue's exact repro: no ctor, no override ── +run("AsyncResource implicit", () => { + class NoCtor extends AsyncResource {} + const a = new NoCtor("MyResource"); + console.log(" ", a.constructor.name, typeof a.triggerAsyncId, a instanceof AsyncResource); +}); + +// ── explicit-ctor control: this form must keep working ── +run("AsyncResource explicit", () => { + class WithCtor extends AsyncResource { + constructor(type: string) { + super(type); + } + } + const b = new WithCtor("MyResource2"); + console.log(" ", b.constructor.name, typeof b.triggerAsyncId, b instanceof AsyncResource); +}); + +// ── two-level (indirect) subclass, no constructor anywhere ── +run("AsyncResource two-level", () => { + class Mid extends AsyncResource {} + class Leaf extends Mid {} + const l = new Leaf("LeafResource"); + console.log(" ", l.constructor.name, typeof l.triggerAsyncId, l instanceof AsyncResource); +}); + +// ── class EXPRESSION, constructor-less ── +run("AsyncResource class-expr", () => { + const Anon = class extends AsyncResource {}; + const inst = new Anon("AnonResource"); + console.log(" ", inst.constructor.name, typeof inst.triggerAsyncId, inst instanceof AsyncResource); +}); + +// ── other native bases reached the SAME way (require() destructure), same +// class-of-defect coverage: constructor-less + explicit-ctor control ── +// Note: this checks the construction surface only, not `instanceof +// AsyncLocalStorage` — that comparison has its own pre-existing gap +// (unrelated to #10623: it reproduces identically whether or not this class +// forwards constructor arguments, and #10623's fix does not touch +// `instanceof` resolution) filed separately. +run("AsyncLocalStorage implicit", () => { + class NoCtorALS extends AsyncLocalStorage {} + const s = new NoCtorALS(); + console.log(" ", typeof s.run, typeof s.getStore); +}); + +// Same `instanceof`-only carve-out as the AsyncLocalStorage case above. +run("EventEmitterAsyncResource implicit", () => { + class NoCtorEEAR extends EventEmitterAsyncResource {} + const e = new NoCtorEEAR(); + console.log(" ", typeof e.on, typeof e.triggerAsyncId); +}); + +run("EventEmitter implicit", () => { + class NoCtorEE extends EventEmitter {} + const ee = new NoCtorEE(); + let got = 0; + ee.on("ping", (v: number) => (got = v)); + ee.emit("ping", 7); + console.log(" ", typeof ee.on, got); +}); + +run("EventEmitter explicit", () => { + class WithCtorEE extends EventEmitter { + tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + } + const ee = new WithCtorEE("t1"); + console.log(" ", typeof ee.on, ee.tag); +}); + +run("Readable implicit", () => { + class NoCtorR extends Readable {} + const r = new NoCtorR({ read() {} }); + console.log(" ", typeof r.push, typeof r.pipe); +}); + +run("Readable explicit", () => { + class WithCtorR extends Readable { + constructor(opts: any) { + super(opts); + } + } + const r = new WithCtorR({ read() {} }); + console.log(" ", typeof r.push); +}); + +// ── Error family: a DIFFERENT (already-correct) mechanism; kept as a +// same-file control so a future regression here shows up next to #10623 ── +run("Error implicit", () => { + class NoCtorErr extends Error {} + const e = new NoCtorErr("boom"); + console.log(" ", e.message, e instanceof Error); +}); + +run("TypeError implicit", () => { + class NoCtorTErr extends TypeError {} + const e = new NoCtorTErr("bad type"); + console.log(" ", e.message, e instanceof TypeError); +}); diff --git a/test-files/test_gap_net_socket_surface_cluster.ts b/test-files/test_gap_net_socket_surface_cluster.ts new file mode 100644 index 0000000000..79e0afd887 --- /dev/null +++ b/test-files/test_gap_net_socket_surface_cluster.ts @@ -0,0 +1,125 @@ +// #10441/#10442/#10444/#10465 — the `net.Socket` surface cluster the package +// audit hit while compiling real socket-backed npm drivers (mysql2, pg, +// redis, ws) natively instead of through Perry's hand-written bindings: +// +// #10441 — prependListener/prependOnceListener were missing entirely +// (silently did nothing; ioredis/iovalkey's RESP parser attach +// via `stream.prependListener("data", …)` never saw a byte). +// #10442 — on()/addListener() returned `undefined` on a TYPED `net.Socket` +// receiver, breaking `sock.on(...).on(...)` chaining. +// #10444 — pipe() didn't exist on `net.Socket` at all (mongodb's +// Connection constructor does `.pipe(new SizedMessageTransform)`). +// #10465 — writable/readable/_writableState/_readableState were missing, +// and readyState/connecting/pending/destroyed didn't track the +// real connect/end/close lifecycle. +// +// One flow exercises all four against a real loopback echo server so the +// output is compared byte-for-byte against Node instead of spot-checked. + +import * as net from "node:net"; +import { PassThrough } from "node:stream"; + +const server = net.createServer((conn) => { + conn.on("data", (d) => conn.write(d)); + // Explicit half-close instead of relying on Node's default + // allowHalfOpen=false auto-end, so this test only exercises the four + // issues above, not the server's own half-open behavior. + conn.on("end", () => conn.end()); +}); + +function show(label: string, s: net.Socket) { + console.log( + label.padEnd(12), + "writable=" + s.writable, + "readable=" + s.readable, + "readyState=" + s.readyState, + "connecting=" + s.connecting, + "pending=" + s.pending, + "destroyed=" + s.destroyed, + ); +} + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + + // ── #10465: a never-connected socket ────────────────────────────────── + const fresh = new net.Socket(); + show("new Socket", fresh); + console.log( + "new Socket _writableState=" + typeof fresh._writableState, + "_readableState=" + typeof fresh._readableState, + ); + fresh.destroy(); + + // Typed receiver end to end — `net.Socket`, not `any` — since #10442's + // defect only reproduced on a statically typed receiver. + const sock: net.Socket = net.connect(port, "127.0.0.1"); + show("connecting", sock); + + // ── #10442: on()/addListener() return value + chaining ──────────────── + console.log("on() returns socket:", sock.on("noop-event", () => {}) === sock); + console.log( + "addListener() returns socket:", + sock.addListener("noop-event", () => {}) === sock, + ); + try { + sock.on("__chain_a", () => {}).on("__chain_b", () => {}); + console.log("chained on().on() ok"); + } catch (e: any) { + console.log("chained on().on() threw:", e.message); + } + + // ── #10441: prependListener/prependOnceListener ──────────────────────── + const order: string[] = []; + sock.on("data", () => order.push("normal")); + const prependRet = sock.prependListener("data", () => order.push("prepend")); + console.log("prependListener returns socket:", prependRet === sock); + const prependOnceRet = sock.prependOnceListener("data", () => order.push("prependOnce")); + console.log("prependOnceListener returns socket:", prependOnceRet === sock); + + sock.on("connect", () => { + show("connected", sock); + const anySock: any = sock; + console.log( + "untyped read:", + "writable=" + anySock.writable, + "readable=" + anySock.readable, + "readyState=" + anySock.readyState, + ); + + sock.once("data", (chunk: Buffer) => { + console.log("data order :", order.join(",")); + console.log("data payload:", JSON.stringify(chunk.toString())); + // Clear the marker listeners before wiring pipe() below so they don't + // also fire on the piped chunk. + sock.removeAllListeners("data"); + + // ── #10444: pipe() ────────────────────────────────────────────── + const dest = new PassThrough(); + const pipeRet = sock.pipe(dest); + console.log("pipe() returns dest:", pipeRet === dest); + dest.on("data", (piped: Buffer) => { + console.log("piped payload:", JSON.stringify(piped.toString())); + show("mid-stream", sock); + sock.end(); + }); + sock.write("piped-chunk"); + }); + + sock.write("first-chunk"); + }); + + sock.on("end", () => { + show("'end'", sock); + }); + + sock.on("close", () => { + show("'close'", sock); + server.close(); + }); + + sock.on("error", (e: any) => { + console.log("socket error:", e.message); + server.close(); + }); +});