diff --git a/CLAUDE.md b/CLAUDE.md index 39dbd67118..e3e66f7353 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.1604 +**Current Version:** 0.5.1605 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index ebd1fecdee..1fbb5e01ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1604" +version = "0.5.1605" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "block2", "objc2", @@ -5849,25 +5849,16 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "argon2", "perry-ffi", "rand_core 0.6.4", ] -[[package]] -name = "perry-ext-axios" -version = "0.5.1604" -dependencies = [ - "perry-ffi", - "reqwest", - "tokio", -] - [[package]] name = "perry-ext-bcrypt" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5866,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5874,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5882,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5890,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "chrono", "cron", @@ -5909,7 +5900,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5908,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5916,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5932,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5940,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5964,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5977,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6009,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6019,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "lru", "perry-ffi", @@ -6037,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "chrono", "perry-ffi", @@ -6045,7 +6036,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "bson", "futures-util", @@ -6057,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6060,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "nanoid", "perry-ffi", @@ -6078,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "bytes", "perry-ffi", @@ -6093,7 +6084,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6112,7 +6103,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "lettre", "perry-ffi", @@ -6122,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "notify", "perry-ffi", @@ -6134,7 +6125,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "printpdf", @@ -6142,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "sqlx", @@ -6151,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "perry-runtime", @@ -6160,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "governor", "perry-ffi", @@ -6168,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "fast_image_resize", "image", @@ -6179,7 +6170,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "lazy_static", "perry-ffi", @@ -6188,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-ffi", @@ -6208,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-ffi", "perry-runtime", @@ -6217,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "futures-util", "lazy_static", @@ -6230,7 +6221,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "brotli", "flate2", @@ -6240,7 +6231,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6250,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-api-manifest", @@ -6270,11 +6261,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1604" +version = "0.5.1605" [[package]] name = "perry-parser" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "perry-diagnostics", @@ -6287,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perex", "regex", @@ -6295,7 +6286,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "ahash", "base64 0.22.1", @@ -6353,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6447,21 +6438,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "dirs", "perry-ffi", @@ -6471,7 +6462,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "jni", @@ -6486,7 +6477,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "rand 0.10.2", "serde", @@ -6496,7 +6487,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6519,7 +6510,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "block2", @@ -6536,7 +6527,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "block2", @@ -6553,7 +6544,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1604" +version = "0.5.1605" [[package]] name = "perry-ui-test" @@ -6564,11 +6555,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1604" +version = "0.5.1605" [[package]] name = "perry-ui-tvos" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "block2", @@ -6585,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "block2", @@ -6602,7 +6593,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "block2", "libc", @@ -6616,7 +6607,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "libc", @@ -6635,7 +6626,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "base64 0.22.1", "libc", @@ -6648,7 +6639,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "anyhow", "base64 0.22.1", @@ -6663,7 +6654,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1604" +version = "0.5.1605" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b8cf098885..f5a536bf9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ members = [ "crates/perry-ext-better-sqlite3", "crates/perry-ext-zlib", "crates/perry-ext-exponential-backoff", - "crates/perry-ext-axios", "crates/perry-ext-events", "crates/perry-ext-decimal", "crates/perry-ext-dayjs", @@ -334,7 +333,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1604" +version = "0.5.1605" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -482,7 +481,6 @@ perry-ext-lru-cache = { path = "crates/perry-ext-lru-cache" } perry-ext-better-sqlite3 = { path = "crates/perry-ext-better-sqlite3" } perry-ext-zlib = { path = "crates/perry-ext-zlib" } perry-ext-exponential-backoff = { path = "crates/perry-ext-exponential-backoff" } -perry-ext-axios = { path = "crates/perry-ext-axios" } perry-ext-events = { path = "crates/perry-ext-events" } perry-ext-decimal = { path = "crates/perry-ext-decimal" } perry-ext-dayjs = { path = "crates/perry-ext-dayjs" } diff --git a/changelog.d/10649-stream-subclass-heritage.md b/changelog.d/10649-stream-subclass-heritage.md new file mode 100644 index 0000000000..5e9bb57991 --- /dev/null +++ b/changelog.d/10649-stream-subclass-heritage.md @@ -0,0 +1,10 @@ +### Fixed + +- **`node:stream` subclass overrides (`_transform`/`_write`/`_read`) are no + longer ignored when the heritage reaching `class X extends ` is a + local alias, an indirect subclass, a class expression, or a CJS + destructured `require('stream')` — the shape nodemailer uses in every + stream class it defines. `write()`/`push()` used to throw + `ERR_METHOD_NOT_IMPLEMENTED` because the override was never installed on + `this`; the dynamic `super()` dispatch now recognizes the resolved + bound-export value regardless of how the heritage expression reached it. diff --git a/changelog.d/10679-axios-native-binding-removal.md b/changelog.d/10679-axios-native-binding-removal.md new file mode 100644 index 0000000000..0d4237bf90 --- /dev/null +++ b/changelog.d/10679-axios-native-binding-removal.md @@ -0,0 +1,63 @@ +Removed the axios native binding (`crates/perry-ext-axios`, `perry-stdlib/src/axios.rs`, +and their `js_axios_*` FFI surface) so `import axios from "axios"` resolves to the real +npm package instead of Perry's hand-written reimplementation. First package removed under +the owner's decision to delete native bindings rather than let them drift from upstream +behavior — the motivating case was `jsonwebtoken.verify` silently accepting forged tokens. + +**Base branch note**: this PR must not merge before #10673 (the `agent-base` +namespace/`export =` fallback fix) — axios's `https-proxy-agent` dependency needs that +fix to compile at all. + +**What was removed**: the `NATIVE_MODULES` entry and manifest method rows (`entries.rs`, +`entries/part_4.rs`), the `[bindings.axios]` block in `well_known_bindings.toml`, the +`perry-ext-axios` crate and its `Cargo.toml`/workspace-member registration, the +`crates/perry-stdlib/src/axios.rs` implementation and its `common/dispatch/property_dispatch.rs` +response-property dispatch arm, the codegen dispatch for axios's static HTTP methods and +response-property access (`lower_call/options/fetch.rs`), the `js_axios_*` FFI declarations +(`runtime_decls/stdlib_ffi/third_party.rs`) and their runtime/no-op-stub implementations +(`perry-runtime/src/closure/v8_stubs.rs`, `perry-ui-android/src/stdlib_stubs.rs`), the +HIR "axios.get/post/… returns a Response" local-instance tagging used only to route +`.status`/`.data`/`.statusText` through the now-deleted native dispatch (`local_natives.rs`, +`destructuring/var_decl/native_new.rs`, `lower/module_decl.rs`, `lower/stmt.rs`), the +`emit.rs` "callable default export" special-case that existed only for axios's `axios(config)` +shape, and the `workspace-architecture.json` entry for the deleted crate. + +Also removed: the axios-only HIR test (`axios_response_property_lowering.rs`) and the +native-dispatch NaN-boxing regression test (`test_issue_340_axios_response_props.ts`, +plus its stale `known_failures.json` entry) — both tested internals of the deleted native +shim and have no analog against real-source axios. Swapped `unimplemented_api_check.rs`'s +`supported_module_with_unknown_member_is_rejected` regression witness from axios to +node-fetch (still a `NATIVE_MODULES` member) since it needs a live native module to +demonstrate the #513 invariant. Regenerated `docs/api/perry.d.ts`, `docs/src/api/reference.md`, +and `docs/src/native-libraries/governance.md`'s table; dropped axios's row/section from +`docs/native-libraries.md` and its line from the `perry native list` example in +`docs/src/cli/commands.md`. Updated `workspace-architecture.json`'s baseline counts +(`workspace_members` 83→82, `decision_counts.externalize` 33→32) for the removed crate. + +**Acceptance test — what a plain axios import needs**: a plain `import axios from "axios"` +with **no `perry.compilePackages` entry for axios at all** compiles and runs correctly — +verified against a live `node:http` GET/POST round-trip (`tests/release/packages/axios-get`, +whose `package.json` already had no `compilePackages` block and needed no new one) and a +second from-scratch repro. Perry's default "compile npm package source when no native +binding claims the specifier" path picks up axios and its full transitive dependency graph +(agent-base, https-proxy-agent, follow-redirects, form-data, combined-stream, mime-types, +debug, and the rest — the same ~26 packages the earlier `perry.compilePackages`-forced +probe used) automatically; `perry compile` reports "130 module(s): 130 native, 0 JavaScript" +for them. **No `package.json` configuration beyond a plain `"axios"` dependency + `npm +install` is required** — the `perry.compilePackages` list from the original probe is not +needed post-removal. + +Validated: `cargo test` green for `perry-api-manifest` (41), `perry-hir` (all suites, 0 +failed), `perry-stdlib` (139, `RUST_TEST_THREADS=1`), `perry` (1136 lib tests + the 5 +integration tests whose comments mention axios: `incoming_message_pipe`, +`issue_10662_namespace_export_equals_fallback`, `issue_5174_headers_http_pump_hang`, +`response_stream_body_pull` — none depend on axios functionally, all green); the full +`crates/perry/tests/*.rs` integration sweep was not run (unrelated to this change, and +each fixture takes ~3 min on the shared build host — CI's `e2e-scoped` only runs +integration tests named by the diff, which is none here). `run_lint_gates.sh +SKIP_COMPILE_GATES=1`: 76 of 77 script gates pass; the one red +("Public benchmark evidence freshness") is pre-existing on every PR. `cargo fmt --all -- +--check` clean. Two pre-existing, unrelated breakages on the base branch (confirmed via +`git stash` A/B, not touched here): `perry-codegen`'s test target fails to compile +(`ImportedClass` missing a field in two unrelated test files) and `perry-runtime`'s +`--tests` build carries one pre-existing dead-code warning in `box.rs`. diff --git a/changelog.d/10747-box-captured-local-types.md b/changelog.d/10747-box-captured-local-types.md new file mode 100644 index 0000000000..9f909c4fc1 --- /dev/null +++ b/changelog.d/10747-box-captured-local-types.md @@ -0,0 +1,57 @@ +### Fixed + +An array local captured by a nested closure read back `undefined` in its own +declaring scope, once `Array.prototype` had ever carried an indexed property. +The closure kept seeing the correct array, so the two storages for one binding +disagreed and `peek() === dest` was false. The trigger is rare — something must +put an indexed property on `Array.prototype`, which arms a monotone deopt latch +that deleting the property does not clear — but the affected shape, an array +local captured by a closure, is ordinary code, and the symptom is a silently +empty-looking variable rather than a crash. + +`0ed806587c` (#10488) added a `ctx.local_types` refresh to the redeclaration +branch of `lower_let`, so `is_numeric_expr` and `static_type_of` would stop +disagreeing about a hoisted `var`. **It fixed that desync for hoisted `var` and +introduced a new one for box-captured locals.** A captured local reaches that +same branch without any redeclaration in the source: `Stmt::PreallocateBoxes` +registers the id up front, so its one real `Stmt::Let` finds `ctx.locals` +already populated and lands there. For such an id the refined type describes the +VALUE while the slot holds a box pointer, and the `local_types` readers then +lower reads as raw local loads instead of going through `js_box_get_bits`. + +The fix skips the refresh when `ctx.boxed_vars` holds the id — an existing +`FnCtx` field already in scope, documented as the set whose `LocalGet` unboxes +through `js_box_get_bits`. A hoisted `var` is unboxed unless separately +captured, so #10488 keeps its fix; `test_gap_10488_var_array_void_compare` and +both `let_stmt_var_redeclare_tests` stay green. + +A box-captured local now keeps whatever type the predefine recorded, normally +`Any`, so it can lose a fast path. That is the conservative direction — the cost +is a fast path, never correctness — and it is not a regression against any +working behaviour: before `0ed806587c` these locals got no refinement at all, +and after it they got one that described the value while the slot held a box +pointer. If refinement for captured locals is wanted later, the right shape is a +box-aware type, not this line. + +Found via `test_gap_array_side_mask_covers_a_pointer_stored_at_a_late_index`, +which regressed in merge train 219. Despite its name and its companions living +in `gc/tests/`, it is not a GC bug: reading the slot *before* the `gc()` showed +it already `undefined`, which removed the collector in one probe. `slice` +passing while `splice` failed was an ordering artefact — the first invocation in +a process passes because the latch is not yet armed. Attributed by bisect over +six builds, each with its runtime stamp checked against the commit under test, +narrowing train 219 to `0ed806587c`. A plain revert was measured and rejected: +it fixes this bug but reds `test_gap_10488`, moving the failure rather than +clearing it. + +Verified on one 871-fixture gap-suite invocation: 864 pass, 7 fail, 0 compile +fail, 0 crash. All 7 are pre-existing — the six standing `gap_snapshot.json` +entries in exactly their recorded state, plus `test_gap_9592_child_timeout_threads` +(#10730, a macOS fixture-portability bug). `test_gap_10488_var_array_void_compare` +(position 46), the new `test_gap_10727_captured_array_local_proto_index` (59) and +`test_gap_array_side_mask_...` (353) all passed in that same pass. + +Note for the next change to `crates/perry-codegen/src/stmt/let_stmt.rs`: it is +now at exactly 2000 lines, the `check_file_size.sh` cap. It was at 1999 before +this change and the combined #10488/#10727 comment was condensed to fit the +guard in at net +1 line. Anything further needs the file split first. diff --git a/changelog.d/10749-require-main-entry-only.md b/changelog.d/10749-require-main-entry-only.md new file mode 100644 index 0000000000..7c7cb43655 --- /dev/null +++ b/changelog.d/10749-require-main-entry-only.md @@ -0,0 +1,32 @@ +Fixed `require.main === module` being trivially `true` in **every** compiled +CommonJS module, not just the process entry point (#10735). +`cjs_wrap`'s preamble unconditionally emitted `require.main = module;`, so +the standard "am I the entry, or merely imported?" idiom took its CLI +branch in every dependency that used it — including bundled packages +(dotenv 18.0.1's `dist/index.cjs` is a confirmed real-world example). + +The fix threads the compiler's existing entry-module knowledge (the same +comparison `import.meta.main` uses) through `cjs_wrap`, but that alone is +insufficient: `cjs_wrap` transpiles a statically-known +`require('./relative')` into a hoisted ESM import, and ESM import +evaluation runs a module's static-import dependencies *before* the +importing module's own top-level code. So a CJS entry's own dependencies +initialize before the entry's own preamble runs, which means "the entry +publishes `require.main` in its own preamble" is too late for any +dependency reached via a hoisted static import. Fixed by publishing a +placeholder object as the shared "main module" from the program's `main()` +itself, before any module's `__init` runs at all (gated on the entry being +CJS-wrapped, so an ESM entry correctly leaves `require.main` `undefined` +for CJS modules it imports); the entry later reclaims that exact object +and fills in its real fields, preserving identity for dependencies that +captured `require.main` before the entry's own code ran. + +The new runtime-side cache backing this (`CJS_MAIN_MODULE`) is registered +with the GC's mutable-root-scanner machinery and verified under forced +evacuation (`PERRY_GC_SCHEDULE_SEED`/`PERRY_GC_FORCE_EVACUATE`/ +`PERRY_GC_PROTECT_FROMSPACE`): the placeholder moved, the cache followed +it, and every identity assertion held across 8,006 forced collections. A +new test (`gc::tests::cjs_main_module`) asserts the rewrite counter is +non-zero under a real evacuating minor, so a future regression that stops +rewriting the cache fails a test instead of silently reintroducing this +bug's failure mode. diff --git a/changelog.d/PENDINGCS-compile-smoke-known.md b/changelog.d/PENDINGCS-compile-smoke-known.md index 0bbb9266d0..3f9ccf1f0e 100644 --- a/changelog.d/PENDINGCS-compile-smoke-known.md +++ b/changelog.d/PENDINGCS-compile-smoke-known.md @@ -1,15 +1,18 @@ -**ci: tolerate two known compile-smoke failures (#9470)** +**ci: tolerate one known compile-smoke failure (#9470)** `compile-smoke` is in `full-suite-gate`'s `needs` and exits on `FAIL -gt 0` with -no allowlist, so two long-standing failures were blocking **every** release cut: +no allowlist, so a long-standing failure was blocking **every** release cut: ``` -Compile smoke: 1359 passed, 2 failed, 67 skipped -- test_issue_340_axios_response_props +Compile smoke: 1360 passed, 1 failed, 67 skipped - test_issue_414_mysql_query_params ``` -Both are the tokio-coherence refusal. Auto-optimize rebuilds the stdlib static +(`test_issue_340_axios_response_props` — the other case this note originally +tracked — was removed along with the native axios binding; see +`changelog.d/10679-axios-native-binding-removal.md`, PR #10679.) + +It is the tokio-coherence refusal. Auto-optimize rebuilds the stdlib static into `target/perry-auto-/` **without** the ext wrappers in the same cargo invocation (`optimized_libs/driver.rs:846-857` passes only `-p perry-runtime-static -p perry-stdlib-static --no-default-features`), so @@ -32,7 +35,7 @@ The list is self-policing, verified in all four directions: | case | result | |---|---| -| exactly the 2 known | passes | +| exactly the 1 known | passes | | known + a NEW failure | **fails**, naming the new one | | a known one now passes | **fails** as a stale entry | | all pass | **fails** as stale entries | diff --git a/crates/perry-api-manifest/src/emit.rs b/crates/perry-api-manifest/src/emit.rs index 5a3945493b..d42c99dc54 100644 --- a/crates/perry-api-manifest/src/emit.rs +++ b/crates/perry-api-manifest/src/emit.rs @@ -203,8 +203,6 @@ pub fn emit_dts(_perry_version: &str) -> String { // Followup under #466 will tighten this when signature data lands. let mut emitted_fn_names: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut emitted_static_methods = Vec::new(); - let mut callable_default = None; for e in entries.iter().filter(|e| { matches!( e.kind, @@ -221,24 +219,19 @@ pub fn emit_dts(_perry_version: &str) -> String { continue; } if e.name == "default" { - if *module == "axios" { - callable_default = Some(*e); - } else { - let _ = writeln!(out, " /** {}{} */", source_dts_tag(e), stub_dts_suffix(e)); - let _ = writeln!(out, " export default function {};", render_signature(e)); - } + let _ = writeln!(out, " /** {}{} */", source_dts_tag(e), stub_dts_suffix(e)); + let _ = writeln!(out, " export default function {};", render_signature(e)); continue; } - emitted_static_methods.push(*e); let _ = writeln!(out, " /** {}{} */", source_dts_tag(e), stub_dts_suffix(e)); let signature = render_signature(e); if is_ts_reserved_word(e.name) { - // Reserved words (e.g. `axios.delete`) can't appear as - // a function declaration's name — `tsc` rejects - // `export function delete(...)` with TS1359 (#526). - // Declare under an underscored alias and re-export with - // the original name; the `as ` rename slot - // accepts arbitrary identifiers. + // Reserved words (e.g. a method literally named `delete`) + // can't appear as a function declaration's name — `tsc` + // rejects `export function delete(...)` with TS1359 + // (#526). Declare under an underscored alias and + // re-export with the original name; the `as ` + // rename slot accepts arbitrary identifiers. let alias = format!("_{}", e.name); let _ = writeln!(out, " function {}{};", alias, signature); let _ = writeln!(out, " export {{ {} as {} }};", alias, e.name); @@ -247,35 +240,6 @@ pub fn emit_dts(_perry_version: &str) -> String { } } - if let Some(default) = callable_default { - let _ = writeln!( - out, - " /** {}{} */", - source_dts_tag(default), - stub_dts_suffix(default) - ); - if emitted_static_methods.is_empty() { - let _ = writeln!( - out, - " export default function {};", - render_signature(default) - ); - } else { - let signature = render_signature(default).replacen("): ", ") => ", 1); - let _ = writeln!(out, " const _default: ({}) & {{", signature); - for method in emitted_static_methods { - let target = if is_ts_reserved_word(method.name) { - format!("_{}", method.name) - } else { - ts_ident(method.name) - }; - let _ = writeln!(out, " {}: typeof {};", ts_ident(method.name), target); - } - let _ = writeln!(out, " }};"); - let _ = writeln!(out, " export default _default;"); - } - } - let _ = writeln!(out, "}}"); let _ = writeln!(out); } @@ -806,47 +770,6 @@ mod tests { ); } - /// #526 acceptance: a method named after a TS reserved word must - /// not surface as `export function (...)` — `tsc` errors - /// out with TS1359. The emitter routes through the - /// `function _delete; export { _delete as delete }` alias pattern - /// so a fresh `perry init` project's `tsc -p .` succeeds. - #[test] - fn dts_axios_delete_does_not_use_reserved_word_as_fn_name() { - let dts = emit_dts("test"); - let block_start = dts.find("declare module \"axios\"").expect("axios block"); - let after = &dts[block_start..]; - let block_end = after.find("\n}\n").expect("block end"); - let block = &after[..block_end]; - assert!( - !block.contains("export function delete("), - "axios.delete must not be emitted as `export function delete(` (TS1359)\nblock: {}", - block - ); - assert!( - block.contains("function _delete(") && block.contains("_delete as delete"), - "axios.delete should use the `function _delete; export {{ _delete as delete }}` \ - alias pattern\nblock: {}", - block - ); - } - - #[test] - fn dts_axios_default_export_exposes_static_methods() { - let dts = emit_dts("test"); - let block_start = dts.find("declare module \"axios\"").expect("axios block"); - let after = &dts[block_start..]; - let block_end = after.find("\n}\n").expect("block end"); - let block = &after[..block_end]; - assert!( - block.contains("get: typeof get;") - && block.contains("delete: typeof _delete;") - && block.contains("export default _default;"), - "the callable axios default must expose its static methods\nblock: {}", - block - ); - } - /// Defense-in-depth for #526: every reserved word the emitter /// recognizes should round-trip through the alias pattern, so /// future manifest additions (e.g. `axios.try`, `axios.new`) don't diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 09604c36e7..7ea8ac5320 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -39,7 +39,6 @@ pub const NATIVE_MODULES: &[&str] = &[ // iovalkey: the Valkey fork of ioredis (valkey-io/iovalkey), served by the // same perry-ext-ioredis surface — see well_known_bindings.toml. "iovalkey", - "axios", // HTTP client (routes onto the native fetch/http stack) "node-fetch", // WHATWG fetch client "ws", // WebSocket client/server "zlib", // (Node builtin) gzip/deflate/brotli/zstd compression diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index e16285f1d1..ea6315342a 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -277,20 +277,6 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // method surface; only the constructor's default protocol differs. class("https", "Agent"), method("https", "Agent", false, None), - // --- axios (perry-ext-axios) — the npm `axios` HTTP client surface. - // The default export is callable (`axios(config)`); both flow - // through perry-ext-axios's `js_axios_*` symbols. --- - method("axios", "default", false, None), - method("axios", "get", false, None), - method("axios", "post", false, None), - method("axios", "put", false, None), - method("axios", "delete", false, None), - method("axios", "patch", false, None), - method("axios", "head", false, None), - method("axios", "options", false, None), - method("axios", "request", false, None), - method("axios", "create", false, None), - method("axios", "all", false, None), // --- node-fetch (perry-ext-fetch) — also exposes the Web Fetch // API classes (Headers, Request, Response, Blob, FormData). --- method("node-fetch", "default", false, None), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 484c6d0176..17e6f1d24b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -749,6 +749,14 @@ pub(super) fn compile_module_entry( ], ); } + // #10735: publish the shared `require.main` placeholder before + // ANY module's `__init` below runs (those are this CJS entry's + // OWN static imports, which ESM eval order runs before the + // entry's own preamble). See the callee's doc comment. Skipped + // for an ESM entry, which must leave `require.main` `undefined`. + if crate::collectors::is_cjs_wrapped_module(hir) { + blk.call_void("js_bootstrap_cjs_main_module_placeholder", &[]); + } for (index, prefix) in non_entry_module_prefixes.iter().enumerate() { if cross_module.deferred_module_prefixes.contains(prefix) { continue; diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index 5fa5d09881..4c2b89861c 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -173,6 +173,23 @@ .block() .call(DOUBLE, "js_has_path_module", &[(DOUBLE, &path)])); } + // #10735: publish/read the shared CJS "main module" — + // `require.main` for the process entry module vs. for every + // other CJS-wrapped module. See `cjs_wrap/wrap.rs` and + // `perry-runtime::module_require::{js_set_cjs_main_module, + // js_get_cjs_main_module}`. + "setCjsMainModule" => { + let module = args.first().map_or_else( + || Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))), + |arg| lower_expr(ctx, arg), + )?; + ctx.block() + .call_void("js_set_cjs_main_module", &[(DOUBLE, &module)]); + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } + "getCjsMainModule" => { + return Ok(ctx.block().call(DOUBLE, "js_get_cjs_main_module", &[])); + } // #10360: seeded into every module init under `--platform bun` // so the runtime can follow Bun where its Web APIs differ from // Node's (e.g. the Response null-body-status check). diff --git a/crates/perry-codegen/src/lower_call/options/fetch.rs b/crates/perry-codegen/src/lower_call/options/fetch.rs index 63e1f6dd84..103c502d49 100644 --- a/crates/perry-codegen/src/lower_call/options/fetch.rs +++ b/crates/perry-codegen/src/lower_call/options/fetch.rs @@ -10,7 +10,7 @@ use anyhow::Result; use perry_hir::Expr; use super::get_raw_string_ptr; -use crate::expr::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx}; +use crate::expr::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, FnCtx}; use crate::nanbox::double_literal; use crate::types::{DOUBLE, I1, I64}; @@ -157,61 +157,6 @@ pub(in crate::lower_call) fn lower_fetch_native_method( } } - // ── axios: static HTTP method calls ── - // Must be before the receiver guard — these are receiver-less calls. - if module == "axios" && object.is_none() { - let url_box = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let url_handle = unbox_to_i64(blk, &url_box); - match method { - "get" | "head" | "options" => { - let rt_fn = match method { - "get" => "js_axios_get", - "head" => "js_axios_head", - _ => "js_axios_options", - }; - let promise = blk.call(I64, rt_fn, &[(I64, &url_handle)]); - return Ok(Some(nanbox_pointer_inline(blk, &promise))); - } - "delete" => { - let promise = blk.call(I64, "js_axios_delete", &[(I64, &url_handle)]); - return Ok(Some(nanbox_pointer_inline(blk, &promise))); - } - "post" | "put" | "patch" => { - // #598: pass the body as a NaN-boxed f64 instead of - // unboxing to i64. Pre-fix the unbox produced a raw - // pointer the runtime read as `*const StringHeader` - // — for an object literal the pointer was a real - // ObjectHeader, the runtime read its bytes as a - // StringHeader (length / refcount / data prefix), - // and the request body became `^@^B^@^@H...` (the - // ObjectHeader struct followed by the first character - // of the stringified field). The runtime side now - // detects strings vs everything-else via the NaN-box - // tag and routes through `js_json_stringify`. - let body_box = if args.len() > 1 { - lower_expr(ctx, &args[1])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let rt_fn = match method { - "post" => "js_axios_post", - "put" => "js_axios_put", - _ => "js_axios_patch", - }; - let promise = - ctx.block() - .call(I64, rt_fn, &[(I64, &url_handle), (DOUBLE, &body_box)]); - return Ok(Some(nanbox_pointer_inline(ctx.block(), &promise))); - } - _ => {} - } - } - // Web Streams static factories. if module == "readable_stream" && object.is_none() && method == "from" { let iterable = if !args.is_empty() { @@ -1259,44 +1204,5 @@ pub(in crate::lower_call) fn lower_fetch_native_method( } } - // ── axios: response property access (response.status, .data, .statusText, .headers) ── - if module == "axios" { - if let Some(recv) = object { - let recv_handle = lower_expr(ctx, recv)?; - let blk = ctx.block(); - // The awaited axios response is a Handle (i64) NaN-boxed via - // `JsValue::from_object_ptr(handle as *mut ())` (POINTER_TAG | - // (handle & POINTER_MASK)). Use `unbox_to_i64` to strip the - // tag and recover the bare handle id; calling - // `bitcast_double_to_i64` alone leaves the upper-16 tag bits - // and the runtime's `get_handle::` lookup - // misses, returning 0 / undefined for every property. (#604 - // followup — only surfaced once the listen() hang was fixed.) - let h_i64 = unbox_to_i64(blk, &recv_handle); - match method { - "status" => { - let status = blk.call(DOUBLE, "js_axios_response_status", &[(I64, &h_i64)]); - return Ok(Some(status)); - } - "statusText" => { - let str_ptr = blk.call(I64, "js_axios_response_status_text", &[(I64, &h_i64)]); - return Ok(Some(nanbox_string_inline(blk, &str_ptr))); - } - "data" => { - // Use the auto-parsed variant (JSON when the body - // looks like JSON, raw string otherwise) so - // `r.data.ok` / `r.data[0]` work the same way as - // in npm `axios`. The function returns a NaN-boxed - // f64 directly; no need to nanbox here. (#604 - // followup — only surfaced once listen() hang fix - // unblocked the axios chain.) - let v = blk.call(DOUBLE, "js_axios_response_data_parsed", &[(I64, &h_i64)]); - return Ok(Some(v)); - } - _ => {} - } - } - } - Ok(None) } diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index e8f1bfd637..d1b723a377 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -409,6 +409,15 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_has_path_module", DOUBLE, &[DOUBLE]); // #10360: `--platform bun` marker (see `__perry_runtime.setBunPlatform`). module.declare_function("js_set_bun_platform", VOID, &[]); + // #10735: shared CJS "main module" (`require.main`) — the entry module + // publishes, every other CJS module reads back. See + // `__perry_runtime.setCjsMainModule` / `.getCjsMainModule`. + module.declare_function("js_set_cjs_main_module", VOID, &[DOUBLE]); + module.declare_function("js_get_cjs_main_module", DOUBLE, &[]); + // #10735: allocates and publishes the placeholder `require.main` object + // BEFORE any module's `__init` runs (called directly from `main()`, not + // from generated module JS — see `codegen::entry::compile_module_entry`). + module.declare_function("js_bootstrap_cjs_main_module_placeholder", VOID, &[]); // Next.js wall 54 (part 2): register a Deferred module's `__init` address by // path so a runtime `require(absolutePath)` can trigger its lazy init. module.declare_function("js_register_path_init", VOID, &[PTR, I64, I64]); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index c600e17d87..05bf3a210f 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -25,7 +25,7 @@ use web::declare_web; /// Stdlib / FFI runtime functions. Without these declarations, user code /// that touches any of the third-party stdlib modules (http, mysql2, pg, -/// redis, mongodb, bcrypt, jsonwebtoken, axios, sharp, cron, WebSocket, +/// redis, mongodb, bcrypt, jsonwebtoken, sharp, cron, WebSocket, /// zlib, etc.) emits `use of undefined value '@js_*'` at clang -c time /// because the IR references the name without a preceding `declare`. /// @@ -36,7 +36,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { declare_net_http(module); // PostgreSQL, Redis/ioredis, MongoDB, SQLite, OS, Crypto, Nanoid. declare_data_stores(module); - // bcrypt/argon2, perry/ads, perry/thread, JWT, axios, sharp, cron, + // bcrypt/argon2, perry/ads, perry/thread, JWT, sharp, cron, // async_hooks/AsyncLocalStorage, DisposableStack, zlib, Buffer, // child_process, cheerio. declare_third_party(module); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index ff029c6f90..22d20a3b74 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -1,5 +1,5 @@ //! Third-party package stdlib FFI declarations (extracted from stdlib_ffi.rs): -//! bcrypt/argon2, perry/ads, perry/thread, jsonwebtoken, axios, sharp, cron, +//! bcrypt/argon2, perry/ads, perry/thread, jsonwebtoken, sharp, cron, //! async_hooks/AsyncLocalStorage, DisposableStack, zlib, Buffer, child_process, //! cheerio. @@ -63,30 +63,13 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_perry_native_f32", DOUBLE, &[DOUBLE]); module.declare_function("js_perry_native_f64", DOUBLE, &[DOUBLE]); - // ========== axios / node-fetch ========== - module.declare_function("js_axios_create", DOUBLE, &[I64]); - module.declare_function("js_axios_delete", I64, &[I64]); - module.declare_function("js_axios_get", I64, &[I64]); - module.declare_function("js_axios_head", I64, &[I64]); - module.declare_function("js_axios_options", I64, &[I64]); + // ========== node-fetch ========== // #598: body arg is a NaN-boxed f64 (DOUBLE) so the runtime can // distinguish strings from objects via the tag and JSON.stringify // non-string bodies. Pre-fix this was I64 (raw unboxed pointer) - // which had no way to tell `axios.post(url, "raw json")` from - // `axios.post(url, {a: 1})`. - module.declare_function("js_axios_post", I64, &[I64, DOUBLE]); - module.declare_function("js_axios_put", I64, &[I64, DOUBLE]); - module.declare_function("js_axios_patch", I64, &[I64, DOUBLE]); - module.declare_function("js_axios_request", I64, &[I64]); - module.declare_function("js_axios_response_status", DOUBLE, &[I64]); - module.declare_function("js_axios_response_status_text", I64, &[I64]); - module.declare_function("js_axios_response_data", I64, &[I64]); // Issue #604 followup — JSON-auto-parsing variant of `.data`. Returns // a NaN-boxed JSValue (parsed object/array/number/bool/null when the // response body is JSON, raw string otherwise) so `r.data.ok` works - // the same way as npm `axios` does for `application/json` responses. - module.declare_function("js_axios_response_data_parsed", DOUBLE, &[I64]); - // ========== sharp / image ========== module.declare_function("js_sharp_auto_orient", I64, &[I64]); module.declare_function("js_sharp_avif", I64, &[I64, DOUBLE]); diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index c2d71f73ac..e99810badc 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -383,22 +383,22 @@ pub(crate) fn lower_let( ctx, &perry_hir::Expr::LocalSet(id, Box::new(init_expr.clone())), )?; - // #10488: a hoisted `var`'s real declaration reaches this - // redeclaration branch (#1803 predefine-then-declare shape) and - // returns below before the fresh-declaration path's - // `ctx.local_types.insert` ever runs. `proven_local_types` a few - // lines up IS refreshed per-site, so `is_numeric_expr` (which - // consults it via `stable_local_type_proof`) sees this - // declaration's more specific type — but `local_types` keeps - // whatever the FIRST (predefine) site declared, normally `Any`. - // That desyncs `is_numeric_expr` from `static_type_of` / - // `expr_may_return_boxed_value_from_raw_f64_fallback`, which both - // read `local_types`: a strict-equality compare against an - // out-of-bounds/hole array read was treated as definitely-numeric - // (a bare `fcmp`, which cannot represent `undefined`) instead of - // falling back to a boxed compare. Refresh `local_types` here too - // so both predicates agree on this local's current type. - ctx.local_types.insert(id, refined_ty.clone()); + // #10488: a hoisted `var`'s real declaration reaches this branch + // (#1803 predefine-then-declare) and returns before the + // fresh-declaration path's `insert` runs, desyncing + // `is_numeric_expr` from the `local_types` readers. Refresh here. + // + // #10727: but NOT when the storage is a box. A captured local + // reaches this branch with no source redeclaration -- + // `PreallocateBoxes` registers the id up front -- and the refined + // type then describes the VALUE while the slot holds a box + // pointer, so reads lower as raw local loads instead of + // `js_box_get_bits`: the declaring scope read `undefined` while a + // closure over the same binding saw the real value. A hoisted + // `var` is unboxed unless captured, so #10488 keeps its fix. + if !ctx.boxed_vars.contains(&id) { + ctx.local_types.insert(id, refined_ty.clone()); + } } else if ctx.tdz_boxes.remove(&id) { // No-init reuse (`let x;`) of a TDZ-seeded box must still end the // dead zone by clearing the sentinel to `undefined`; otherwise a diff --git a/crates/perry-ext-axios/Cargo.toml b/crates/perry-ext-axios/Cargo.toml deleted file mode 100644 index 889c76e9da..0000000000 --- a/crates/perry-ext-axios/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "perry-ext-axios" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `axios` HTTP client — uses only `perry-ffi`. First HTTP-client port (Phase 5 step 13)." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -reqwest = { workspace = true } -tokio = { workspace = true } - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-axios/src/lib.rs b/crates/perry-ext-axios/src/lib.rs deleted file mode 100644 index 001bfb65e0..0000000000 --- a/crates/perry-ext-axios/src/lib.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Native bindings for the npm `axios` HTTP client. -//! -//! Phase 5 step 13 — first HTTP-client wrapper port. Uses -//! perry-ffi v0.5.x's full surface: handle registry + -//! spawn_blocking + JsPromise + JsValue. Reqwest under the hood -//! (same as perry-stdlib's existing axios copy). -//! -//! Functionally identical to `crates/perry-stdlib/src/axios.rs`. - -use perry_ffi::{ - alloc_string, get_handle, json_stringify, read_string, register_handle, spawn_blocking, - with_handle, Handle, JsPromise, JsString, JsValue, Promise, StringHeader, -}; - -/// #598: read the body argument as a JSON string. axios in npm-land -/// accepts the body as either a string (sent as-is) or any JS value -/// (JSON.stringify'd before send). Pre-fix Perry's FFI took a raw -/// `*const StringHeader`, which the codegen produced by unboxing the -/// caller's NaN-boxed value — for an object literal the unboxed -/// pointer was a real `*mut ObjectHeader`, the runtime read it as a -/// `*mut StringHeader`, and the request body became the byte pattern -/// of the ObjectHeader struct followed by the first character of the -/// stringified field. Same shape under bun: `axios.post(url, {a:1})` -/// sends `{"a":1}`. With the new f64 signature, the codegen passes -/// the NaN-boxed value through; here we route strings unchanged and -/// JSON.stringify everything else. -unsafe fn read_body_as_string(value_bits: f64) -> String { - const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; - const SHORT_STRING_TAG: u64 = 0x7FFB_0000_0000_0000; - const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; - const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; - let bits = value_bits.to_bits(); - if bits == TAG_UNDEFINED || bits == TAG_NULL { - return String::new(); - } - let tag = bits & TAG_MASK; - if tag == STRING_TAG || tag == SHORT_STRING_TAG { - // String: read as-is, no JSON quoting. - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader; - let handle = JsString::from_raw(ptr as *mut StringHeader); - return read_string(handle).map(String::from).unwrap_or_default(); - } - // Object / number / array / etc. — JSON.stringify. - let v = JsValue::from_bits(bits); - json_stringify(v).unwrap_or_default() -} - -/// Response handle wrapper. -pub struct AxiosResponseHandle { - pub status: u16, - pub status_text: String, - pub data: String, - /// Issue #627: lower-cased Content-Type header value (without - /// charset suffix), or empty string if absent. `js_axios_response_data_parsed` - /// consults this to decide whether to JSON-parse the body — matches - /// npm axios's content-type-based behavior, replacing v0.5.714's - /// body-shape heuristic which would incorrectly parse a JSON-shaped - /// string body served with `text/plain`. - pub content_type: String, -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Common request driver — runs the reqwest call inside -/// spawn_blocking, packages the response into an -/// `AxiosResponseHandle`, registers it, and resolves the promise -/// with a POINTER_TAG-tagged handle value (issue #340 trick from -/// the original perry-stdlib axios — without the explicit -/// NaN-boxing, the awaiter sees a subnormal float that decays -/// to `undefined` on `r.status` accesses). -fn run_request( - method: &'static str, - url_or_err: Result, - build: F, -) -> *mut Promise -where - F: FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder + Send + 'static, -{ - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let url = match url_or_err { - Ok(u) => u, - Err(msg) => { - promise.reject_string(msg); - return raw; - } - }; - - spawn_blocking(move || { - let result: Result = tokio::runtime::Handle::current() - .block_on(async move { - let client = reqwest::Client::new(); - let request = build(client, url); - let response = request - .send() - .await - .map_err(|e| format!("{} request failed: {}", method, e))?; - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - // Issue #627: capture Content-Type before consuming the - // body. Lower-case + take the part before `;` so - // `application/json; charset=utf-8` reduces to - // `application/json` for the JSON-parse decision. - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .map(|s| s.split(';').next().unwrap_or(s).trim().to_ascii_lowercase()) - .unwrap_or_default(); - let data = response - .text() - .await - .map_err(|e| format!("Failed to read response body: {}", e))?; - Ok(AxiosResponseHandle { - status, - status_text, - data, - content_type, - }) - }); - match result { - Ok(resp) => { - let handle = register_handle(resp); - // POINTER_TAG-tagged handle value — see #340. - promise.resolve(JsValue::from_object_ptr(handle as *mut ())); - } - Err(msg) => promise.reject_string(&msg), - } - }); - raw -} - -/// `axios.get(url) -> Promise`. -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_axios_get(url_ptr: *const StringHeader) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - run_request("GET", url, |client, url| client.get(&url)) -} - -/// `axios.head(url) -> Promise`. -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_axios_head(url_ptr: *const StringHeader) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - run_request("HEAD", url, |client, url| client.head(&url)) -} - -/// `axios.options(url) -> Promise`. -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_axios_options(url_ptr: *const StringHeader) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - run_request("OPTIONS", url, |client, url| { - client.request(reqwest::Method::OPTIONS, &url) - }) -} - -/// `axios.post(url, data) -> Promise`. -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. `data` is -/// a NaN-boxed JSValue — strings are sent as-is, all other shapes are -/// JSON.stringify'd. See `read_body_as_string` for the routing rule -/// (#598). The signature uses `f64` to match the codegen dispatch's -/// pass-as-double path; Rust's calling convention puts it in d0 / a -/// vector register on AArch64, matching what the codegen emits. -#[no_mangle] -pub unsafe extern "C" fn js_axios_post(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - let body = read_body_as_string(data); - run_request("POST", url, move |client, url| { - client - .post(&url) - .header("Content-Type", "application/json") - .body(body) - }) -} - -/// `axios.put(url, data) -> Promise`. Same body-encoding -/// rule as `axios.post` (#598). -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. `data` is -/// a NaN-boxed JSValue. -#[no_mangle] -pub unsafe extern "C" fn js_axios_put(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - let body = read_body_as_string(data); - run_request("PUT", url, move |client, url| { - client - .put(&url) - .header("Content-Type", "application/json") - .body(body) - }) -} - -/// `axios.delete(url) -> Promise`. -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_axios_delete(url_ptr: *const StringHeader) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - run_request("DELETE", url, |client, url| client.delete(&url)) -} - -/// `axios.patch(url, data) -> Promise`. Same body-encoding -/// rule as `axios.post` (#598). -/// -/// # Safety -/// -/// `url_ptr` must be null or a Perry-runtime `StringHeader`. `data` is -/// a NaN-boxed JSValue. -#[no_mangle] -pub unsafe extern "C" fn js_axios_patch(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let url = read_str(url_ptr).ok_or("Invalid URL"); - let body = read_body_as_string(data); - run_request("PATCH", url, move |client, url| { - client - .patch(&url) - .header("Content-Type", "application/json") - .body(body) - }) -} - -/// `response.status -> number`. -#[no_mangle] -pub extern "C" fn js_axios_response_status(handle: Handle) -> f64 { - if let Some(r) = get_handle::(handle) { - r.status as f64 - } else { - 0.0 - } -} - -/// `response.statusText -> string`. -#[no_mangle] -pub extern "C" fn js_axios_response_status_text(handle: Handle) -> *mut StringHeader { - with_handle::(handle, |r| alloc_string(&r.status_text).as_raw()) - .unwrap_or(std::ptr::null_mut()) -} - -/// `response.data -> string`. Legacy/backwards-compat — returns the -/// raw response body bytes as a perry string. For the JSON-auto-parse -/// path that npm `axios` provides (where `r.data.ok` works directly -/// when the server returns `application/json`), see -/// `js_axios_response_data_parsed` below. -#[no_mangle] -pub extern "C" fn js_axios_response_data(handle: Handle) -> *mut StringHeader { - with_handle::(handle, |r| alloc_string(&r.data).as_raw()) - .unwrap_or(std::ptr::null_mut()) -} - -/// `response.data -> any` — auto-parsed variant. npm `axios` parses -/// the response body as JSON when the response's content-type starts -/// with `application/json`; otherwise it hands back the raw string. -/// Returns an f64 NaN-boxed JSValue: a string for non-JSON, a parsed -/// object/array/number/bool/null for JSON. Returns the string fallback -/// on any parse error so callers don't have to special-case malformed -/// JSON. The TS-side `r.data` getter routes here so `r.data.ok` / -/// `r.data[0]` / etc. work the same way as in node `axios`. Issue -/// #604 followup — only surfaced once the listen() hang was fixed. -#[no_mangle] -pub extern "C" fn js_axios_response_data_parsed(handle: Handle) -> f64 { - // Issue #627: snapshot body + content-type in one with_handle pass to - // avoid two registry lookups + leaking the lock across the FFI call to - // js_json_parse below. - let snapshot = with_handle::(handle, |r| { - (r.data.clone(), r.content_type.clone()) - }); - let (body, content_type) = match snapshot { - Some(s) => s, - None => return f64::from_bits(0x7FFC_0000_0000_0001), // TAG_UNDEFINED - }; - // Issue #627: npm axios parses JSON only when content-type starts with - // `application/json` (with optional `; charset=...`). Pre-fix, perry - // used a body-shape heuristic which would incorrectly parse a JSON- - // looking string body served with `text/plain`. The `+json` suffix - // form (e.g. `application/vnd.api+json`) also gets parsed by npm - // axios per the standard, so accept either shape. - let is_json_ct = content_type == "application/json" || content_type.ends_with("+json"); - if is_json_ct { - // Cross the FFI boundary into the runtime's JSON parser. The - // runtime returns `undefined` (TAG_UNDEFINED) on parse error, - // which we detect and fall through to the raw-string path so - // the user always gets *something* on `r.data`. Note: the - // runtime's `js_json_parse` declares its return type as - // `JSValue` (repr(transparent) over u64), so we declare it - // here as `u64` rather than `f64` to keep the AArch64 ABI on - // the integer register (x0) instead of the float register (d0). - extern "C" { - fn js_json_parse(ptr: *const StringHeader) -> u64; - } - let s = alloc_string(&body); - let parsed_bits = unsafe { js_json_parse(s.as_raw()) }; - const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - if parsed_bits != TAG_UNDEFINED { - return f64::from_bits(parsed_bits); - } - } - // Non-JSON or parse failure — return the raw body as a perry - // string. NaN-boxed via STRING_TAG so the receiver sees it as a - // proper JS string. - let s = alloc_string(&body); - let bits = 0x7FFF_0000_0000_0000_u64 | (s.as_raw() as u64 & 0x0000_FFFF_FFFF_FFFF); - f64::from_bits(bits) -} diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 0944d8cd10..145a34a2f6 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -460,19 +460,6 @@ pub(crate) fn register_native_from_new_and_calls( Some("Connection") } ("pg", "connect") => Some("Client"), - // axios.get/post/put/delete/patch/request — mirror - // the top-level decl arm in lower.rs:4011 so - // `await axios.get(...)` registers the result as - // an axios.Response inside async function bodies. - // Without this, `r.status` / `r.data` fall through - // to generic property dispatch and read the - // raw handle pointer as an ObjectHeader. Issue - // #604 followup — same pattern as the createServer - // registration above. - ( - "axios", - "get" | "post" | "put" | "delete" | "patch" | "request", - ) => Some("Response"), _ => None, }; if let Some(class_name) = class_name { diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index df2868c134..0c2e42be83 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -1245,24 +1245,6 @@ pub fn fix_native_instance_expr_with_locals( Expr::PropertyGet { object, property, .. } => { - if let Expr::LocalGet(local_id) = object.as_ref() { - if matches!(property.as_str(), "status" | "statusText" | "data") - && matches!( - local_id_instances.get(local_id), - Some((module, class)) if module == "axios" && class == "Response" - ) - { - let object_expr = std::mem::replace(object.as_mut(), Expr::Undefined); - *expr = Expr::NativeMethodCall { - module: "axios".to_string(), - class_name: Some("Response".to_string()), - object: Some(Box::new(object_expr)), - method: property.clone(), - args: Vec::new(), - }; - return; - } - } // Recurse into the object first so any nested `$(sel)` Call has // been rewritten to a cheerio NativeMethodCall. fix_native_instance_expr_with_locals(object, native_instances, local_id_instances); @@ -1372,10 +1354,6 @@ pub fn detect_native_instance_creation_with_context( ("node-cron", "schedule") => "CronJob", ("readline", "createInterface") => "Interface", ("bun", "Transpiler") => "Transpiler", - ( - "axios", - "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "request", - ) => "Response", // Issue #1193: `const $ = load(html)` / `loadFragment(html)` // returns the jQuery-like callable used as `$(selector)`. // Tagging the local as CheerioAPI lets the rewriter below diff --git a/crates/perry-hir/src/lower/expr_call/globals.rs b/crates/perry-hir/src/lower/expr_call/globals.rs index 44f4596f55..2551653daa 100644 --- a/crates/perry-hir/src/lower/expr_call/globals.rs +++ b/crates/perry-hir/src/lower/expr_call/globals.rs @@ -351,6 +351,38 @@ pub(super) fn try_global_builtins( args: vec![path], })); } + // #10735: `require.main` must be the process ENTRY module only — + // equal to `module` there, unequal (or `undefined`, when the + // process entry is ESM) everywhere else. The CJS preamble + // (`cjs_wrap/wrap.rs`) emits exactly one of these two calls per + // module depending on whether IT is the compile-time entry: + // the entry publishes its own `module` record as the shared + // "main module"; every other CJS module reads it back instead of + // assigning its OWN `module` (which is the bug — it made + // `require.main === module` trivially true everywhere). + "__perry_set_cjs_main_module" => { + let module = if !args.is_empty() { + args.remove(0) + } else { + Expr::Undefined + }; + return Ok(Ok(Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "setCjsMainModule".to_string(), + args: vec![module], + })); + } + "__perry_get_cjs_main_module" => { + return Ok(Ok(Expr::NativeMethodCall { + module: "__perry_runtime".to_string(), + class_name: None, + object: None, + method: "getCjsMainModule".to_string(), + args: vec![], + })); + } "Symbol" => { // Symbol() / Symbol(description) if args.is_empty() { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 142efb4eb5..0a5f50f6b6 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1007,11 +1007,6 @@ pub(crate) fn lower_module_decl( "http" | "https", "request" | "get", ) => Some("ClientRequest"), - ( - "axios", - "get" | "post" | "put" | "delete" - | "patch" | "request", - ) => Some("Response"), _ => None, }; if let Some(class_name) = class_name { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index bc94fe146c..94ec086fd3 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1027,10 +1027,6 @@ pub(crate) fn lower_stmt( } = inner.as_ref() { let class_name = match (mod_name.as_str(), method.as_str()) { - ( - "axios", - "get" | "post" | "put" | "delete" | "patch" | "request", - ) => Some("Response"), ("mongodb", "connect") => Some("MongoClient"), ("pg", "connect") => Some("Client"), _ => None, diff --git a/crates/perry-hir/tests/axios_response_property_lowering.rs b/crates/perry-hir/tests/axios_response_property_lowering.rs deleted file mode 100644 index e153eb9513..0000000000 --- a/crates/perry-hir/tests/axios_response_property_lowering.rs +++ /dev/null @@ -1,48 +0,0 @@ -use perry_diagnostics::SourceCache; -use perry_hir::{clear_current_module_source, fix_local_native_instances, lower_module}; -use perry_parser::parse_typescript_with_cache; - -#[test] -fn awaited_axios_response_properties_keep_native_dispatch() { - let mut cache = SourceCache::new(); - let parsed = parse_typescript_with_cache( - r#" - import axios from "axios"; - - async function main() { - const response = await axios.get("https://example.com/data"); - const head = await axios.head("https://example.com/data"); - const options = await axios.options("https://example.com/data"); - console.log(`status=${response.status}`); - console.log(`ok=${response.data.ok}`); - console.log(`head=${head.status}:${head.data}`); - console.log(`options=${options.status}:${options.data}`); - } - "#, - "/tmp/axios_response_property_lowering.ts", - &mut cache, - ) - .expect("parse"); - let mut module = lower_module( - &parsed.module, - "test", - "/tmp/axios_response_property_lowering.ts", - ) - .expect("lower"); - clear_current_module_source(); - fix_local_native_instances(&mut module); - - let main = module - .functions - .iter() - .find(|function| function.name == "main") - .expect("main function"); - let hir = format!("{:#?}", main.body); - assert!( - hir.matches("class_name: Some(").count() == 6 - && hir.matches("\"Response\"").count() == 6 - && hir.contains("method: \"status\"") - && hir.contains("method: \"data\""), - "awaited Axios response properties lost native dispatch:\n{hir}" - ); -} diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index 14fa823fbc..62419f4129 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -319,8 +319,9 @@ fn os_eol_and_path_sep_compile() { /// As of #513, every module in `NATIVE_MODULES` has at least one /// manifest entry, so the permissive fall-through is unreachable for -/// supported modules. `axios.foo` (which used to silently compile under -/// the pre-#513 zero-entries-permissive shape) now errors. +/// supported modules. `node-fetch`'s bogus member (which used to +/// silently compile under the pre-#513 zero-entries-permissive shape) +/// now errors. /// /// The drift test `every_native_module_has_at_least_one_manifest_entry` /// in `crates/perry-codegen/tests/manifest_consistency.rs` makes this @@ -330,14 +331,14 @@ fn os_eol_and_path_sep_compile() { fn supported_module_with_unknown_member_is_rejected() { let result = lower_result_strict( r#" - import axios from "axios"; - const x = axios.foo; + import fetch from "node-fetch"; + const x = fetch.foo; "#, ); - let err = result.expect_err("axios.foo should error post-#513"); + let err = result.expect_err("node-fetch.foo should error post-#513"); assert!( - err.contains("axios.foo") && err.contains("not implemented"), - "expected error naming `axios.foo` and `not implemented`, got: {err}" + err.contains("fetch.foo") && err.contains("not implemented"), + "expected error naming `fetch.foo` and `not implemented`, got: {err}" ); } diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 9930d058b8..93e20e8bb3 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -103,11 +103,10 @@ pub use v8_stubs::{ }; pub use v8_stubs::{ - js_argon2_hash_options, js_axios_create, js_axios_request, js_lodash_ends_with, - js_lodash_escape, js_lodash_includes, js_lodash_lower_first, js_lodash_replace, - js_lodash_split, js_lodash_start_case, js_lodash_starts_with, js_lodash_unescape, - js_lodash_upper_first, js_ratelimit_create, js_sharp_negate, js_sharp_quality, - js_sharp_to_format, + js_argon2_hash_options, js_lodash_ends_with, js_lodash_escape, js_lodash_includes, + js_lodash_lower_first, js_lodash_replace, js_lodash_split, js_lodash_start_case, + js_lodash_starts_with, js_lodash_unescape, js_lodash_upper_first, js_ratelimit_create, + js_sharp_negate, js_sharp_quality, js_sharp_to_format, }; #[cfg(test)] diff --git a/crates/perry-runtime/src/closure/v8_stubs.rs b/crates/perry-runtime/src/closure/v8_stubs.rs index 1dbca0403d..b5d1fb27a8 100644 --- a/crates/perry-runtime/src/closure/v8_stubs.rs +++ b/crates/perry-runtime/src/closure/v8_stubs.rs @@ -1,5 +1,5 @@ //! V8-interop no-op stubs and AOT no-op stubs for unconditionally-declared -//! FFI symbols (lodash, axios, argon2, sharp, ratelimit). +//! FFI symbols (lodash, argon2, sharp, ratelimit). // V8 interop no-op stubs. Perry no longer ships a runtime JS engine: the // `perry-jsruntime` crate (V8 via `deno_core`) that used to provide the real @@ -288,14 +288,6 @@ pub extern "C" fn js_lodash_upper_first() -> f64 { 0.0 } #[no_mangle] -pub extern "C" fn js_axios_create() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_request() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_argon2_hash_options() -> i64 { 0 } diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index c062e634f4..ae21b86be8 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -999,6 +999,11 @@ pub fn gc_init() { // Runtime path-module exports and cached initialization errors live in a // per-heap Rust registry, so moving GC must mark and rewrite them. reg_scanner!(crate::module_require::scan_module_path_roots_mut); + // #10735: the shared CJS "main module" (`require.main` / Node's + // `process.mainModule`) is a raw heap pointer cached in a thread-local + // outside any shadow frame — a moving collection must mark and rewrite + // it like any other mutable root. + reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut); reg_budgeted_scanner!( promise_mutable_root_scanner, crate::promise::scan_promise_roots_mut_step, diff --git a/crates/perry-runtime/src/gc/tests/cjs_main_module.rs b/crates/perry-runtime/src/gc/tests/cjs_main_module.rs new file mode 100644 index 0000000000..b6fd7269d2 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/cjs_main_module.rs @@ -0,0 +1,58 @@ +//! #10735 witness: `scan_cjs_main_module_root_mut`'s rewrite counter is a +//! diagnostic nothing else reads, which is exactly the kind of thing that +//! rots silently -- a future change that stops rewriting the cache would +//! report `total_rewrites=0` forever while looking perfectly healthy. This +//! test forces a REAL evacuating collection (not a passive scan that never +//! sees a move) and asserts both that the placeholder's address actually +//! changed and that the counter tracked it, per CLAUDE.md's "a gate must +//! assert its subject was live" rule. +//! +//! Lives here rather than in `module_require.rs` because a real evacuating +//! minor needs `CopyingNurseryTestGuard`'s preflight setup (generated write +//! barriers reporting "active", the conservative-full-scan test default +//! turned off, a clean shadow stack / remembered set) -- machinery private +//! to this `gc::tests` tree. That guard also clears the thread's mutable +//! scanner registry so unrelated GC tests see only the roots they install, +//! which would remove the very scanner under test, so this file +//! re-registers it explicitly after constructing the guard. + +use super::super::*; +use super::support::CopyingNurseryTestGuard; + +#[test] +fn placeholder_move_under_forced_evacuation_increments_the_rewrite_counter() { + let _nursery = CopyingNurseryTestGuard::new(0); + let _evac = knob_overrides::ForcedEvacuationTestGuard::on(); + let _diag = GcDiagTestGuard::force_on(); + // `CopyingNurseryTestGuard::new` clears the thread's scanner registry so + // this collection sees exactly the roots the test installs -- put the + // one under test back. + gc_register_mutable_root_scanner(crate::module_require::scan_cjs_main_module_root_mut); + + crate::module_require::js_bootstrap_cjs_main_module_placeholder(); + let before_bits = + crate::module_require::test_cjs_main_module_bits().expect("placeholder must be published"); + let rewrites_before = crate::module_require::test_cjs_main_module_rewrite_count(); + + js_gc_collect(); + + let after_bits = crate::module_require::test_cjs_main_module_bits() + .expect("placeholder must survive the collection"); + let rewrites_after = crate::module_require::test_cjs_main_module_rewrite_count(); + + assert_ne!( + before_bits, after_bits, + "forced evacuation must have moved the placeholder -- if the \ + address is unchanged, this test's premise (a real move happened) \ + is false, and the counter assertion below would be checking \ + nothing (before={before_bits:#x} after={after_bits:#x})" + ); + assert!( + rewrites_after > rewrites_before, + "scan_cjs_main_module_root_mut must have counted the rewrite \ + (before={rewrites_before} after={rewrites_after}); a zero delta \ + here means the cache stopped following the object it caches -- \ + the exact regression #10735's identity guarantee depends on never \ + happening" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 63d50b4fa6..12c8c0fa0b 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -15,6 +15,7 @@ mod buffer_side_tables; mod census; mod census_block_windows; mod census_whole_block; +mod cjs_main_module; mod concat_site; mod contract; mod copy_slot_decode; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 17d92d2018..01c35ad3d6 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -23,6 +23,158 @@ fn undefined() -> f64 { f64::from_bits(TAG_UNDEFINED) } +crate::perry_thread_local! { + /// The process ENTRY module's own CJS `module` record — Node's + /// `require.main` / `process.mainModule`. Set exactly once, by the entry + /// module's own preamble (see `cjs_wrap::wrap_commonjs_with_body_offset` + /// in the compiler), before that module's body runs any `require()` of + /// its own — so every dependency it (transitively) requires observes + /// this already published. Every OTHER CJS-wrapped module reads it back + /// via [`js_get_cjs_main_module`] instead of assigning its own local + /// `module`, which is the #10735 bug this replaces (`require.main === + /// module` was trivially true in every compiled CommonJS module, not + /// just the real entry point). + /// + /// Stays `None` (JS `undefined`) for the lifetime of the heap when the + /// process entry is ESM — matching Node, where a CJS module reached only + /// via `import` from an ESM entry has `require.main === undefined` + /// (there is no CommonJS "main" in that process). + static CJS_MAIN_MODULE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Codegen FFI: publish the process entry module's own CJS `module` record as +/// the shared "main module". Emitted ONCE, in the entry module's preamble, +/// before its body runs any `require()` of a dependency — so every later read +/// on this heap sees it already set. First call wins (idempotent): there +/// should never be a second, but a re-entrant load must not let a later +/// module overwrite the true entry. +#[no_mangle] +pub extern "C" fn js_set_cjs_main_module(module_value: f64) { + CJS_MAIN_MODULE.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some(module_value.to_bits()); + } + }); +} + +/// Codegen FFI: `require.main` for a NON-entry CJS module — the value +/// [`js_set_cjs_main_module`] published, or JS `undefined` if this heap's +/// process entry never called it (an ESM entry, or a heap whose entry point +/// was never CommonJS-wrapped). +#[no_mangle] +pub extern "C" fn js_get_cjs_main_module() -> f64 { + CJS_MAIN_MODULE.with(|slot| slot.borrow().map(f64::from_bits).unwrap_or_else(undefined)) +} + +/// Codegen FFI: emitted ONCE, in `main()`, before ANY module's `__init` runs +/// — including the entry's own, since ESM's static-import evaluation order +/// runs every hoisted dependency's top-level code before the importing +/// module's (see `cjs_wrap`'s `require('./x')` -> `import` hoist). Node's +/// loader sets up `require.main`/`process.mainModule` before invoking the +/// entry's script body at all, so no CommonJS module - not even one loaded +/// before the entry's own preamble textually runs - ever observes it unset. +/// Perry can't replicate that ordering directly (the entry's real `module` +/// record is built by JS the entry's own preamble emits, which necessarily +/// runs LAST among a CJS entry's static imports), so this allocates a bare +/// placeholder object and publishes THAT via [`js_set_cjs_main_module`] +/// up front. Every non-entry module's `require.main` resolves to this same +/// object from heap birth. When the entry's own preamble finally runs, it +/// reclaims this exact object (via [`js_get_cjs_main_module`]) and fills in +/// its real fields in place — same identity throughout, so a dependency that +/// captured `require.main` before the entry ran still `===`-matches the +/// entry's `module` afterward (JS identity survives mutation). +/// +/// Codegen emits the call only when the entry module is itself CJS-wrapped +/// (`collectors::is_cjs_wrapped_module`); an ESM entry never calls this, so +/// [`js_get_cjs_main_module`] stays `undefined` for the lifetime of the heap +/// — matching Node, where a CJS module reached only via `import` from an ESM +/// entry has no CommonJS "main" at all. +#[no_mangle] +pub extern "C" fn js_bootstrap_cjs_main_module_placeholder() { + let placeholder = object_value(js_object_alloc(0, 0)); + js_set_cjs_main_module(placeholder); +} + +/// GC root scanner for [`CJS_MAIN_MODULE`]: a raw heap pointer cached outside +/// any shadow frame, so a moving collection must mark and rewrite it like any +/// other mutable root. Registered in `gc::gc_init` beside +/// `scan_module_path_roots_mut`. +pub fn scan_cjs_main_module_root_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + CJS_MAIN_MODULE.with(|slot| { + if let Some(bits) = slot.borrow_mut().as_mut() { + let rewritten = visitor.visit_nanbox_u64_slot(bits); + // #10735: `visit_nanbox_u64_slot` returns true only when it + // actually rewrote the slot (the cached object moved this + // cycle). A diagnostic-only counter distinguishing "the + // placeholder happened never to move" from "it moved and the + // cache followed it" -- the load-bearing question for this + // holder's identity guarantee, not visible from the generic + // per-cycle counters. + if rewritten && crate::gc::gc_diag_enabled() { + CJS_MAIN_MODULE_REWRITES.with(|c| c.set(c.get() + 1)); + eprintln!( + "[cjs-main-module] rewritten: new_bits={:#018x} total_rewrites={}", + *bits, + CJS_MAIN_MODULE_REWRITES.with(std::cell::Cell::get) + ); + } + } + }); +} + +crate::perry_thread_local! { + /// Diagnostic-only (`PERRY_GC_DIAG=1`) count of how many times + /// [`scan_cjs_main_module_root_mut`] actually rewrote the cached + /// placeholder's bits (i.e. the object moved while cached). Never + /// read for behaviour. + static CJS_MAIN_MODULE_REWRITES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +mod cjs_main_module_tests { + use super::*; + + // Each `#[test]` fn runs on its own harness-spawned thread, so + // `CJS_MAIN_MODULE` (thread-local) starts fresh here regardless of test + // execution order — no explicit reset needed. + + #[test] + fn defaults_to_undefined_when_no_entry_has_published() { + assert_eq!(js_get_cjs_main_module().to_bits(), TAG_UNDEFINED); + } + + #[test] + fn published_value_reads_back_identically() { + let entry_module = string_value("entry-module-marker"); + js_set_cjs_main_module(entry_module); + assert_eq!(js_get_cjs_main_module().to_bits(), entry_module.to_bits()); + } + + #[test] + fn first_publication_wins_a_later_call_cannot_overwrite_it() { + let first = string_value("first-entry"); + let second = string_value("second-entry-should-be-ignored"); + js_set_cjs_main_module(first); + js_set_cjs_main_module(second); + assert_eq!(js_get_cjs_main_module().to_bits(), first.to_bits()); + } +} + +/// Test-only accessors used from `gc::tests::cjs_main_module` (a sibling +/// module tree that cannot see `cjs_main_module_tests`'s items, and needs +/// its own file to reach the `CopyingNurseryTestGuard` apparatus a real +/// forced-evacuation witness test requires -- see that file for why). +#[cfg(test)] +pub(crate) fn test_cjs_main_module_bits() -> Option { + CJS_MAIN_MODULE.with(|s| *s.borrow()) +} + +#[cfg(test)] +pub(crate) fn test_cjs_main_module_rewrite_count() -> u64 { + CJS_MAIN_MODULE_REWRITES.with(std::cell::Cell::get) +} + fn null() -> f64 { f64::from_bits(TAG_NULL) } diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 13cbe05c23..b8e6d4a3bd 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -755,6 +755,56 @@ pub unsafe extern "C" fn js_fetch_or_value_super( return dispatch(this_box); } } + // #10448: `class X extends Transform` (and Readable/Writable/Duplex) + // never called the subclass's `_transform`/`_write`/`_read` override + // unless the heritage was a shape `is_genuine_node_stream_parent` + // recognizes statically (`crates/perry-hir/src/lower_decl/class_decl.rs`) + // — a local alias (`const Alias = Transform`), a namespace member reached + // through a CJS destructured `require('stream')`, or an indirect + // subclass all fell through to the ordinary-call dispatch below, + // which invokes the bound `stream` export as a plain constructor and + // drops the result — `this` stayed an empty object, so `write()` threw + // `ERR_METHOD_NOT_IMPLEMENTED`. Recognize the resolved bound-export + // value here, exactly as the WASI arm above does, and run the same + // runtime shim the static `extends Transform` path already uses + // (`js_node_stream_*_subclass_init`, `crates/perry-codegen/src/expr/write_barrier.rs`'s + // `lower_node_stream_super_init`), so every heritage shape installs the + // override onto `this` identically. + // + // `PassThrough` is deliberately NOT handled here: HIR never recognizes + // it as a node:stream native parent at all, even via a bare import + // (`canonical_native_parent_name` lists Readable/Writable/Duplex/ + // Transform but not PassThrough), so the hidden `_transform` field this + // shim reads is never pre-seeded for ANY `PassThrough` heritage shape — + // that's a separate, deeper HIR-level gap needing its own fix; adding an + // arm here alone was confirmed (empirically) to change nothing. + if let Some((module, method)) = bound_native_parent.as_ref() { + if super::super::native_module::normalize_native_module_alias(module.as_str()) == "stream" { + let opts = if args_len >= 1 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let handled = match method.as_str() { + "Readable" => Some(crate::node_stream::js_node_stream_readable_subclass_init( + this_box, opts, + )), + "Writable" => Some(crate::node_stream::js_node_stream_writable_subclass_init( + this_box, opts, + )), + "Duplex" => Some(crate::node_stream::js_node_stream_duplex_subclass_init( + this_box, opts, + )), + "Transform" => Some(crate::node_stream::js_node_stream_transform_subclass_init( + this_box, opts, + )), + _ => None, + }; + if handled.is_some() { + return undef; + } + } + } // `class X extends Temporal.` (non-spread `super(a, b)`): a Temporal // constructor returns a fresh NaN-boxed cell and does NOT mutate the // implicit `this`, so the ordinary dispatch below would drop that cell and diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index d56b9fd646..57d1141486 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -76,18 +76,17 @@ bundled-commander = [] # perry-stdlib's per-tick bridge into it lives behind `external-fastify-pump`. http-server = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "async-runtime"] -# Web Fetch and Axios compatibility surface. The well-known flip can -# strip this when the external binding owns the imported surface. +# Web Fetch compatibility surface. The well-known flip can strip this +# when the external binding owns the imported surface. # `bundled-streams` rides under the umbrella for backwards-compat # with v0.5.571's `--features http-client` callers (which got # `pub mod streams` transitively). The well-known flip strips # `bundled-streams` directly, and also strips `http-client` when -# axios / node-fetch / http / https is imported through the -# well-known table — both ends move in lockstep. +# node-fetch / http / https is imported through the well-known +# table — both ends move in lockstep. # -# #5174: `http-client` decomposes into `web-fetch` (the Web Fetch API) -# plus the Axios compatibility module. Node HTTP is provided only by -# perry-ext-http. +# #5174: `http-client` decomposes into `web-fetch` (the Web Fetch API). +# Node HTTP is provided only by perry-ext-http. web-fetch = ["dep:reqwest", "async-runtime", "bundled-streams"] http-client = ["web-fetch"] diff --git a/crates/perry-stdlib/src/axios.rs b/crates/perry-stdlib/src/axios.rs deleted file mode 100644 index 2a606e45ba..0000000000 --- a/crates/perry-stdlib/src/axios.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! Axios module -//! -//! Native implementation of the 'axios' npm package using reqwest. -//! Provides HTTP client functionality with a promise-based API. - -use crate::common::{ - get_handle, register_handle, spawn_for_promise, string_from_header_lossy as string_from_header, - Handle, -}; -use perry_runtime::{js_promise_new_cross_thread, js_string_from_bytes, Promise, StringHeader}; - -/// #598: read the body argument as a JSON string. Strings pass -/// through as-is; everything else is JSON.stringify'd via the -/// runtime's `js_json_stringify`. See perry-ext-axios's parallel -/// helper for the full rationale. -unsafe fn body_string_from_value(value_bits: f64) -> String { - const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; - const SHORT_STRING_TAG: u64 = 0x7FFB_0000_0000_0000; - const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; - const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; - let bits = value_bits.to_bits(); - if bits == TAG_UNDEFINED || bits == TAG_NULL { - return String::new(); - } - let tag = bits & TAG_MASK; - if tag == STRING_TAG || tag == SHORT_STRING_TAG { - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader; - return string_from_header(ptr).unwrap_or_default(); - } - // Object / array / number / etc. — JSON.stringify (type_hint=0 - // = auto-detect from NaN-box tag). - extern "C" { - fn js_json_stringify(value: f64, type_hint: u32) -> *mut StringHeader; - } - let str_ptr = js_json_stringify(value_bits, 0); - string_from_header(str_ptr).unwrap_or_default() -} - -/// Response handle wrapper -pub struct AxiosResponseHandle { - pub status: u16, - pub status_text: String, - pub data: String, - pub headers: Vec<(String, String)>, -} - -unsafe fn request_without_body( - url_ptr: *const StringHeader, - method: reqwest::Method, -) -> *mut Promise { - let promise = js_promise_new_cross_thread(); - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - spawn_for_promise(promise as *mut u8, async move { - Err::("Invalid URL".to_string()) - }); - return promise; - } - }; - spawn_for_promise(promise as *mut u8, async move { - let client = reqwest::Client::new(); - match client.request(method, &url).send().await { - Ok(response) => { - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let headers: Vec<(String, String)> = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - match response.text().await { - Ok(data) => { - let handle = register_handle(AxiosResponseHandle { - status, - status_text, - data, - headers, - }); - // NaN-box the handle so the awaiter keeps it as an - // object instead of treating the small id as a number. - Ok((handle as u64) | 0x7FFD_0000_0000_0000) - } - Err(e) => Err(format!("Failed to read response body: {}", e)), - } - } - Err(e) => Err(format!("Request failed: {}", e)), - } - }); - - promise -} - -/// axios.get(url) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_get(url_ptr: *const StringHeader) -> *mut Promise { - request_without_body(url_ptr, reqwest::Method::GET) -} - -/// axios.head(url) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_head(url_ptr: *const StringHeader) -> *mut Promise { - request_without_body(url_ptr, reqwest::Method::HEAD) -} - -/// axios.options(url) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_options(url_ptr: *const StringHeader) -> *mut Promise { - request_without_body(url_ptr, reqwest::Method::OPTIONS) -} - -/// axios.post(url, data) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_post(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let promise = js_promise_new_cross_thread(); - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - spawn_for_promise(promise as *mut u8, async move { - Err::("Invalid URL".to_string()) - }); - return promise; - } - }; - - // #598: stringify on Perry's main thread BEFORE crossing the - // tokio boundary. `js_json_stringify` reads from perry-runtime's - // thread-local arena; calling it from inside `spawn_for_promise` - // would access the wrong arena. - let body = body_string_from_value(data); - - spawn_for_promise(promise as *mut u8, async move { - let client = reqwest::Client::new(); - match client - .post(&url) - .header("Content-Type", "application/json") - .body(body) - .send() - .await - { - Ok(response) => { - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let headers: Vec<(String, String)> = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - match response.text().await { - Ok(data) => { - let handle = register_handle(AxiosResponseHandle { - status, - status_text, - data, - headers, - }); - // Issue #340: NaN-box the handle as POINTER_TAG - // (0x7FFD) so the awaiter sees a proper handle - // value, not a subnormal float that decays to - // undefined on `r.status` / `r.data` accesses. - Ok((handle as u64) | 0x7FFD_0000_0000_0000) - } - Err(e) => Err(format!("Failed to read response body: {}", e)), - } - } - Err(e) => Err(format!("Request failed: {}", e)), - } - }); - - promise -} - -/// axios.put(url, data) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_put(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let promise = js_promise_new_cross_thread(); - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - spawn_for_promise(promise as *mut u8, async move { - Err::("Invalid URL".to_string()) - }); - return promise; - } - }; - - // #598: stringify on the main thread (see js_axios_post). - let body = body_string_from_value(data); - - spawn_for_promise(promise as *mut u8, async move { - let client = reqwest::Client::new(); - match client - .put(&url) - .header("Content-Type", "application/json") - .body(body) - .send() - .await - { - Ok(response) => { - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let headers: Vec<(String, String)> = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - match response.text().await { - Ok(data) => { - let handle = register_handle(AxiosResponseHandle { - status, - status_text, - data, - headers, - }); - // Issue #340: NaN-box the handle as POINTER_TAG - // (0x7FFD) so the awaiter sees a proper handle - // value, not a subnormal float that decays to - // undefined on `r.status` / `r.data` accesses. - Ok((handle as u64) | 0x7FFD_0000_0000_0000) - } - Err(e) => Err(format!("Failed to read response body: {}", e)), - } - } - Err(e) => Err(format!("Request failed: {}", e)), - } - }); - - promise -} - -/// axios.delete(url) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_delete(url_ptr: *const StringHeader) -> *mut Promise { - let promise = js_promise_new_cross_thread(); - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - spawn_for_promise(promise as *mut u8, async move { - Err::("Invalid URL".to_string()) - }); - return promise; - } - }; - - spawn_for_promise(promise as *mut u8, async move { - let client = reqwest::Client::new(); - match client.delete(&url).send().await { - Ok(response) => { - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let headers: Vec<(String, String)> = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - match response.text().await { - Ok(data) => { - let handle = register_handle(AxiosResponseHandle { - status, - status_text, - data, - headers, - }); - // Issue #340: NaN-box the handle as POINTER_TAG - // (0x7FFD) so the awaiter sees a proper handle - // value, not a subnormal float that decays to - // undefined on `r.status` / `r.data` accesses. - Ok((handle as u64) | 0x7FFD_0000_0000_0000) - } - Err(e) => Err(format!("Failed to read response body: {}", e)), - } - } - Err(e) => Err(format!("Request failed: {}", e)), - } - }); - - promise -} - -/// axios.patch(url, data) -> Promise -#[no_mangle] -pub unsafe extern "C" fn js_axios_patch(url_ptr: *const StringHeader, data: f64) -> *mut Promise { - let promise = js_promise_new_cross_thread(); - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - spawn_for_promise(promise as *mut u8, async move { - Err::("Invalid URL".to_string()) - }); - return promise; - } - }; - - // #598: stringify on the main thread (see js_axios_post). - let body = body_string_from_value(data); - - spawn_for_promise(promise as *mut u8, async move { - let client = reqwest::Client::new(); - match client - .patch(&url) - .header("Content-Type", "application/json") - .body(body) - .send() - .await - { - Ok(response) => { - let status = response.status().as_u16(); - let status_text = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let headers: Vec<(String, String)> = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - match response.text().await { - Ok(data) => { - let handle = register_handle(AxiosResponseHandle { - status, - status_text, - data, - headers, - }); - // Issue #340: NaN-box the handle as POINTER_TAG - // (0x7FFD) so the awaiter sees a proper handle - // value, not a subnormal float that decays to - // undefined on `r.status` / `r.data` accesses. - Ok((handle as u64) | 0x7FFD_0000_0000_0000) - } - Err(e) => Err(format!("Failed to read response body: {}", e)), - } - } - Err(e) => Err(format!("Request failed: {}", e)), - } - }); - - promise -} - -/// response.status -> number -#[no_mangle] -pub unsafe extern "C" fn js_axios_response_status(handle: Handle) -> f64 { - if let Some(response) = get_handle::(handle) { - response.status as f64 - } else { - 0.0 - } -} - -/// response.statusText -> string -#[no_mangle] -pub unsafe extern "C" fn js_axios_response_status_text(handle: Handle) -> *mut StringHeader { - if let Some(response) = get_handle::(handle) { - js_string_from_bytes( - response.status_text.as_ptr(), - response.status_text.len() as u32, - ) - } else { - std::ptr::null_mut() - } -} - -/// response.data -> string -#[no_mangle] -pub unsafe extern "C" fn js_axios_response_data(handle: Handle) -> *mut StringHeader { - if let Some(response) = get_handle::(handle) { - js_string_from_bytes(response.data.as_ptr(), response.data.len() as u32) - } else { - std::ptr::null_mut() - } -} diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index f698902cdc..5fbf08c79f 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -561,45 +561,6 @@ pub unsafe extern "C" fn js_handle_property_dispatch( } } - // Issue #340: axios response — dispatch `r.status` / `r.data` / - // `r.statusText` / `r.headers` to the AxiosResponseHandle accessor - // shims. The handle id is registered in the common HANDLES - // registry; gate on registry membership AND a known property - // name so a colliding handle id doesn't silently return one of - // these slots when the user meant something else (same disjoint - // method-set discipline as the method dispatch above). - #[cfg(feature = "http-client")] - if matches!(property_name, "status" | "data" | "statusText" | "headers") { - if with_handle::(handle, |_| true) - .unwrap_or(false) - { - use perry_runtime::JSValue; - return match property_name { - "status" => crate::axios::js_axios_response_status(handle), - "data" => { - let ptr = crate::axios::js_axios_response_data(handle); - if ptr.is_null() { - f64::from_bits(0x7FFC_0000_0000_0001) - } else { - f64::from_bits(JSValue::string_ptr(ptr).bits()) - } - } - "statusText" => { - let ptr = crate::axios::js_axios_response_status_text(handle); - if ptr.is_null() { - f64::from_bits(0x7FFC_0000_0000_0001) - } else { - f64::from_bits(JSValue::string_ptr(ptr).bits()) - } - } - // headers: Vec<(String, String)> — return undefined - // for now (header object materialisation is its own - // follow-up; status / data cover the issue). - _ => f64::from_bits(0x7FFC_0000_0000_0001), - }; - } - } - #[cfg(feature = "external-http-client-pump")] if let Some(value) = unsafe { super::super::dispatch_http::dispatch_client_request_property(handle, property_name) diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 41c5353d1e..8812fb2a15 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -6,7 +6,7 @@ //! # Features //! - `core` - Minimal runtime (always included) //! - `http-server` - Native HTTP server (hyper-based) -//! - `http-client` - Web Fetch and Axios compatibility surface +//! - `http-client` - Web Fetch compatibility surface //! - `database` - All databases (postgres, mysql, sqlite, redis, mongodb) //! - `crypto` - Cryptographic functions //! - `compression` - zlib compression @@ -183,12 +183,6 @@ pub mod fetch_blob; #[cfg(feature = "web-fetch")] pub use fetch_blob::*; -// === Axios compatibility surface === -#[cfg(feature = "http-client")] -pub mod axios; -#[cfg(feature = "http-client")] -pub use axios::*; - // === Web Streams API (issue #237) === // Per-binding gate (v0.5.572): `bundled-streams` is the only flag // that toggles `pub mod streams`. The well-known flip strips diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 99951335a2..07eafc3afe 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -56,40 +56,6 @@ pub extern "C" fn js_await_js_promise() -> i64 { 0 } #[no_mangle] -pub extern "C" fn js_axios_create() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_delete() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_get() -> i64 { - 0 -} - -#[no_mangle] -pub extern "C" fn js_axios_head() -> i64 { - 0 -} - -#[no_mangle] -pub extern "C" fn js_axios_options() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_post() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_put() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_axios_request() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_bcrypt_compare() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs index 7c86633760..5fb96888a8 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/parcel_watcher_tests.rs @@ -14,6 +14,7 @@ module.exports = binding src, &PathBuf::from("/tmp/node_modules/opencode/watcher.js"), Some("linux-x86_64-musl"), + false, ); assert!( wrapped.contains("from '@parcel/watcher-linux-x64-musl'") diff --git a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs index 5f5b6a780d..d8e3b7d87e 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs @@ -50,7 +50,7 @@ exports.compute = compute; fn wrap_and_lower(body: &str) -> perry_hir::Module { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(body, path, None); + let wrapped = wrap_commonjs_for_target(body, path, None, false); let ast = perry_parser::parse_typescript(&wrapped, "index.js") .expect("the wrap template must produce parseable ESM"); perry_hir::lower_module(&ast, "dep", &path.to_string_lossy()) @@ -63,7 +63,7 @@ fn wrap_and_lower(body: &str) -> perry_hir::Module { #[test] fn cjs_preamble_does_not_arm_the_ptr_shape_module_barrier() { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity, and the more precise failure of the two: assert the // preamble still HAS the site the recogniser is written for. Without this @@ -129,7 +129,7 @@ const EXPECTED_PREAMBLE_ALLOC_STMTS: usize = 2; #[test] fn the_cjs_preamble_is_still_recognised_as_scaffolding_allocation() { let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity on the template, one assertion per recogniser conjunct, so // a template edit names the conjunct it broke rather than failing as an @@ -197,7 +197,7 @@ fn a_module_that_was_never_cjs_wrapped_has_no_preamble() { fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() { let path = Path::new("/tmp/perry-canary/.next/server/chunks/lazy.js"); let marker = "exports.ready = true;"; - let wrapped = wrap_commonjs_for_target(marker, path, None); + let wrapped = wrap_commonjs_for_target(marker, path, None, false); let partial = wrapped .find("__perry_register_path_module_partial(") @@ -247,7 +247,7 @@ fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() #[test] fn computed_relative_requires_are_joined_against_the_module_dir() { let path = Path::new("/tmp/perry-canary/.next/server/webpack-runtime.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity: if the wrap stops consulting the registry at all, the // assertions below would be about a branch that no longer exists. @@ -290,7 +290,7 @@ fn computed_relative_requires_are_joined_against_the_module_dir() { fn the_wrap_still_binds_the_local_the_cjs_entry_recogniser_keys_on() { let local = perry_codegen::cjs_wrap_create_require_local(); let path = Path::new("/tmp/perry-canary/node_modules/dep/index.js"); - let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None, false); // Anti-vacuity: the template must still emit the binding at all. assert!( diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests.rs b/crates/perry/src/commands/compile/cjs_wrap/tests.rs index e64cdba370..22ab9db45a 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests.rs @@ -28,7 +28,7 @@ fn cjs_wrap_body_offset_maps_back_to_original_line() { // line `L - prefix_line_count`. let original = "function f() {\n return new Nope();\n}\nmodule.exports = f;\n"; let path = PathBuf::from("/tmp/x/index.js"); - let (wrapped, body_off) = wrap_commonjs_with_body_offset(original, &path, None); + let (wrapped, body_off) = wrap_commonjs_with_body_offset(original, &path, None, false); let body_off = body_off.expect("body should be locatable in wrapped output"); // Prefix line count = newlines before the body in the wrapped output. let prefix_lines = wrapped.as_bytes()[..body_off] @@ -748,6 +748,7 @@ exports.spawn = function spawn() { return terminalCtor; }; src, &PathBuf::from("/tmp/node_modules/node-pty/lib/index.js"), Some("windows"), + false, ); assert!( wrapped.contains("import _req_0 from './windowsTerminal';") @@ -783,6 +784,7 @@ exports.spawn = function spawn() { return terminalCtor; }; src, &PathBuf::from("/tmp/node_modules/node-pty/lib/index.js"), Some("linux"), + false, ); assert!( wrapped.contains("from './unixTerminal'"), diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 91840d4319..da26e86a93 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -100,15 +100,21 @@ fn is_global_value_builtin_name(name: &str) -> bool { /// peeking at re-export wrappers' transitive named exports. #[cfg(test)] pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Path) -> String { - wrap_commonjs_for_target(source, source_path, None) + // Not the process entry: every call site that does not know (or care) + // whether `source_path` is the compile-time entry module goes through + // here, which is correct for the overwhelming majority of CJS-wrapped + // files (dependencies). The real per-module entry status is threaded + // explicitly from `collect_modules.rs`, the only place that knows it. + wrap_commonjs_for_target(source, source_path, None, false) } pub(in crate::commands::compile) fn wrap_commonjs_for_target( source: &str, source_path: &Path, target: Option<&str>, + is_entry_module: bool, ) -> String { - wrap_commonjs_with_body_offset(source, source_path, target).0 + wrap_commonjs_with_body_offset(source, source_path, target, is_entry_module).0 } /// Like [`wrap_commonjs_for_target`], but also returns the byte offset within @@ -122,6 +128,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( source: &str, source_path: &Path, target: Option<&str>, + is_entry_module: bool, ) -> (String, Option) { let mut source_cow = Cow::Borrowed(source); @@ -955,6 +962,68 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // `require(specifier)` for one of those fell through to compiled-module // resolution and raised `MODULE_NOT_FOUND` instead of routing through // `createRequire`. Each entry emits both the bare and `node:` spelling. + // #10735: `require.main` must be the process ENTRY module only — + // `module` there, unequal (or `undefined`, for an ESM entry) everywhere + // else. `codegen::entry::compile_module_entry` already published a + // placeholder object as the shared "main module" in `main()`, BEFORE any + // module ran — see `js_bootstrap_cjs_main_module_placeholder`'s doc + // comment for why that has to happen outside any module's own preamble + // (ESM eval order runs a CJS entry's own static-import dependencies + // before the entry's own top-level code, so a naive "entry publishes + // first thing in its own preamble" is too late for every hoisted + // `require('./relative')`). + // + // The entry module CLAIMS that placeholder (same object identity a + // dependency may already have captured as `require.main`) and fills in + // its real fields; every non-entry module just reads it back instead of + // building its own — the latter is what made `require.main === module` + // trivially true in every compiled CommonJS module, not just the true + // entry point (#10735). + let require_main_stmt = if is_entry_module { + "require.main = module;" + } else { + "require.main = __perry_get_cjs_main_module();" + }; + // #10735: entry-only. A non-entry module keeps the single-literal + // construction below unchanged (still recognised by + // `cjs_scaffolding.rs`'s `Ptr` folding — see the comment on that + // literal). The entry instead mutates the ALREADY-PUBLISHED placeholder + // in place, field by field, so its identity matches what a dependency + // may have captured before this preamble ran. This is entry-only (one + // object per program), so it does not reintroduce the eleven-shape- + // transition cost the folded literal below exists to avoid. + let cjs_module_init_stmt = if is_entry_module { + format!( + r#"const __cjs_module = __perry_get_cjs_main_module(); + __cjs_module.exports = {{}}; + __cjs_module.__perry_cjs_record = true; + __cjs_module.__perry_cjs_factory = {cjs_factory_value}; + __cjs_module.id = {module_filename_literal}; + __cjs_module.path = {module_dir_literal}; + __cjs_module.filename = {module_filename_literal}; + __cjs_module.loaded = false; + __cjs_module.children = []; + __cjs_module.parent = globalThis.__perry_cjs_pending_parent; + __cjs_module.paths = [{module_dir_literal} + '/node_modules']; + __cjs_module.require = undefined;"# + ) + } else { + format!( + r#"const __cjs_module = {{ + exports: {{}}, + __perry_cjs_record: true, + __perry_cjs_factory: {cjs_factory_value}, + id: {module_filename_literal}, + path: {module_dir_literal}, + filename: {module_filename_literal}, + loaded: false, + children: [], + parent: globalThis.__perry_cjs_pending_parent, + paths: [{module_dir_literal} + '/node_modules'], + require: undefined, + }};"# + ) + }; let cjs_preamble = format!( r#" // #3527: `module`/`exports` are reassignable `var`s (mirroring Node, where // they are wrapper-function parameters), so CJS bodies that do @@ -980,19 +1049,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // positionally. Adding or reordering a field drops the record back to being // reported as a denied user candidate in the `Ptr` report; // `preamble_canary_tests` is what catches that. - const __cjs_module = {{ - exports: {{}}, - __perry_cjs_record: true, - __perry_cjs_factory: {cjs_factory_value}, - id: {module_filename_literal}, - path: {module_dir_literal}, - filename: {module_filename_literal}, - loaded: false, - children: [], - parent: globalThis.__perry_cjs_pending_parent, - paths: [{module_dir_literal} + '/node_modules'], - require: undefined, - }}; + {cjs_module_init_stmt} globalThis.__perry_cjs_pending_parent = undefined; // Node populates `module.parent` before the body evaluates, so link it // here rather than at the tail's registry publication. @@ -1135,7 +1192,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // ~2,200 CJS modules that is pure startup garbage. require.cache = __perry_cjs_base_require.cache; require.extensions = __perry_cjs_base_require.extensions; - require.main = module;"# + {require_main_stmt}"# ); let cjs_preamble = format!( "{cjs_preamble}\n module.require = function moduleRequire(specifier) {{ return require(specifier); }};" diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a179e14cc5..0a9474d9fd 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -408,6 +408,9 @@ fn collect_module_one( // left untouched. let was_cjs_wrapped = (is_in_compiled_pkg || !is_in_node_modules) && super::cjs_wrap::is_commonjs(&raw_source); + // #10735: this module's `require.main` (CJS preamble below) must resolve + // to the compile-time entry -- same comparison as `is_entry_module` below. + let cjs_is_entry_module = ctx.entry_canonical.as_ref() == Some(&canonical); // #5247 / #7036: when source locations are requested, capture where the // original module body lands inside the wrapped output so debug frames and // opt reports can map a wrapped-coordinate byte offset back to an @@ -415,8 +418,12 @@ fn collect_module_one( let mut cjs_wrap_body_prefix_lines: Option = None; let source = if was_cjs_wrapped { if ctx.debug_symbols { - let (wrapped, body_off) = - super::cjs_wrap::wrap_commonjs_with_body_offset(&raw_source, &canonical, target); + let (wrapped, body_off) = super::cjs_wrap::wrap_commonjs_with_body_offset( + &raw_source, + &canonical, + target, + cjs_is_entry_module, + ); // Newlines before the original body in the wrapped output = the // wrapper prefix line count. Recorded only when the body was // located; otherwise we skip the skew correction (graceful @@ -429,7 +436,12 @@ fn collect_module_one( }); wrapped } else { - super::cjs_wrap::wrap_commonjs_for_target(&raw_source, &canonical, target) + super::cjs_wrap::wrap_commonjs_for_target( + &raw_source, + &canonical, + target, + cjs_is_entry_module, + ) } } else { raw_source diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index 8aab6c74c7..00eb57f5a8 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -747,7 +747,6 @@ pub(crate) fn binding_needs_shared_tokio(module: &str) -> bool { | "https" | "http2" // HTTP clients (reqwest, hyper) - | "axios" | "node-fetch" // undici — glue over the native fetch stack (network I/O family). // The wrapper itself has no tokio dep today, but it rides the diff --git a/crates/perry/src/commands/sandbox_profile.rs b/crates/perry/src/commands/sandbox_profile.rs index 024d94a045..a9200767e2 100644 --- a/crates/perry/src/commands/sandbox_profile.rs +++ b/crates/perry/src/commands/sandbox_profile.rs @@ -60,7 +60,6 @@ pub fn build_macos_profile(ctx: &CompilationContext) -> String { || imports_module(ctx, "tls") || imports_module(ctx, "dns") || imports_module(ctx, "ws") - || imports_module(ctx, "axios") || imports_module(ctx, "node-fetch") || imports_module(ctx, "redis") || imports_module(ctx, "ioredis") diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index e508bceb8b..ae8e640b0c 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -33,11 +33,11 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // spellings need the same feature for auto-optimized stdlib builds. "streams" | "stream/web" | "stream_web" | "fs/promises" => &["bundled-streams"], - // ── Web Fetch and Axios compatibility surface ──────────────── + // ── Web Fetch compatibility surface ─────────────────────────── // Node HTTP/HTTPS/HTTP2 are provided by perry-ext-http and need - // no perry-stdlib feature. Axios and node-fetch still use the - // legacy umbrella for compatibility. - "axios" | "node-fetch" => &["http-client"], + // no perry-stdlib feature. node-fetch still uses the legacy + // umbrella for compatibility. + "node-fetch" => &["http-client"], // `undici` (#466) has no perry-stdlib copy to strip — the wrapper // crate (perry-ext-undici) is thin glue over the native Web Fetch @@ -317,7 +317,7 @@ mod tests { assert!(module_to_features("http").is_empty()); assert!(module_to_features("node:https").is_empty()); assert!(module_to_features("http2").is_empty()); - assert_eq!(module_to_features("axios"), &["http-client"]); + assert_eq!(module_to_features("node-fetch"), &["http-client"]); } #[test] diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index db16d7c6b9..0b537d3ae1 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -203,18 +203,6 @@ repo = "https://github.com/coveooss/exponential-backoff" ref = "5622170828bd91dc149585bfa3d82c8972a75ac5" ported-at = "3.1.3" date = "2026-07-30" -[bindings.axios] -crate = "perry-ext-axios" -lib = "perry_ext_axios" -tracking = "#466" - -[bindings.axios.upstream] -version = "1.19.0" -sha256 = "a511049fdaec40a320368b3ee965079b3e14481f82d052584f746bbdc3f01ede" -repo = "https://github.com/axios/axios" -ref = "311fcc5c8d989b7248f05d390bb83bfbfb009977" -ported-at = "1.19.0" -date = "2026-07-30" [bindings.events] crate = "perry-ext-events" lib = "perry_ext_events" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index c798cd7bb3..6497a7386b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2078 entries across 133 modules +// Coverage: 2067 entries across 132 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -257,44 +257,6 @@ declare module "async_hooks" { export function triggerAsyncId(...args: any[]): any; } -declare module "axios" { - /** stdlib */ - export function all(...args: any[]): any; - /** stdlib */ - export function create(...args: any[]): any; - /** stdlib */ - function _delete(...args: any[]): any; - export { _delete as delete }; - /** stdlib */ - export function get(...args: any[]): any; - /** stdlib */ - export function head(...args: any[]): any; - /** stdlib */ - export function options(...args: any[]): any; - /** stdlib */ - export function patch(...args: any[]): any; - /** stdlib */ - export function post(...args: any[]): any; - /** stdlib */ - export function put(...args: any[]): any; - /** stdlib */ - export function request(...args: any[]): any; - /** stdlib */ - const _default: ((...args: any[]) => any) & { - all: typeof all; - create: typeof create; - delete: typeof _delete; - get: typeof get; - head: typeof head; - options: typeof options; - patch: typeof patch; - post: typeof post; - put: typeof put; - request: typeof request; - }; - export default _default; -} - declare module "bcrypt" { /** stdlib */ export function compare(plaintext: string, hash: string): any; diff --git a/docs/native-libraries.md b/docs/native-libraries.md index c3e062406b..33c567379f 100644 --- a/docs/native-libraries.md +++ b/docs/native-libraries.md @@ -34,7 +34,6 @@ implementations, organized by category: ### HTTP & Networking | npm Package | Rust Backend | Description | |-------------|--------------|-------------| -| `axios` | [reqwest](https://crates.io/crates/reqwest) | HTTP client with full method support | | `node-fetch` | [reqwest](https://crates.io/crates/reqwest) | Fetch API implementation | | `ws` | [tokio-tungstenite](https://crates.io/crates/tokio-tungstenite) | WebSocket client | | `nodemailer` | [lettre](https://crates.io/crates/lettre) | SMTP email sending | @@ -115,7 +114,6 @@ Click any library name to jump to its documentation: | Library | Category | Jump | |---------|----------|------| -| axios | HTTP Client | [docs](#axios) | | argon2 | Security | [docs](#argon2) | | bcrypt | Security | [docs](#bcrypt) | | better-sqlite3 | Database | [docs](#better-sqlite3) | @@ -920,52 +918,6 @@ const dayEnd = endOfDay(new Date()); --- -## axios - -**npm package:** [axios](https://www.npmjs.com/package/axios) -**Rust backend:** [reqwest](https://crates.io/crates/reqwest) v0.12 - -### Supported API - -```typescript -import axios from 'axios'; - -// Simple requests -const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1'); -const postResponse = await axios.post('https://jsonplaceholder.typicode.com/posts', { title: 'hello' }); -const putResponse = await axios.put('https://jsonplaceholder.typicode.com/posts/1', { title: 'updated' }); -const deleteResponse = await axios.delete('https://jsonplaceholder.typicode.com/posts/1'); - -// Full request with config -const response2 = await axios.request({ - method: 'POST', - url: 'https://jsonplaceholder.typicode.com/posts', - headers: { 'Content-Type': 'application/json' }, - data: { title: 'hello' } -}); - -// Create instance with defaults -const api = axios.create({ - baseURL: 'https://jsonplaceholder.typicode.com', - timeout: 5000, - headers: { 'Authorization': 'Bearer token' } -}); -``` - -### Response Properties - -- `response.status` - HTTP status code -- `response.statusText` - HTTP status text -- `response.data` - Response body (JSON parsed) -- `response.headers` - Response headers - -### Notes -- HTTPS supported via rustls -- JSON bodies automatically serialized/parsed -- Timeouts supported via config - ---- - ## argon2 **npm package:** [argon2](https://www.npmjs.com/package/argon2) diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index d1d9f3bbd5..458ee6d20a 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3020 entries across 135 modules. +Total: 3009 entries across 134 modules. ## Modules @@ -22,7 +22,6 @@ Total: 3020 entries across 135 modules. - [`assert`](#assert) - [`assert/strict`](#assertstrict) - [`async_hooks`](#async_hooks) -- [`axios`](#axios) - [`bcrypt`](#bcrypt) - [`better-sqlite3`](#better-sqlite3) - [`bignumber.js`](#bignumberjs) @@ -342,22 +341,6 @@ Total: 3020 entries across 135 modules. - `asyncWrapProviders` - `default` -## `axios` - -### Methods - -- `all` — module -- `create` — module -- `default` — module -- `delete` — module -- `get` — module -- `head` — module -- `options` — module -- `patch` — module -- `post` — module -- `put` — module -- `request` — module - ## `bcrypt` ### Methods diff --git a/docs/src/cli/commands.md b/docs/src/cli/commands.md index 8d7add8bc1..7459af175f 100644 --- a/docs/src/cli/commands.md +++ b/docs/src/cli/commands.md @@ -424,10 +424,9 @@ perry native list Output: ```text -30 bindings ship with this Perry build: +29 bindings ship with this Perry build: argon2 → perry-ext-argon2 (#466) - axios → perry-ext-axios (#466) bcrypt → perry-ext-bcrypt (#466) better-sqlite3 → perry-ext-better-sqlite3 (#466) … diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 297f380946..bc06dda3f5 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -86,7 +86,6 @@ from `well_known_bindings.toml`. Regenerate this table with |---|---|---|---|---| | `perry-ext-ads` | `perry/ads` | Obsolete integration | Remove after compatibility review | Bundled; removal pending | | `perry-ext-argon2` | `argon2` | External integration | Move to an external native package | Bundled; migration pending | -| `perry-ext-axios` | `axios` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-bcrypt` | `bcrypt` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-better-sqlite3` | `better-sqlite3` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-cheerio` | `cheerio` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 730efc1d1b..ac1a0cd1bb 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -330,7 +330,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", - "crates/perry-runtime/src/gc/mod.rs": "0243f1b1b1fae870983df500898abc353086473bbde3f478162e2826867762fe", + "crates/perry-runtime/src/gc/mod.rs": "59379cb96d5a3d3377fc3d34d387b509dcc1dc731679e6fd60507d844e898463", "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/test-files/gap_10448_stream_subclass_heritage_helper.cjs b/test-files/gap_10448_stream_subclass_heritage_helper.cjs new file mode 100644 index 0000000000..ecd23333f5 --- /dev/null +++ b/test-files/gap_10448_stream_subclass_heritage_helper.cjs @@ -0,0 +1,68 @@ +'use strict'; +// CommonJS half of test_gap_10448_stream_subclass_heritage.ts: the exact +// shape nodemailer uses everywhere (`const { Transform } = +// require('stream'); class X extends Transform`), plus the sibling +// Writable/Readable/Duplex destructured shapes the issue lists as broken +// the same way, and a namespace-member export for comparison. +// +// State is captured via public class fields, not a constructor-body +// assignment after `super(...args)` with a rest-param spread — that shape +// (`constructor(...args) { super(...args); ... }`) hits a separate, +// pre-existing gap (native stream methods go missing) independent of +// heritage shape or this issue; not exercised here to keep this test +// isolated to #10448's own defect. +const { Transform, Writable, Readable, Duplex } = require('stream'); +const stream = require('stream'); + +class CjsTransform extends Transform { + _transform(chunk, _enc, cb) { + cb(null, String(chunk).toUpperCase()); + } +} + +// `require('stream').Transform` reached via a namespace member on a plain +// `require()` result (not destructured) — control: this shape is already +// recognized statically (`is_genuine_node_stream_parent`). +class CjsViaMember extends stream.Transform { + _transform(chunk, _enc, cb) { + cb(null, String(chunk).toUpperCase()); + } +} + +class CjsWritable extends Writable { + captured = ''; + _write(chunk, _enc, cb) { + this.captured += String(chunk).toUpperCase(); + cb(); + } +} + +class CjsReadable extends Readable { + _done = false; + _read() { + if (this._done) return; + this._done = true; + this.push('x'); + this.push('y'); + this.push(null); + } +} + +// Write-half only (no `_read`/push): proves the destructured `Duplex` +// heritage installs `_write` the same way `Writable` does, without +// depending on read/write event-ordering across engines. +class CjsDuplex extends Duplex { + captured = ''; + _write(chunk, _enc, cb) { + this.captured += String(chunk).toUpperCase(); + cb(); + } +} + +module.exports = { + CjsTransform, + CjsViaMember, + CjsWritable, + CjsReadable, + CjsDuplex, +}; diff --git a/test-files/gap_10735_require_main_cli_guard.cjs b/test-files/gap_10735_require_main_cli_guard.cjs new file mode 100644 index 0000000000..f3a299957c --- /dev/null +++ b/test-files/gap_10735_require_main_cli_guard.cjs @@ -0,0 +1,9 @@ +// #10735 helper: the real-world shape (dotenv's bundled CLI, and countless +// other packages) — `if (require.main === module) { ...CLI... }`. Importing +// this as a dependency must NOT take the CLI branch: no "CLI" line, no +// process.exit(1). +if (require.main === module) { + console.log('CLI'); + process.exit(1); +} +exports.ok = true; diff --git a/test-files/gap_10735_require_main_deep.cjs b/test-files/gap_10735_require_main_deep.cjs new file mode 100644 index 0000000000..b8b93cf398 --- /dev/null +++ b/test-files/gap_10735_require_main_deep.cjs @@ -0,0 +1,5 @@ +// #10735 helper: required two levels deep (entry -> dep2 -> deep). Still not +// the entry, so `require.main` must still resolve to the ENTRY's module, not +// to dep2's or to this module's own. +console.log('deep (2 levels): require.main === module:', require.main === module); +exports.tag = 'deep'; diff --git a/test-files/gap_10735_require_main_dep.cjs b/test-files/gap_10735_require_main_dep.cjs new file mode 100644 index 0000000000..9a638b1f11 --- /dev/null +++ b/test-files/gap_10735_require_main_dep.cjs @@ -0,0 +1,7 @@ +// #10735 helper: a plain required dependency. `require.main` must be the +// process ENTRY module (test_gap_10735_require_main_entry.cts), never this +// module's own `module` — that was the bug (require.main === module was +// trivially true in every compiled CommonJS module). +console.log('dep: require.main === module:', require.main === module); +exports.mainRef = require.main; +exports.tag = 'dep'; diff --git a/test-files/gap_10735_require_main_dep2.cjs b/test-files/gap_10735_require_main_dep2.cjs new file mode 100644 index 0000000000..e15c68afc8 --- /dev/null +++ b/test-files/gap_10735_require_main_dep2.cjs @@ -0,0 +1,21 @@ +// #10735 helper: requires `deep.cjs` (two levels deep from the entry) and +// re-requires `dep.cjs` (already loaded by the entry directly) to exercise +// Node's module cache — the SAME dep.cjs instance and the SAME require.main +// object must come back, not a fresh copy. +// +// Requires come BEFORE any side-effecting statement (deliberately): Perry +// transpiles a static `require('./relative')` into a hoisted ESM `import`, +// which runs the imported module's top-level code before THIS module's own +// trailing statements — the reverse of Node's inline evaluation order when a +// require() is preceded by other code in the same file. That reordering is +// an existing, unrelated property of cjs_wrap's require-hoisting (not +// anything #10735 touches), so this fixture avoids it structurally, the way +// real bundled output typically does, to keep the two engines' output +// byte-for-byte comparable and isolate the require.main assertions this +// test exists to check. +const deep = require('./gap_10735_require_main_deep.cjs'); +const depAgain = require('./gap_10735_require_main_dep.cjs'); +console.log('dep2: require.main === module:', require.main === module); +exports.deepTag = deep.tag; +exports.depExportsRef = depAgain; +exports.depMainRefFromHere = depAgain.mainRef; diff --git a/test-files/gap_10735_require_main_esm_dep.cjs b/test-files/gap_10735_require_main_esm_dep.cjs new file mode 100644 index 0000000000..bb57ce923f --- /dev/null +++ b/test-files/gap_10735_require_main_esm_dep.cjs @@ -0,0 +1,7 @@ +// #10735 helper for the ESM-entry case: a CJS module reached only via +// `import` from an ESM entry has NO CommonJS "main" — Node leaves +// `require.main` as `undefined` there (verified against Node 26.5.1; this is +// not assumed). +console.log('esm-imported dep: require.main === undefined:', require.main === undefined); +console.log('esm-imported dep: typeof require.main:', typeof require.main); +module.exports = { tag: 'esm-dep' }; diff --git a/test-files/test_ffi_surface_runtime_core.ts b/test-files/test_ffi_surface_runtime_core.ts index 1b988a174a..965c9d3902 100644 --- a/test-files/test_ffi_surface_runtime_core.ts +++ b/test-files/test_ffi_surface_runtime_core.ts @@ -6,7 +6,7 @@ // inventory into behavioral tests as each area gets deeper compatibility // coverage. // -// Inventory entries: 347 unique FFI names, 348 declarations. +// Inventory entries: 345 unique FFI names, 346 declarations. const testFfiSurfaceRuntimeCoreVersion = 1; if (testFfiSurfaceRuntimeCoreVersion !== 1) { @@ -101,8 +101,6 @@ crates/perry-runtime/src/child_process.rs: crates/perry-runtime/src/closure.rs: - js_argon2_hash_options - js_await_js_promise - - js_axios_create - - js_axios_request - js_closure_call10 - js_closure_call11 - js_closure_call12 diff --git a/test-files/test_ffi_surface_stdlib_core.ts b/test-files/test_ffi_surface_stdlib_core.ts index ded25dca0c..37645b05a4 100644 --- a/test-files/test_ffi_surface_stdlib_core.ts +++ b/test-files/test_ffi_surface_stdlib_core.ts @@ -6,7 +6,7 @@ // inventory into behavioral tests as each area gets deeper compatibility // coverage. // -// Inventory entries: 81 unique FFI names, 82 declarations. +// Inventory entries: 73 unique FFI names, 74 declarations. const testFfiSurfaceStdlibCoreVersion = 1; if (testFfiSurfaceStdlibCoreVersion !== 1) { @@ -16,15 +16,6 @@ console.log("test_ffi_surface_stdlib_core: ok"); /* @covers -crates/perry-stdlib/src/axios.rs: - - js_axios_delete - - js_axios_get - - js_axios_patch - - js_axios_post - - js_axios_put - - js_axios_response_data - - js_axios_response_status - - js_axios_response_status_text crates/perry-stdlib/src/common/dispatch.rs: - js_handle_method_dispatch - js_handle_property_set_dispatch diff --git a/test-files/test_gap_10448_stream_subclass_heritage.ts b/test-files/test_gap_10448_stream_subclass_heritage.ts new file mode 100644 index 0000000000..e07d8d7fee --- /dev/null +++ b/test-files/test_gap_10448_stream_subclass_heritage.ts @@ -0,0 +1,167 @@ +// #10448: node:stream subclass overrides (`_transform`/`_write`/`_read`) +// were ignored whenever the heritage reaching `class X extends ` was +// anything OTHER than a shape `is_genuine_node_stream_parent` recognizes +// statically at HIR-lowering time +// (`crates/perry-hir/src/lower_decl/class_decl.rs`) — a local alias +// (`const Alias = Transform`), an indirect subclass, a class expression, or +// a CJS destructured `require('stream')` (the shape nodemailer uses in +// every stream class it defines). `write()`/`push()` then threw +// `ERR_METHOD_NOT_IMPLEMENTED` because the override was never installed on +// `this`. +// +// (`PassThrough` is a separate, deeper gap — HIR never recognizes it +// statically even via a bare import, unlike Readable/Writable/Duplex/ +// Transform, so it needs its own follow-up; not covered by this test, see +// the PR body.) +// +// Each check awaits its own stream before starting the next so output order +// is deterministic regardless of engine event-loop/nextTick scheduling +// differences — only the per-check content is the thing under test. State +// is captured via public class fields (not a `constructor(...args) { +// super(...args); ... }` rest-spread pattern, which hits an unrelated +// pre-existing gap independent of heritage shape). +import { Transform, Writable, Readable, Duplex } from "stream"; +import * as streamNs from "stream"; +import { + CjsTransform, + CjsViaMember, + CjsWritable, + CjsReadable, + CjsDuplex, +} from "./gap_10448_stream_subclass_heritage_helper.cjs"; + +const AliasTransform = Transform; + +class ViaImport extends Transform { + _transform(chunk: any, _enc: string, cb: any) { + cb(null, String(chunk).toUpperCase()); + } +} +class ViaAlias extends AliasTransform { + _transform(chunk: any, _enc: string, cb: any) { + cb(null, String(chunk).toUpperCase()); + } +} +class ViaNamespaceMember extends streamNs.Transform { + _transform(chunk: any, _enc: string, cb: any) { + cb(null, String(chunk).toUpperCase()); + } +} +class Mid extends AliasTransform {} +class ViaIndirect extends Mid { + _transform(chunk: any, _enc: string, cb: any) { + cb(null, String(chunk).toUpperCase()); + } +} +const ViaClassExpr = class extends AliasTransform { + _transform(chunk: any, _enc: string, cb: any) { + cb(null, String(chunk).toUpperCase()); + } +}; + +function runTransform(name: string, T: any): Promise { + return new Promise((resolve) => { + const t = new T(); + let out = ""; + t.on("data", (c: any) => (out += c)); + t.on("end", () => { + console.log(name, JSON.stringify(out)); + resolve(); + }); + try { + t.write("ab"); + t.end("c"); + } catch (e: any) { + console.log(name, "threw", e.code); + resolve(); + } + }); +} + +function runWritable(name: string, W: any): Promise { + return new Promise((resolve) => { + let w: any; + try { + w = new W(); + } catch (e: any) { + console.log(name, "threw (construct)", e.message); + resolve(); + return; + } + w.on("finish", () => { + console.log(name, JSON.stringify(w.captured)); + resolve(); + }); + try { + w.write("ab"); + w.end("c"); + } catch (e: any) { + console.log(name, "threw", e.code); + resolve(); + } + }); +} + +function runReadable(name: string, R: any): Promise { + return new Promise((resolve) => { + let r: any; + try { + r = new R(); + } catch (e: any) { + console.log(name, "threw (construct)", e.message); + resolve(); + return; + } + let out = ""; + r.on("data", (c: any) => (out += c)); + r.on("end", () => { + console.log(name, JSON.stringify(out)); + resolve(); + }); + }); +} + +class WViaWritable extends Writable { + captured = ""; + _write(chunk: any, _enc: string, cb: any) { + this.captured += String(chunk).toUpperCase(); + cb(); + } +} + +class RViaReadable extends Readable { + private _done = false; + _read() { + if (this._done) return; + this._done = true; + this.push("m"); + this.push("n"); + this.push(null); + } +} + +class DViaDuplex extends Duplex { + captured = ""; + _write(chunk: any, _enc: string, cb: any) { + this.captured += String(chunk).toUpperCase(); + cb(); + } +} + +async function main() { + await runTransform("Transform via import ", ViaImport); + await runTransform("Transform via alias ", ViaAlias); + await runTransform("Transform via namespace member", ViaNamespaceMember); + await runTransform("Transform via indirect subclas", ViaIndirect); + await runTransform("Transform via class expression", ViaClassExpr); + await runTransform("Transform CJS destructured ", CjsTransform); + await runTransform("Transform CJS namespace member", CjsViaMember); + await runWritable("Writable CJS destructured ", CjsWritable); + await runWritable("Duplex CJS destructured ", CjsDuplex); + await runReadable("Readable CJS destructured ", CjsReadable); + await runWritable("Writable via import ", WViaWritable); + await runReadable("Readable via import ", RViaReadable); + await runWritable("Duplex via import ", DViaDuplex); +} + +main(); diff --git a/test-files/test_gap_10727_captured_array_local_proto_index.ts b/test-files/test_gap_10727_captured_array_local_proto_index.ts new file mode 100644 index 0000000000..6a6ae6f78c --- /dev/null +++ b/test-files/test_gap_10727_captured_array_local_proto_index.ts @@ -0,0 +1,101 @@ +// #10727: an array local captured by a nested closure read back `undefined` in +// its OWN declaring scope, once `Array.prototype` had ever carried an indexed +// property. The closure kept seeing the correct array, so the two storages for +// one binding disagreed and `S() === dest` was false. +// +// Introduced by #10488's `ctx.local_types.insert` on the redeclaration branch +// of `lower_let`. `Stmt::PreallocateBoxes` pre-creates the box for a captured +// local before its `Stmt::Let`, so the `Let` lands on that same redeclaration +// branch and recorded the refined type (`Array(Any)`) for a local whose real +// storage is a box. Predicates reading `local_types` then lowered reads as raw +// local loads instead of `js_box_get_bits`. +// +// The trigger is rare (something must put an indexed property on +// `Array.prototype`) but the affected shape — an array local captured by a +// closure — is ordinary code. + +function show(v: unknown): string { + if (v === undefined) return "undefined"; + if (v === null) return "null"; + if (typeof v === "object") return "object"; + return typeof v + "(" + String(v) + ")"; +} + +// Arm the array-prototype index deopt, then remove the property again. The +// latch that this sets is monotone: deleting the property does not clear it. +Object.defineProperty(Array.prototype, "11", { + configurable: true, + get() { + return "proto11"; + }, +}); +delete (Array.prototype as Record)["11"]; + +// 1. the regression: a captured array local, read from its declaring scope +function capturedArray(): void { + const dest: unknown[] = new Array(12); + dest[0] = { tag: "zero" }; + for (let i = 1; i < 12; i++) { + dest[i] = i; + } + function peek(): unknown[] { + return dest; + } + console.log("captured typeof:", typeof dest); + console.log("captured isArray:", Array.isArray(dest)); + console.log("captured length:", (dest as unknown[]).length); + console.log("captured [0]:", show(dest[0])); + console.log("captured [5]:", show(dest[5])); + console.log("captured [11]:", show(dest[11])); + console.log("captured identity:", peek() === dest); + console.log("captured via closure:", peek().length, show(peek()[5])); +} + +// 2. control: the same array with no closure over it +function plainArray(): void { + const dest: unknown[] = new Array(12); + dest[0] = { tag: "zero" }; + for (let i = 1; i < 12; i++) { + dest[i] = i; + } + console.log("plain length:", dest.length, "[5]:", show(dest[5])); +} + +// 3. control: a captured NON-array local +function capturedObject(): void { + const held = { a: 1, b: 2 }; + function peek(): { a: number; b: number } { + return held; + } + console.log("object a:", held.a, "identity:", peek() === held); +} + +// 4. a captured array that is mutated after the closure is created +function capturedThenMutated(): void { + const rows: number[] = []; + function collect(value: number): void { + rows.push(value); + } + collect(1); + collect(2); + rows.push(3); + console.log("mutated length:", rows.length, "join:", rows.join(",")); +} + +// 5. the #10488 shape must keep working: a hoisted `var` redeclared in both +// branches, then compared against an out-of-bounds read. +function hoistedVarRedeclare(flag: boolean): void { + if (flag) { + var nums = [1, 2, 3]; + } else { + var nums = [4, 5]; + } + console.log("var redeclare:", nums.length, nums[10] === undefined, nums[0]); +} + +capturedArray(); +plainArray(); +capturedObject(); +capturedThenMutated(); +hoistedVarRedeclare(true); +hoistedVarRedeclare(false); diff --git a/test-files/test_gap_10735_require_main_entry.cts b/test-files/test_gap_10735_require_main_entry.cts new file mode 100644 index 0000000000..dd512af43a --- /dev/null +++ b/test-files/test_gap_10735_require_main_entry.cts @@ -0,0 +1,35 @@ +// #10735: `require.main` must be the process ENTRY module only, equal to +// `module` there and unequal (or undefined, for an ESM entry — see the +// companion `test_gap_10735_require_main_esm_entry.ts`) everywhere else. +// +// Perry's CJS preamble used to emit `require.main = module;` unconditionally +// in EVERY compiled CommonJS module, so the idiom +// `if (require.main === module) { ...CLI... }` — used by countless packages +// (dotenv among them) to gate CLI behaviour — took its CLI branch whenever +// such a package was merely imported as a library. +// +// `.cts`, deliberately: this repo's package is `"type": "module"`, so a +// plain `.ts` runs as an ES module under Node; `.cts` forces CommonJS goal +// agreement between Node and Perry (see test_gap_9412's header for the full +// rationale). Keep this file free of top-level `import`/`export`. +// +// All `require()` calls come before any `console.log`, matching every +// helper file — see the ordering note in gap_10735_require_main_dep2.cjs. +const depFromEntry = require('./gap_10735_require_main_dep.cjs'); +const dep2 = require('./gap_10735_require_main_dep2.cjs'); +require('./gap_10735_require_main_cli_guard.cjs'); + +console.log('entry: require.main === module:', require.main === module); + +// Two-levels-deep dependency (entry -> dep2 -> deep) observed the same +// entry module as require.main. +console.log('deep tag:', dep2.deepTag); + +// dep.cjs was required once directly by the entry and once transitively by +// dep2 — Node's module cache means both call sites get the SAME exports +// object and the SAME require.main reference back, not a fresh reload. +console.log('dep exports identity cached across require sites:', depFromEntry === dep2.depExportsRef); +console.log('dep require.main identity stable across require sites:', depFromEntry.mainRef === dep2.depMainRefFromHere); +console.log('dep require.main === entry module:', depFromEntry.mainRef === module); + +console.log('entry done'); diff --git a/test-files/test_gap_10735_require_main_esm_entry.ts b/test-files/test_gap_10735_require_main_esm_entry.ts new file mode 100644 index 0000000000..dec4a6238e --- /dev/null +++ b/test-files/test_gap_10735_require_main_esm_entry.ts @@ -0,0 +1,6 @@ +// #10735 companion: when the process ENTRY is ESM, no CommonJS module ever +// ran as "main" — a CJS module reached only via `import` from that entry +// must see `require.main === undefined`, not merely "not itself". +import cjsDep from "./gap_10735_require_main_esm_dep.cjs"; + +console.log("esm entry imported cjs dep:", cjsDep); diff --git a/test-files/test_issue_340_axios_response_props.ts b/test-files/test_issue_340_axios_response_props.ts deleted file mode 100644 index 3230e74495..0000000000 --- a/test-files/test_issue_340_axios_response_props.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Regression for #340: axios shim's response.status / response.data / -// response.statusText silently returned `undefined` because (a) the -// async resolution path queued the AxiosResponseHandle id without -// NaN-boxing — the awaiter saw a subnormal float instead of a -// POINTER_TAG'd handle, and (b) the codegen IC fast path's -// `js_object_get_field_ic_miss` slow path bailed at `obj < 0x10000` -// for handle receivers, never reaching the runtime's -// `HANDLE_PROPERTY_DISPATCH` table. -// -// Fixes: -// - axios.rs: NaN-box every `Ok(handle as u64)` with POINTER_TAG so -// awaited values are real handles. -// - js_object_get_field_ic_miss: route small-handle receivers to -// `HANDLE_PROPERTY_DISPATCH` (matches js_native_call_method's -// handle threshold of 0x100000). -// - js_handle_property_dispatch: new arm for `AxiosResponseHandle` -// that returns status / data / statusText. -// - PropertyGet IC fast path: small-handle guard via select() so the -// GcHeader load reads from a safe sentinel address; the AND with -// is_real_ptr in the hit predicate ensures handles miss to the -// slow path cleanly without SIGSEGV. -// -// Uses a local URL stub via `axios.get` against an unreachable port — -// we don't actually care about the response body, just that -// `r.status` and `r.data` return non-undefined values when the -// promise resolves to a real AxiosResponseHandle. Network success -// is left to the issue's manual repro (live HTTPS GET). -// -// This test instead exercises the property-dispatch contract via a -// guaranteed-fail GET so we hit the error path of axios.get — which -// also sets the AxiosResponseHandle but with status=0 / data="". -// That's enough to verify the dispatch wiring without depending on -// a network round trip in CI. - -import axios from 'axios'; - -async function main(): Promise { - // Use a guaranteed-fail port so axios's reqwest backend produces an - // error path. Pre-fix: we'd hit the same undefined return. - // Post-fix: the dispatch wiring is verified end-to-end (no need to - // assert specific values — the catch block prints what we got). - try { - const r = await axios.get('http://127.0.0.1:1/never-listens', { - timeout: 1, - validateStatus: () => true, - }); - // If somehow it reached here with a real response, prove status/ - // data dispatch worked (didn't return undefined). - console.log('status type:', typeof r.status); - console.log('data type:', typeof r.data); - } catch (e: any) { - // Connection failure path is expected in CI. Just verify we got - // a string error message back — a sanity check that the await - // path didn't itself crash with the post-fix changes. - console.log('caught:', typeof e === 'string' ? 'string' : 'other'); - } -} - -main().then(() => process.exit(0)); diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index ed881947a1..228463a2bb 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -260,15 +260,6 @@ "linux" ] }, - "test_issue_340_axios_response_props": { - "issue": "8271", - "added": "2026-08-17", - "category": "untriaged", - "reason": "First complete parity run since 2026-07-04 (suite dark behind tag-gating + continue-on-error; #8187/#8244). Needs bisect over the six-week window. 2026-08-17 parity-debt audit (#8271).", - "platforms": [ - "linux" - ] - }, "test_issue_341_typed_field_native": { "issue": "8271", "added": "2026-08-17", diff --git a/tests/release/packages/axios-get/package.json b/tests/release/packages/axios-get/package.json index 2251237210..2ac9cc5a9b 100644 --- a/tests/release/packages/axios-get/package.json +++ b/tests/release/packages/axios-get/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "Tier-3 fixture: GET round-trip via Perry's native axios module against an in-process node:http server.", + "description": "Tier-3 fixture: GET round-trip via real-source-compiled axios (no perry.compilePackages entry needed) against an in-process node:http server.", "dependencies": { "axios": "^1.18.0" } diff --git a/workspace-architecture.json b/workspace-architecture.json index ebca2c85f8..240f5ba834 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 79, + "workspace_members": 78, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 30, + "externalize": 29, "keep": 44, "merge": 1, "remove": 1, @@ -150,11 +150,6 @@ "decision": "externalize", "migration": "external-package" }, - "perry-ext-axios": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-bcrypt": { "category": "binding", "decision": "externalize",