diff --git a/CLAUDE.md b/CLAUDE.md index d419cf294a..da33091b9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,7 @@ to the same physical root still deduplicate by canonical path. ### LLVM Type Mismatches - Loop counter optimization produces i32 — always convert before passing to f64/i64 functions - Constructor parameters always f64 (NaN-boxed) at signature level +- A new `PERRY_*` environment variable read in codegen must be added to `BUILD_CACHE_ENV_VARS` in `crates/perry/src/commands/compile/build_cache.rs`, or to `BUILD_CACHE_ENV_EXCLUSIONS` with a reason it cannot change emitted code. Otherwise cached objects can silently serve a different setting. Run `cargo test -p perry codegen_env_vars_are_build_cache_inputs` to check registration. ### Async / Threading - Thread-local arenas: JSValues from tokio workers invalid on main thread diff --git a/changelog.d/9746-number-radix-formatting.md b/changelog.d/9746-number-radix-formatting.md new file mode 100644 index 0000000000..636f0401da --- /dev/null +++ b/changelog.d/9746-number-radix-formatting.md @@ -0,0 +1,10 @@ +Fix non-decimal `Number.prototype.toString(radix)` digit selection. Large +integers now zero-fill digits beyond the double's precision and subtract +remainders before dividing, matching Node for cases such as +`(1e21).toString(36)` and `(9007199254740994).toString(3)`. Fractional conversion +also rounds the final digit when the residual is within the stopping tolerance, +fixing `(0.1).toString(36)`. + +Runtime regressions and an end-to-end fixture cover all radices, both signs, +the 2^53 boundary, large exponents, fractional values, dynamic calls, and boxed +numbers. The original formatter fails the new large-integer regression. diff --git a/changelog.d/9747-tombstone-default-comment.md b/changelog.d/9747-tombstone-default-comment.md new file mode 100644 index 0000000000..8a763dd52e --- /dev/null +++ b/changelog.d/9747-tombstone-default-comment.md @@ -0,0 +1,3 @@ +Remove the stale default-OFF claim above the object tombstone-delete gate. +The default and its rationale remain documented alongside the environment +parsing, avoiding conflicting descriptions of the shipping behavior. diff --git a/changelog.d/9748-codegen-cache-guidance.md b/changelog.d/9748-codegen-cache-guidance.md new file mode 100644 index 0000000000..3650043e74 --- /dev/null +++ b/changelog.d/9748-codegen-cache-guidance.md @@ -0,0 +1,7 @@ +Make the codegen environment-variable registration failure name the registry +file, the input and exclusion declaration anchors, and the command to rerun. +Document the cache-registration requirement in the contributor guidance and +beside the OnceLock reader pattern so new switches are registered when added. +The existing missing-input and stale-exclusion checks remain enforced. +Also register `PERRY_CONCAT_SITE_CACHE`, another omission found by running the +gate: toggling its generated concatenation tables must invalidate the cache. diff --git a/changelog.d/9749-dynamic-heritage-this.md b/changelog.d/9749-dynamic-heritage-this.md new file mode 100644 index 0000000000..fca59e1c3f --- /dev/null +++ b/changelog.d/9749-dynamic-heritage-this.md @@ -0,0 +1,9 @@ +**Dynamic-heritage classes now retain the receiver created by `super()` when +their runtime superclass resolves to `Object`.** + +Perry replayed these constructors against a provisional instance but discarded +the constructor's effective return value. Writes after `super()` therefore +landed on the new receiver while `new` returned the abandoned one. Dynamic +construction now propagates the replacement receiver and preserves its derived +prototype and per-evaluation private brand, matching Node for both shared class +references and fresh captured class expressions. diff --git a/changelog.d/9750-raw-tls-holder-audit.md b/changelog.d/9750-raw-tls-holder-audit.md new file mode 100644 index 0000000000..67fbf35708 --- /dev/null +++ b/changelog.d/9750-raw-tls-holder-audit.md @@ -0,0 +1,3 @@ +### Fixed +- Include raw `thread_local!` declarations in the GC holder inventory, so skipping Perry's TLS convention cannot hide a new opaque holder. Existing uncovered declarations join the identity ratchet as explicit audit debt. +- Give the GC census's deliberately untraced address snapshot a source-pinned, non-moving collection-window contract, and move its production TLS into the hot TLS registry. diff --git a/changelog.d/9752-linux-pthread-stack-attributes.md b/changelog.d/9752-linux-pthread-stack-attributes.md new file mode 100644 index 0000000000..c544b69d0d --- /dev/null +++ b/changelog.d/9752-linux-pthread-stack-attributes.md @@ -0,0 +1,2 @@ +### Fixed +- Use libc's typed pthread attributes for Linux GC and error-stack bounds, fixing conflicting declarations in the warnings gate and replacing manually sized attribute buffers with correctly aligned storage. diff --git a/changelog.d/9753-factory-class-heritage-identity.md b/changelog.d/9753-factory-class-heritage-identity.md new file mode 100644 index 0000000000..232fc92462 --- /dev/null +++ b/changelog.d/9753-factory-class-heritage-identity.md @@ -0,0 +1,7 @@ +### Fixed + +- Function-body class declarations with dynamic heritage now create a distinct + class for each evaluation, even when their bodies capture no locals. Chained + factories preserve their evaluated superclass and per-class static state. + Prototype reflection, `instanceof`, and inherited method lookup follow each + evaluation's own prototype chain instead of the shared template (#9502). diff --git a/changelog.d/9754-bun-global-diagnostics.md b/changelog.d/9754-bun-global-diagnostics.md new file mode 100644 index 0000000000..170dfb9664 --- /dev/null +++ b/changelog.d/9754-bun-global-diagnostics.md @@ -0,0 +1,2 @@ +### Fixed +- Recognize the platform-provided `Bun` global in Bun-mode diagnostics while preserving runtime namespace lookup, lexical shadowing, and default-platform warnings. diff --git a/changelog.d/9757-ordinary-prototype-stores.md b/changelog.d/9757-ordinary-prototype-stores.md new file mode 100644 index 0000000000..78742a109e --- /dev/null +++ b/changelog.d/9757-ordinary-prototype-stores.md @@ -0,0 +1 @@ +Fix statically named `.prototype` assignments on ordinary objects when the receiver is a function parameter or has previously received a computed-key write (#9365). These assignments now use ordinary property semantics, including accessors, proxies, and strict-mode write failures, while preserving function prototype metadata for derived classes. Evaluate the receiver once and keep it rooted while evaluating the assigned value. diff --git a/changelog.d/9758-imported-class-expression-prototypes.md b/changelog.d/9758-imported-class-expression-prototypes.md new file mode 100644 index 0000000000..bbbe910108 --- /dev/null +++ b/changelog.d/9758-imported-class-expression-prototypes.md @@ -0,0 +1 @@ +Fix property reads on imported class-expression bindings, including `.prototype` returning `undefined` across module boundaries (#9366). Imported variables now use the ordinary property dispatcher after loading their current value, preserving class tags and live bindings through renamed re-exports. diff --git a/changelog.d/9759-static-worker-url-helpers.md b/changelog.d/9759-static-worker-url-helpers.md new file mode 100644 index 0000000000..58732328ab --- /dev/null +++ b/changelog.d/9759-static-worker-url-helpers.md @@ -0,0 +1,2 @@ +### Fixes +- Resolve bounded module-local helper chains used as Worker entry URLs, including Bun embedded file URLs and `node:worker_threads` constructors. Substitute static string/URL arguments while rejecting effectful, mutable, recursive, opaque or over-budget helper expressions. Keep evaluating the original filename expression when constructing the Worker. Fixes #9744. diff --git a/changelog.d/9760-bun-jsc-heap-stats.md b/changelog.d/9760-bun-jsc-heap-stats.md new file mode 100644 index 0000000000..5e48318745 --- /dev/null +++ b/changelog.d/9760-bun-jsc-heap-stats.md @@ -0,0 +1,2 @@ +### Fixes +- Implement `bun:jsc.heapStats()` and `heapStats(true)` across static imports, dynamic imports, and `require`. Reports contain Perry's per-thread heap and allocator counters, including type and pinned-cell counts, with JavaScriptCore differences documented. Fixes #9743. diff --git a/changelog.d/9761-import-meta-require.md b/changelog.d/9761-import-meta-require.md new file mode 100644 index 0000000000..ba00924596 --- /dev/null +++ b/changelog.d/9761-import-meta-require.md @@ -0,0 +1,2 @@ +### Fixes +- Lower direct and computed-literal `import.meta.require()` calls through synchronous compiled-module dispatch. Relative and Bun virtual chunk paths are discovered ahead of time, return their namespace immediately, and initialize once when loaded. Fixes #9742. diff --git a/changelog.d/9763-websocket-server-upgrades.md b/changelog.d/9763-websocket-server-upgrades.md new file mode 100644 index 0000000000..584c275a40 --- /dev/null +++ b/changelog.d/9763-websocket-server-upgrades.md @@ -0,0 +1,4 @@ +### Fixed +- Attach native `WebSocketServer({ server })` instances to an existing HTTP listener; deliver manual `handleUpgrade` callbacks and connection events with usable client handles and the original request. +- Bind `WebSocketServer({ port: 0 })` to an ephemeral port and expose the actual listening address through `address()`. +- Treat native handle IDs as identities when hashing Sets, including `WebSocketServer.clients`. diff --git a/changelog.d/9766-transitive-class-inlining.md b/changelog.d/9766-transitive-class-inlining.md new file mode 100644 index 0000000000..a276e18eec --- /dev/null +++ b/changelog.d/9766-transitive-class-inlining.md @@ -0,0 +1,3 @@ +### Fixed + +- Keep cross-module helpers and methods that depend on imported classes in their source module, preserving constructors, methods, and iterators. Fixes the three failing codehz/ecs comprehensive performance tests (#9023). diff --git a/changelog.d/9767-isolated-runtime-fixtures.md b/changelog.d/9767-isolated-runtime-fixtures.md new file mode 100644 index 0000000000..ad7fb7fdb5 --- /dev/null +++ b/changelog.d/9767-isolated-runtime-fixtures.md @@ -0,0 +1,3 @@ +### Fixed + +- Isolate runtime test fixtures that inspect loaded libraries, process-wide box counters, and the composed symbol cache. Prevent neighboring tests from corrupting their assertions, and tighten the box reuse bound (#9197). diff --git a/changelog.d/9768-train124-gate-followups.md b/changelog.d/9768-train124-gate-followups.md new file mode 100644 index 0000000000..91a0c2626d --- /dev/null +++ b/changelog.d/9768-train124-gate-followups.md @@ -0,0 +1,24 @@ +**Gate follow-ups for the 20-PR train.** Five gates went red together; each is +fixed at the source rather than baselined away. + +- `ic_miss.rs`'s new `#[cold]` IC-miss diagnostic guarded a key pointer with a + bare `< 0x1000` floor, which admits the whole handle band — real addresses on + Linux, hidden by macOS's higher mmap base (#9219 class). It now asks + `addr_class::is_above_handle_band`. +- Six raw-handle reads in `bun_compat/jsc.rs`, `class_registry/construct.rs` and + `construct/class_object.rs` moved into `with_mut_ptr` (#7341). +- `IC_DIAG` and `REGEX_DIAG` are classified. Both are off-by-default diagnostics; + `REGEX_DIAG`'s entry records that its `per_pattern` key is a pattern + `StringHeader` address used only as an opaque grouping id — never + dereferenced — and that a recycled address merges two rows' counters. +- `PASS1_MARKED`'s `non_moving_snapshot` pin was re-audited after #9760 touched + `gc/mod.rs`. That change is `mod heap_stats;` plus a re-export and alters no + mark/sweep control flow; `heap_stats()` runs only from the JS-facing + `bun:jsc.heapStats()`, never inside a cycle. The window is unchanged, so only + that one file's digest was re-pinned. +- The string payload-access ratchet moved the good way (358 → 353 sites) and its + baseline is recorded. + +`regex.rs` also crossed the 2000-line cap, so `escape_regexp_source` moved to the +existing `regex/escape.rs` and flags validation to a new `regex/flags.rs`, both +gated on `regex-engine` like their siblings. diff --git a/changelog.d/keystroke-property-key-decode.md b/changelog.d/keystroke-property-key-decode.md new file mode 100644 index 0000000000..50d22562ce --- /dev/null +++ b/changelog.d/keystroke-property-key-decode.md @@ -0,0 +1,28 @@ +### Performance + +- **Property reads no longer UTF-8-validate ASCII keys, and no longer copy + the key or take the async-resource registry lock for ordinary receivers.** + The generic read ladder decodes the key `StringHeader` at several layers + per miss (`js_object_get_field_ic_miss`, closure expando lookup, accessor + and reflection probes, the typed-feedback class-field guards, async- + resource dispatch); `core::str::from_utf8` on those decodes was 2 % of the + claude-code keystroke profile, the guard's `String` copy was a `malloc` + per guarded class-field access, and `async_resource_property` copied the + key and locked the registry before asking whether any AsyncResource + handle existed at all. + + - `crates/perry-runtime/src/string/mod.rs` — `header_str_checked`: a + header whose `utf16_len == byte_len` is pure ASCII, so it is borrowed + unchecked; anything else takes the `from_utf8` scan it always took + (WTF-8 payloads still answer `None`). Used by `has_own_helpers`, + `closure_dynamic_prop_by_key`, the accessor probes, + `typedarray_props::string_header_str` and the typed-feedback guards + (which now borrow instead of allocating a `String`; every consumer is a + Rust-side table read, so the payload cannot move while borrowed). + - `crates/perry-runtime/src/async_hooks.rs` — `is_async_resource_handle` + answers from the atomic handle count before touching the mutex, and the + IC-miss handler / `async_resource_property` ask it before decoding or + copying the key. + + Test: `header_str_checked_matches_from_utf8_on_every_payload_class` + (ASCII, non-ASCII scalar, lone surrogate, empty). diff --git a/changelog.d/keystroke-regex-site-cache.md b/changelog.d/keystroke-regex-site-cache.md new file mode 100644 index 0000000000..0815906d5a --- /dev/null +++ b/changelog.d/keystroke-regex-site-cache.md @@ -0,0 +1,43 @@ +### Performance + +- **RegExp construction and exec no longer hash or copy the pattern text.** + On the claude-code TUI a keystroke re-runs ink's layout, whose text + measurement (`string-width` / `emoji-regex` / `ansi-regex`) evaluates a + regex literal per text segment — `emojiRegex()` is a fresh ~12 KB `/…/g` + per call. Each `js_regexp_new` copied that pattern three times and + SipHashed it once (the `VALIDATED_PATTERNS` probe key, `owned_pattern`, + the `REGEX_SOURCE_TABLE` entry); the first operation on each header did + the same three more times in `build_and_install_programs`; and, for the + common pattern with no fancy fallback, `lookup_fancy_regex` and + `lookup_repeat_matcher` fell through to a full clone + hash of the pattern + on EVERY exec. SipHash over pattern text was 31 % of the main thread in + the 20 s after a 400-char reply had rendered (regex 38 % inclusive). + + - `crates/perry-runtime/src/regex/site_cache.rs` (new) — a thread-local, + content-keyed construction cache: a cheap fingerprint (length, three + 8-byte windows, canonical flags) plus a full byte compare, so identity + never depends on an address. A hit skips validation (validity is a pure + function of the pair), shares the owned pattern/flags as `Arc`, and + installs the programs the first executed header compiled — the new + header is born built and never touches the `(pattern, flags)` caches. + Kill switch `PERRY_REGEX_SITE_CACHE=0`. + - `regex.rs` — `lookup_fancy_regex` / `lookup_repeat_matcher` treat a + built header as authoritative (a null program pointer after the build + IS the answer; every install path publishes all three together), so no + per-exec cache probe remains. `REGEX_SOURCE_TABLE` holds `Arc` + pairs; the two address-keyed regex tables use the pointer hasher. + - `regex/exec.rs` — `test` on a global/sticky receiver runs + `regexp_find_advancing`, the find-only twin of `exec`'s engine phase + (same engine order, `lastIndex` advance/reset and sticky anchoring), + instead of materializing a captures array plus one string per capture + that it then discarded. + - `hot_diag.rs` (new) — `PERRY_REGEX_DIAG=` (constructions, + validated/site hits, pattern bytes, compiles, cache clears, lazy builds, + exec/test/match/replace counts, capture bytes, per-pattern table) and + `PERRY_IC_DIAG=` (property-read IC misses by reason and by site). + Snapshots every ~1 s of activity; diagnostic only. + + Tests: `site_cache_reconstruction_is_born_built` (fails without the + cache) and `global_test_advances_and_resets_last_index` (every + `lastIndex` branch of the find-only path, all three engines, UTF-16 + units) in `regex/tests.rs`. diff --git a/changelog.d/object-cache-build-id-override.md b/changelog.d/object-cache-build-id-override.md new file mode 100644 index 0000000000..c3af5ce70a --- /dev/null +++ b/changelog.d/object-cache-build-id-override.md @@ -0,0 +1,7 @@ +### Build + +- `PERRY_OBJECT_CACHE_BUILD_ID=` pins the build-id component of the + per-module object-cache key, so a `perry` built from a runtime-only branch + can reuse the objects a sibling build cached under the same HIR and options + and go straight to the link. Codegen changes still miss through the HIR and + option fields of the key; an unparsable value is ignored. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 370e208959..7f6a182c25 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -59,6 +59,7 @@ pub const NATIVE_MODULES: &[&str] = &[ // #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier // (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`. "bun:ffi", + "bun:jsc", "ffi", // node:ffi (the node: prefix is normalized away) "bun:sqlite", // Bun facade over Perry's native SQLite engine "node-cron", // cron-style scheduler (npm node-cron; aliases `cron`) @@ -245,6 +246,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ "buffer", // #6562: bun:ffi is implemented entirely in perry-runtime. "bun:ffi", + "bun:jsc", "ffi", "assert", "assert/strict", diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index faead889b2..8ccae06fe6 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -428,6 +428,8 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ // #1113 — `wss.handleUpgrade(req, socket, head, cb)` for a // `new WebSocketServer({ noServer: true })`. method("ws", "handleUpgrade", true, None), + method("ws", "address", true, None), + method("ws", "emit", true, None), // Issue #577 Phase 4 — Client-class methods for the upgrade-path wsId. method("ws", "on", true, Some("Client")), method("ws", "addListener", true, Some("Client")), diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 5dfaccfd74..f47abbcd70 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1121,6 +1121,8 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("bun", "zstdDecompress", false, None), method("bun", "zstdDecompressSync", false, None), method("bun", "gc", false, None), + // Perry heap/allocator statistics; numeric approximations are documented. + method("bun:jsc", "heapStats", false, None), method("bun", "generateHeapSnapshot", false, None), method("bun", "file", false, None), method("bun", "write", false, None), diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 504b743f79..f6a8e47d99 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -52,6 +52,12 @@ pub(crate) fn function_body_returns_generator_object(body: &[perry_hir::Stmt]) - /// Cached at first call so subsequent compile_* calls skip the /// env-var lookup. /// +/// When adding a `PERRY_*` reader using this pattern, register it in +/// `BUILD_CACHE_ENV_VARS` in `crates/perry/src/commands/compile/build_cache.rs`. +/// Only readers that cannot change emitted code belong in that file's +/// `BUILD_CACHE_ENV_EXCLUSIONS`, with a reason. The OnceLock caches the reader; +/// the registry keeps compiled objects from being reused across settings. +/// /// Why on by default now: the shadow stack precisely covers every /// pointer-typed local in compiled JS frames, complementing the /// conservative C-stack scan. With Phase A complete and the GC diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 2f985ef075..83a64c3739 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -606,9 +606,10 @@ fn note_prototype_effect( } // Function-classic prototypes are keyed by a synthetic class id derived // from the closure value, and `new ()` lowers to `NewDynamic`, so - // these cannot rewrite a declared class's table. `SetFunctionPrototype` - // installs a whole prototype object for such a function — same story. - Expr::RegisterFunctionPrototypeMethod { .. } | Expr::SetFunctionPrototype { .. } => {} + // these cannot rewrite a declared class's table. + Expr::RegisterFunctionPrototypeMethod { .. } => {} + // #9365: this node also performs ordinary stores on arbitrary receivers. + Expr::SetFunctionPrototype { func, .. } => note_prototype_holder(func, facts), // Any expression that so much as NAMES a prototype object: the value // can be aliased into a local and written through later. Expr::PropertyGet { diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 64e00dc857..f77a2e08a1 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -64,10 +64,10 @@ pub(crate) use helpers::{ }; use super::{ - emit_string_literal_global, emit_typed_feedback_register_site, import_origin_suffix, - import_origin_suffix_ns, is_global_this_builtin_name, lower_expr, nanbox_pointer_inline, - nanbox_string_inline, raw_f64_layout_fact, try_lower_pod_field_get, unbox_to_i64, FnCtx, - TypedFeedbackContract, TypedFeedbackKind, + emit_string_literal_global, emit_typed_feedback_register_site, import_origin_suffix_ns, + is_global_this_builtin_name, lower_expr, nanbox_pointer_inline, nanbox_string_inline, + raw_f64_layout_fact, try_lower_pod_field_get, unbox_to_i64, FnCtx, TypedFeedbackContract, + TypedFeedbackKind, }; pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { @@ -1225,50 +1225,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } } - // Imported exported-variable access: `Key.DOWN`, `FILTER.X`. - // ExternFuncRef used as a PropertyGet object means an - // imported const — call the getter function to load the - // actual object value, then do the property access on it. - // Without this, the codegen uses the address of the - // ClosureHeader global (wrong memory) instead of the - // object stored in the module's export global. - // - // Gate strictly on `imported_vars`: only exported const/let - // bindings have a `perry_fn___` *getter* whose call - // returns the value. For an imported *function*, that same symbol - // IS the function body — calling it here invoked the function with - // zero args (reading garbage params) and read the property off its - // return value. Stripe hit this on `StripeResource.method` / - // `.extend` (an `export { StripeResource }` function with static - // props); every static read invoked the constructor instead. The - // function/class case falls through to the generic path below, - // which materializes the closure value and reads its dynamic prop. + // #9366: an exported variable can hold a class reference, a heap + // object, or a primitive. Lower the binding through its live getter + // and preserve its value tag in the ordinary property dispatcher. + // Masking every getter result into an ObjectHeader pointer drops + // class-expression prototypes (and other class properties). if let Expr::ExternFuncRef { name, .. } = object.as_ref() { - if ctx.imported_vars.contains(name) { - if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { - // Issue #678: re-export renames mean the suffix in the - // origin module differs from the consumer-visible name. - let origin_suffix = - import_origin_suffix(ctx.import_function_origin_names, name); - let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); - ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); - let obj_val = ctx.block().call(DOUBLE, &getter, &[]); - // Now do property access on the actual object. - let key_idx = ctx.strings.intern(property); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&obj_val); - let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); - return Ok(blk.call( - DOUBLE, - "js_object_get_field_by_name_f64", - &[(I64, &obj_handle), (I64, &key_handle)], - )); - } + if ctx.imported_vars.contains(name) + && ctx.import_function_prefixes.contains_key(name) + { + return lower_generic_property_get(ctx, object, property, *byte_offset); } } // Getter dispatch: if the receiver is a known class and diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 5196d9c092..c57e8d35ac 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -103,6 +103,48 @@ fn emit(debug: bool, source: Option<&str>) -> String { .expect("LLVM IR should be UTF-8") } +#[test] +fn imported_variable_read_preserves_class_tags_and_calls_the_live_getter_once() { + let mut module = Module::new("imported_class_9366.ts"); + module.init.push(Stmt::Expr(Expr::PropertyGet { + object: Box::new(Expr::ExternFuncRef { + name: "Renamed".to_string(), + param_types: vec![], + return_type: perry_hir::types::Type::Any, + }), + property: "prototype".to_string(), + byte_offset: 0, + })); + let mut opts = ir_opts(false, None); + opts.imported_vars.insert("Renamed".to_string()); + opts.import_function_prefixes + .insert("Renamed".to_string(), "remote".to_string()); + opts.import_function_origin_names + .insert("Renamed".to_string(), "Expr".to_string()); + let ir = String::from_utf8(compile_module(&module, opts).unwrap()).unwrap(); + let getter = "perry_fn_remote__Expr"; + assert_eq!( + ir.matches(&format!("call double @{getter}(")).count(), + 1, + "{ir}" + ); + let value = crate::testing::temp_slots::first_call_result(&ir, getter).unwrap(); + let bits = ir + .lines() + .find_map(|line| { + let (result, operand) = line.trim().split_once(" = bitcast double ")?; + (operand == format!("{value} to i64")).then_some(result) + }) + .expect("getter result must be classified by its intact value tag"); + assert!( + ir.lines().any(|line| { + line.contains("call double @js_typed_feedback_object_get_field_by_name_f64(") + && line.contains(&format!(", i64 {bits},")) + }), + "class dispatch must receive the getter's unmasked value bits:\n{ir}" + ); +} + fn emit_guarded_length_read() -> String { let mut module = Module::new("guarded_length_read.ts"); module.init = vec![ diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 8af687611a..e768a9685d 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -726,26 +726,28 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Ok(obj_box) }) } - // Issue #711 part 2: `.prototype = ` pattern. - // Calls `js_set_function_prototype(func, proto)`, which (when - // func is a closure and proto is an object) allocates a - // synthetic class id and binds the proto object as that - // class's vtable source. Method dispatch later consults - // CLASS_PROTOTYPE_OBJECTS to resolve methods. - Expr::SetFunctionPrototype { func, proto } => { - let func_val = lower_expr(ctx, func)?; - let proto_val = lower_expr(ctx, proto)?; - // Discard the returned synthetic class id — it's stored in - // the runtime side-table keyed by func_val and consulted - // later by `js_register_class_parent_dynamic`. User code - // gets the assigned value (proto_val) as the expression - // result, matching JS semantics for `x.foo = bar`. - let _ = ctx.block().call( - crate::types::I32, - "js_set_function_prototype", - &[(DOUBLE, &func_val), (DOUBLE, &proto_val)], - ); - Ok(proto_val) + Expr::SetFunctionPrototype { + func, + proto, + strict, + } => { + with_rooted_group(ctx, 1, |ctx, group| { + let protect_receiver = any_operand_may_collect(ctx, [proto.as_ref()]); + let receiver = group.lower(ctx, func, protect_receiver)?; + let value = lower_expr(ctx, proto)?; + let receiver = group.reread(ctx, receiver)?; + // The setter can run user code and collect. Its rooted return + // supplies the assignment result after any evacuation. + Ok(ctx.block().call( + DOUBLE, + "js_set_prototype_property", + &[ + (DOUBLE, &receiver), + (DOUBLE, &value), + (I32, if *strict { "1" } else { "0" }), + ], + )) + }) } // Link a generator/async-generator instance into the spec prototype // chain. Closure bodies can use their own closure pointer to preserve diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index b7f35da1e9..d40f4283a6 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -515,6 +515,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_ws_close_client", OwnerKind::WellKnown("ws")), ("js_ws_server_new", OwnerKind::WellKnown("ws")), ("js_ws_server_clients", OwnerKind::WellKnown("ws")), + ("js_ws_server_address", OwnerKind::WellKnown("ws")), + ("js_ws_server_emit", OwnerKind::WellKnown("ws")), ("js_ws_server_close", OwnerKind::WellKnown("ws")), // ── #1724: global Blob/File + URL object-URL helpers ────────────── diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index 9f8c1c2110..5ae17ea8c0 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -9,6 +9,15 @@ use super::*; /// `Bun.stdin` / `Bun.stdout` / `Bun.stderr` are property reads (handled by /// `js_native_module_property_by_name`), not rows here. pub(crate) const BUN_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "bun:jsc", + has_receiver: false, + method: "heapStats", + class_filter: None, + runtime: "js_bun_jsc_heap_stats", + args: &[NA_F64], + ret: NR_F64, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs index 9b7ec796c0..7cdadc04c1 100644 --- a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs @@ -78,6 +78,33 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "handleUpgrade", + class_filter: None, + runtime: "js_ws_handle_upgrade", + args: &[NA_F64, NA_F64, NA_F64, NA_PTR], + ret: NR_VOID, + }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "address", + class_filter: None, + runtime: "js_ws_server_address", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "emit", + class_filter: None, + runtime: "js_ws_server_emit", + args: &[NA_STR, NA_F64, NA_F64], + ret: NR_BOOL, + }, // Issue #577 Phase 4 — `("ws", "Client")` instance methods. // The wsId delivered to `Server.on('upgrade', (req, wsId, head) => …)` // is NaN-boxed POINTER_TAG so unbox_to_i64 (called by the dispatch diff --git a/crates/perry-codegen/src/nm_install.rs b/crates/perry-codegen/src/nm_install.rs index 15d3c3960c..d742be0543 100644 --- a/crates/perry-codegen/src/nm_install.rs +++ b/crates/perry-codegen/src/nm_install.rs @@ -14,6 +14,7 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { "bigint" => Some("js_nm_install_bigint"), "buffer" | "buffer.Buffer" => Some("js_nm_install_buffer"), "bun" => Some("js_bun_tcp_nm_install"), + "bun:jsc" => Some("js_nm_install_bun"), // #6562: bun:ffi keeps its scheme prefix (only `node:` is stripped // above). "bun:ffi" | "ffi" | "ffi.default" => Some("js_nm_install_bun_ffi"), @@ -86,6 +87,7 @@ pub(crate) const NM_INSTALL_SYMBOLS: &[&str] = &[ "js_nm_install_bigint", "js_nm_install_buffer", "js_bun_tcp_nm_install", + "js_nm_install_bun", "js_nm_install_bun_ffi", "js_nm_install_child_process", "js_nm_install_cluster", diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 5a578f4c87..1a9b60f941 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -313,6 +313,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_nm_install_buffer", VOID, &[]); module.declare_function("js_bun_tcp_nm_install", VOID, &[]); // #6562: bun:ffi dispatch bucket. + module.declare_function("js_nm_install_bun", VOID, &[]); module.declare_function("js_nm_install_bun_ffi", VOID, &[]); module.declare_function("js_nm_install_child_process", VOID, &[]); module.declare_function("js_nm_install_cluster", VOID, &[]); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs index a7b8115ee7..6d1b5b460c 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs @@ -130,6 +130,8 @@ pub(crate) fn declare_web(module: &mut LlModule) { module.declare_function("js_ws_on_client_i64", I64, &[I64, I64, I64]); module.declare_function("js_ws_server_close", VOID, &[I64]); module.declare_function("js_ws_server_clients", DOUBLE, &[I64]); + module.declare_function("js_ws_server_address", DOUBLE, &[I64]); + module.declare_function("js_ws_server_emit", I32, &[I64, I64, DOUBLE, DOUBLE]); module.declare_function("js_ws_server_new", I64, &[DOUBLE]); // #1113 — `wss.handleUpgrade(req, socket, head, cb)`. Receiver // (the noServer WsServerHandle) is passed as I64 (post-unbox_to_i64 @@ -138,7 +140,7 @@ pub(crate) fn declare_web(module: &mut LlModule) { // cb is the unboxed closure pointer (I64). module.declare_function( "js_ws_handle_upgrade", - I64, + VOID, &[I64, DOUBLE, DOUBLE, DOUBLE, I64], ); module.declare_function("js_ws_wait_for_message", I64, &[I64, DOUBLE]); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index b101f9cbf9..c4a02e2aba 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1479,13 +1479,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { &[I32, PTR, I64, DOUBLE, DOUBLE], ); module.declare_function("js_array_push_spread_any", I64, &[I64, DOUBLE]); - // Issue #711 part 2: prototype-based class declaration via - // `.prototype = `. Binds an object as the function's - // prototype source; subsequent `class X extends ` lookups - // dispatch into the object's methods. Returns the synthetic - // class id allocated for the function value (or 0 on validation - // failure). Codegen discards the return. + // Retain the legacy registration ABI. New assignments use ordinary + // PutValue and synchronize function metadata only from the stored value. module.declare_function("js_set_function_prototype", I32, &[DOUBLE, DOUBLE]); + module.declare_function("js_set_prototype_property", DOUBLE, &[DOUBLE, DOUBLE, I32]); // Issue #838: JS-classic prototype-method assignment. // `Class.prototype.method = fn` (or the aliased // `let p = Class.prototype; p.method = fn` shape) registers the diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 88b3201342..6bfb4f71b5 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -164,6 +164,28 @@ fn allocating() -> Expr { Expr::Object(Vec::new()) } +#[test] +fn prototype_assignment_receiver_survives_an_allocating_rhs() { + let ir = ir_for( + "prototype_store_9365.cts", + vec![Stmt::Expr(Expr::SetFunctionPrototype { + func: Box::new(allocating()), + proto: Box::new(allocating()), + strict: false, + })], + ); + let f = init_ir(&ir); + assert_eq!( + f.lines() + .filter(|line| line.contains("call i64 @js_object_alloc(")) + .count(), + 2, + "both operands must allocate exactly once:\n{f}", + ); + let receiver = first_call_result(f, "js_object_alloc").expect("receiver allocation"); + assert_rooted_across(f, &receiver, "js_set_prototype_property", "#9365 receiver"); +} + // ---------------------------------------------------------------- #6970 ---- /// `m.set(key, value)` where `value` allocates: `key` is finished but lives in diff --git a/crates/perry-ext-http/src/server/dispatch_ext.rs b/crates/perry-ext-http/src/server/dispatch_ext.rs index a13ea8f317..5a314a299d 100644 --- a/crates/perry-ext-http/src/server/dispatch_ext.rs +++ b/crates/perry-ext-http/src/server/dispatch_ext.rs @@ -68,6 +68,7 @@ extern "C" { pub(crate) fn ensure_dispatch_extensions_registered() { static REGISTER: Once = Once::new(); REGISTER.call_once(|| unsafe { + perry_ext_ws::register_http_address_reader(crate::server::upgrade::attached_address); js_register_handle_method_dispatch_extension(http_server_method_dispatch_ext); js_register_handle_property_dispatch_extension(http_server_property_dispatch_ext); js_register_handle_property_set_dispatch_extension(http_server_property_set_dispatch_ext); diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 4dd197b078..ff11492046 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -1220,7 +1220,9 @@ async fn handle_request( let has_upgrade_listeners = get_handle::(server_handle) .map(|server| server_has_event_listener(server, "upgrade")) .unwrap_or(false); - if has_upgrade_listeners && req.headers().contains_key("sec-websocket-key") { + if (has_upgrade_listeners || perry_ext_ws::has_attached_server(server_handle)) + && req.headers().contains_key("sec-websocket-key") + { return handle_websocket_upgrade( server_handle, peer, @@ -1582,6 +1584,11 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { up.head, ); } else { + perry_ext_ws::accept_attached_connection( + up.server_handle, + handle_to_pointer_f64(up.request_handle), + up.ws_id, + ); crate::server::upgrade::fire_upgrade_listeners( up.server_handle, up.request_handle, diff --git a/crates/perry-ext-http/src/server/server/deferred_events.rs b/crates/perry-ext-http/src/server/server/deferred_events.rs index 0d1cba9b24..86084ac72d 100644 --- a/crates/perry-ext-http/src/server/server/deferred_events.rs +++ b/crates/perry-ext-http/src/server/server/deferred_events.rs @@ -95,6 +95,7 @@ where // #8082: the drained snapshot crosses each callback — root it. let scope = perry_ffi::TransientRootScope::enter(); let rooted = scope.root_addrs(&cbs); + perry_ext_ws::attached_server_listening(server_handle); let mut call = DeferredCallbacksCall { callbacks: rooted.as_ptr(), len: rooted.len(), diff --git a/crates/perry-ext-http/src/server/upgrade.rs b/crates/perry-ext-http/src/server/upgrade.rs index 2b0a9695cd..9ba0a8db54 100644 --- a/crates/perry-ext-http/src/server/upgrade.rs +++ b/crates/perry-ext-http/src/server/upgrade.rs @@ -22,18 +22,7 @@ //! so user code can interact with it through `ws.on('message',…)`, //! `ws.send(…)`, `ws.close(…)` unchanged. //! -//! The TS-side wrapper for `import { WebSocketServer } from 'ws'` -//! when constructed with `{ server }` simply registers an -//! `'upgrade'` listener that re-dispatches to its own `'connection'` -//! event: -//! -//! ```ts -//! const wss = new WebSocketServer({ server: httpServer }); -//! // wss internally: -//! // server.on('upgrade', (req, wsId, head) => { -//! // wss.emit('connection', wsId, req); -//! // }); -//! ``` +//! Attached WebSocket servers are native observers registered by perry-ext-ws. use perry_ffi::{alloc_string, get_handle_mut, JsClosure, RawClosureHeader}; @@ -122,3 +111,19 @@ pub(crate) fn fire_upgrade_listeners( fn _force_link() -> u64 { POINTER_TAG | (PTR_MASK & 0) } + +/// Read owned address metadata without allocating JS objects or introducing a +/// reverse dependency from ws to HTTP. +pub(crate) fn attached_address(handle: i64) -> Option<(String, u16)> { + perry_ffi::get_handle::(handle) + .and_then(|s| s.listening.then(|| (s.bound_host.clone(), s.bound_port))) + .or_else(|| { + perry_ffi::get_handle::(handle).and_then( + |s| { + s.base + .listening + .then(|| (s.base.bound_host.clone(), s.base.bound_port)) + }, + ) + }) +} diff --git a/crates/perry-ext-http/src/test_async_shims.rs b/crates/perry-ext-http/src/test_async_shims.rs index 6f642e5f85..d6bd8c9f12 100644 --- a/crates/perry-ext-http/src/test_async_shims.rs +++ b/crates/perry-ext-http/src/test_async_shims.rs @@ -64,3 +64,9 @@ pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( // host stdlib archive, which unit-test binaries do not link. #[no_mangle] pub extern "C" fn perry_ffi_spawn_async(_ctx: *mut c_void) {} + +// Linking the ws dispatch extension also retains its synchronous polling +// helper. These unit tests use the no-op task shim above; real networking is +// exercised by the compiled HTTP/WebSocket integration tests. +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry-ext-ws/src/dispatch.rs b/crates/perry-ext-ws/src/dispatch.rs new file mode 100644 index 0000000000..b3090c9518 --- /dev/null +++ b/crates/perry-ext-ws/src/dispatch.rs @@ -0,0 +1,126 @@ +//! Runtime dispatch for WebSocket receivers whose static type was erased. +use super::*; + +extern "C" { + fn js_register_handle_method_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32, + ); + fn js_class_method_bind(receiver: f64, name: *const u8, len: usize) -> f64; +} + +pub(super) unsafe fn register_method_dispatch() { + js_register_handle_method_dispatch_extension(method); +} + +fn knows(handle: i64, name: &str) -> bool { + if get_handle_mut::(handle).is_some() { + matches!( + name, + "clients" | "address" | "handleUpgrade" | "emit" | "on" | "addListener" | "close" + ) + } else if get_handle_mut::(handle).is_some() { + matches!(name, "send" | "close" | "on" | "addListener" | "readyState") + } else { + false + } +} + +pub(super) unsafe fn property(handle: i64, ptr: *const u8, len: usize, out: *mut f64) -> i32 { + if ptr.is_null() { + return 0; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else { + return 0; + }; + if !knows(handle, name) { + return 0; + } + let value = match name { + "clients" => js_ws_server_clients(handle), + "readyState" => js_ws_ready_state(handle), + _ => js_class_method_bind(f64::from_bits(POINTER_TAG | handle as u64), ptr, len), + }; + if !out.is_null() { + *out = value; + } + 1 +} + +unsafe extern "C" fn method( + handle: i64, + ptr: *const u8, + len: usize, + args: *const f64, + argc: usize, + out: *mut f64, +) -> i32 { + if ptr.is_null() { + return 0; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else { + return 0; + }; + if !knows(handle, name) || matches!(name, "clients" | "readyState") { + return 0; + } + let args = if args.is_null() { + &[][..] + } else { + std::slice::from_raw_parts(args, argc) + }; + let scope = perry_ffi::TransientRootScope::enter(); + let args: Vec<_> = args.iter().map(|value| scope.root_nanbox(*value)).collect(); + let arg = |i| { + args.get(i) + .map(|value: &perry_ffi::TransientRootedNanbox| value.get()) + .unwrap_or_else(undefined) + }; + let value = match name { + "address" => js_ws_server_address(handle), + "handleUpgrade" => { + js_ws_handle_upgrade( + handle, + arg(0), + arg(1), + arg(2), + (arg(3).to_bits() & POINTER_MASK) as i64, + ); + undefined() + } + "emit" => { + let event = string_arg(arg(0)); + f64::from_bits( + JsValue::from_bool(js_ws_server_emit(handle, event, arg(1), arg(2)) != 0).bits(), + ) + } + "on" | "addListener" => { + let event = string_arg(arg(0)); + js_ws_on(handle, event, (arg(1).to_bits() & POINTER_MASK) as i64); + f64::from_bits(POINTER_TAG | handle as u64) + } + "send" => { + js_ws_send(handle, string_arg(arg(0))); + undefined() + } + "close" => { + js_ws_close(handle); + undefined() + } + _ => return 0, + }; + if !out.is_null() { + *out = value; + } + 1 +} + +fn string_arg(value: f64) -> *const StringHeader { + let value = JsValue::from_bits(value.to_bits()); + if value.is_short_string() { + value_string(value) + .map(|s| alloc_string(&s).as_raw() as *const StringHeader) + .unwrap_or(std::ptr::null()) + } else { + value.as_string_ptr() + } +} diff --git a/crates/perry-ext-ws/src/lib.rs b/crates/perry-ext-ws/src/lib.rs index 5d381615dc..83c64d8a5c 100644 --- a/crates/perry-ext-ws/src/lib.rs +++ b/crates/perry-ext-ws/src/lib.rs @@ -25,6 +25,7 @@ //! enough for typical WebSocket usage. Cooperative `spawn_async` is //! a v0.6.0 followup. +mod dispatch; /// SIMD-widened WebSocket frame (un)masking (RFC 6455 §5.3). See /// [`mask::apply_mask`] / [`mask::apply_mask_from`]. The hot tungstenite /// read/write path masks internally with its own `u32`-blocked routine @@ -32,6 +33,8 @@ /// frame bytes perry handles itself — kept byte-identical to the scalar /// reference and validated by a property test. pub mod mask; +mod server; +pub use server::*; #[cfg(test)] mod test_async_shims; @@ -42,8 +45,7 @@ use perry_ffi::{ alloc_set, alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut, notify_main_thread, register_aux_event_pump, register_handle, set_add, set_delete, spawn_async, spawn_blocking_with_reactor as spawn_blocking, take_handle, - GcRootVisitor, Handle, JsClosure, JsString, JsValue, ObjectHeader, RawClosureHeader, - StringHeader, + GcRootVisitor, Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::atomic::{AtomicI32, Ordering}; @@ -75,6 +77,8 @@ unsafe fn read_str(ptr: *const StringHeader) -> Option { // ── Global state ────────────────────────────────────────────────── +struct WsClientHandle; + struct WsConnection { sender: mpsc::UnboundedSender, messages: Vec, @@ -101,6 +105,9 @@ pub struct WsServerHandle { /// Event name → list of closure pointers. pub listeners: HashMap>, pub port: u16, + pub host: String, + pub attached_server: Option, + pub no_server: bool, pub is_listening: bool, pub client_ids: Vec, /// The persistent JavaScript `Set` exposed as `WebSocketServer.clients`. @@ -127,7 +134,6 @@ enum PendingWsEvent { lazy_static! { static ref WS_CONNECTIONS: Mutex> = Mutex::new(HashMap::new()); static ref WS_CLIENT_PARENT_SERVER: Mutex> = Mutex::new(HashMap::new()); - static ref NEXT_WS_ID: Mutex = Mutex::new(1); static ref WS_CLIENT_LISTENERS: Mutex> = Mutex::new(HashMap::new()); static ref WS_PENDING_EVENTS: Mutex> = Mutex::new(Vec::new()); @@ -150,7 +156,8 @@ fn ensure_runtime_hooks_registered() { gc_register_mutable_root_scanner_named("perry-ext-ws", scan_ws_roots); register_aux_event_pump(js_ws_process_pending, js_ws_has_pending); unsafe { - js_register_handle_property_dispatch_extension(js_ext_ws_handle_property_dispatch) + js_register_handle_property_dispatch_extension(js_ext_ws_handle_property_dispatch); + dispatch::register_method_dispatch(); }; }); } @@ -168,9 +175,8 @@ fn ensure_runtime_hooks_registered() { /// heartbeat — uncatchable by application code, so the process exited every /// 30 seconds. /// -/// Returns 0 (not handled) for every other property and for any handle that is -/// not a live `WsServerHandle`, so the composite dispatcher falls through to -/// the primary stdlib dispatcher unchanged. +/// Also exposes native server/client method values. Unknown members and +/// unrelated handle types fall through to the primary dispatcher. /// /// # Safety /// FFI entry; `property_name_ptr` must be valid for `property_name_len` bytes, @@ -182,20 +188,7 @@ pub unsafe extern "C" fn js_ext_ws_handle_property_dispatch( property_name_len: usize, out: *mut f64, ) -> i32 { - if property_name_ptr.is_null() || property_name_len != b"clients".len() { - return 0; - } - if std::slice::from_raw_parts(property_name_ptr, property_name_len) != b"clients" { - return 0; - } - let clients = js_ws_server_clients(handle); - if clients.to_bits() == JsValue::UNDEFINED.bits() { - return 0; - } - if !out.is_null() { - *out = clients; - } - 1 + dispatch::property(handle, property_name_ptr, property_name_len, out) } fn scan_ws_roots(visitor: &mut GcRootVisitor<'_>) { @@ -225,9 +218,7 @@ fn push_ws_event(ev: PendingWsEvent) { #[inline] fn client_js_value(ws_id: usize) -> JsValue { - // Server-side clients are represented throughout this wrapper as ordinary - // numeric handles (the same value delivered to `connection` listeners). - JsValue::from_number(ws_id as f64) + JsValue::from_bits(POINTER_TAG | ws_id as u64) } /// Add a connection to a server's persistent JS-visible clients Set. @@ -325,10 +316,7 @@ pub extern "C" fn js_ws_connect_start(url_nanboxed: f64) -> f64 { // Allocate the id synchronously so the caller can register // listeners before the connect resolves. - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -381,10 +369,7 @@ fn setup_client_io( tokio_tungstenite::MaybeTlsStream, >, ) -> usize { - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -500,6 +485,10 @@ pub unsafe extern "C" fn js_ws_send(handle: i64, message_ptr: *const StringHeade #[no_mangle] pub extern "C" fn js_ws_close(handle: i64) { + if get_handle_mut::(handle).is_some() { + js_ws_server_close(handle); + return; + } let id = handle as usize; if let Some(c) = WS_CONNECTIONS.lock().unwrap().get_mut(&id) { let _ = c.sender.send(WsCommand::Close); @@ -598,7 +587,7 @@ pub unsafe extern "C" fn js_ws_on_client_i64( /// `message_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *const StringHeader) { - let id = handle_f64 as i64 as usize; + let id = decode_client_id(handle_f64); let Some(msg) = read_str(message_ptr) else { return; }; @@ -609,7 +598,7 @@ pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *con #[no_mangle] pub extern "C" fn js_ws_close_client(handle_f64: f64) { - let id = handle_f64 as i64 as usize; + let id = decode_client_id(handle_f64); if let Some(c) = WS_CONNECTIONS.lock().unwrap().get_mut(&id) { let _ = c.sender.send(WsCommand::Close); c.is_open = false; @@ -728,14 +717,7 @@ pub unsafe extern "C" fn js_ws_on( if callback_ptr == 0 { return handle; } - // Issue #606: client ws_ids (NEXT_WS_ID counter) and server handle - // ids (perry-ffi NEXT_HANDLE counter) live in disjoint registries - // but their numeric ranges collide — both start near 1. If we look - // up the server registry first, a client id that happens to also - // be a registered server handle id would route through the server - // arm and the user's `client.on("open", cb)` would land on the - // server's listeners. Check the client registry first so client - // dispatch is correct regardless of allocation order. + // Client and server ids share the handle allocator, so routing is unambiguous. let ws_id = handle as usize; let is_client = WS_CONNECTIONS.lock().unwrap().contains_key(&ws_id); if !is_client { @@ -789,210 +771,6 @@ pub unsafe extern "C" fn js_ws_on( // ── Server ──────────────────────────────────────────────────────── -/// `new WebSocketServer({ port })` — sync ctor; spawns the accept loop. -/// -/// #1113: `new WebSocketServer({ noServer: true })` must NOT bind a -/// TCP port or spawn the accept loop — it's a passive registry whose -/// connections arrive exclusively via `wss.handleUpgrade(...)` driven -/// by a host server's `'upgrade'` event (fastify's `app.server` or -/// `node:http`). For that shape we register a listener-only handle and -/// return early; `WS_ACTIVE_SERVERS` is left untouched so a noServer -/// wss doesn't keep the event loop alive on its own (the host server's -/// has-active gate — `js_fastify_has_active` — does that). -#[no_mangle] -pub extern "C" fn js_ws_server_new(opts_f64: f64) -> Handle { - ensure_runtime_hooks_registered(); - let port = extract_port(opts_f64); - let no_server = extract_no_server(opts_f64); - let clients_bits = alloc_set(4).bits(); - - if no_server || port == 0 { - // Listener-only handle — no bind, no accept loop, no shutdown - // channel (nothing to shut down). Connections are injected via - // `js_ws_handle_upgrade`. - return register_handle(WsServerHandle { - listeners: HashMap::new(), - port: 0, - is_listening: false, - client_ids: Vec::new(), - clients_bits, - shutdown_tx: None, - }); - } - - let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); - let server_handle = register_handle(WsServerHandle { - listeners: HashMap::new(), - port, - is_listening: false, - client_ids: Vec::new(), - clients_bits, - shutdown_tx: Some(shutdown_tx), - }); - WS_ACTIVE_SERVERS.fetch_add(1, Ordering::Relaxed); - let handle_id = server_handle; - // Issue #606 — `spawn_blocking_with_reactor` already runs the closure - // inside a tokio worker task, so `Handle::current().block_on(fut)` panics - // with "Cannot start a runtime from within a runtime". Schedule the - // accept loop as a sibling task on the existing runtime instead. - // (Same root cause as the v0.5.691 sweep that fixed perry-ext-http's - // server.rs / https_server.rs / http2_server.rs and perry-ext-ws's - // `drive_server_client_io` — this site was missed in that sweep.) - spawn_blocking(move || { - tokio::spawn(async move { - let addr = format!("0.0.0.0:{}", port); - let listener = match tokio::net::TcpListener::bind(&addr).await { - Ok(l) => l, - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("WebSocketServer bind error: {}", e), - )); - return; - } - }; - if let Some(s) = get_handle_mut::(handle_id) { - s.is_listening = true; - } - push_ws_event(PendingWsEvent::Listening(handle_id)); - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok((tcp_stream, _addr)) => { - match tokio_tungstenite::accept_async(tcp_stream).await { - Ok(ws_stream) => { - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); - let (tx, rx) = mpsc::unbounded_channel::(); - WS_CONNECTIONS.lock().unwrap().insert(ws_id, WsConnection { - sender: tx, - messages: Vec::new(), - is_open: true, - is_closing: false, - is_closed: false, - }); - WS_CLIENT_LISTENERS.lock().unwrap().insert(ws_id, WsClientListeners { - listeners: HashMap::new(), - }); - if let Some(s) = get_handle_mut::(handle_id) { - s.client_ids.push(ws_id); - } - WS_CLIENT_PARENT_SERVER.lock().unwrap().insert(ws_id, handle_id); - push_ws_event(PendingWsEvent::Connection(handle_id, ws_id)); - drive_server_client_io(ws_id, ws_stream, rx); - } - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("WebSocket handshake error: {}", e), - )); - } - } - } - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("accept error: {}", e), - )); - } - } - } - _ = shutdown_rx.recv() => { - break; - } - } - } - if let Some(s) = get_handle_mut::(handle_id) { - s.is_listening = false; - } - WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); - }); - }); - server_handle -} - -/// Return the persistent `Set` exposed as `WebSocketServer.clients`. -/// -/// The Set is allocated with the server, updated before connection/close -/// callbacks run, and rooted through the server handle for its full lifetime. -#[no_mangle] -pub extern "C" fn js_ws_server_clients(handle: i64) -> f64 { - get_handle_mut::(handle) - .map(|server| f64::from_bits(server.clients_bits)) - .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())) -} - -fn extract_port(opts_f64: f64) -> u16 { - let bits = opts_f64.to_bits(); - if (bits & TAG_MASK) == POINTER_TAG { - let ptr = (bits & POINTER_MASK) as *const ObjectHeader; - if !ptr.is_null() { - // Object literal: assume `port` is the first field - // (positional shape — same convention as nodemailer/pg/mysql2). - let val = unsafe { perry_ffi::js_object_get_field(ptr, 0) }; - if val.is_number() { - let n = val.to_number(); - if n.is_finite() && n > 0.0 { - return n as u16; - } - } - } - return 0; - } - if opts_f64.is_finite() && opts_f64 > 0.0 { - opts_f64 as u16 - } else { - 0 - } -} - -/// #1113 — detect `new WebSocketServer({ noServer: true })`. -/// -/// perry-ffi exposes only positional object-field reads -/// (`js_object_get_field(ptr, idx)`), not name-based lookup, so we -/// can't read the `noServer` key by name. Heuristic: an options -/// object that carries a `true` boolean field AND no positive numeric -/// port field is a `noServer` config. (A real `{ port: N }` config -/// has a positive number in field 0 — `extract_port` handles that; -/// a `{ noServer: true }` config has no port and a `true` boolean.) -/// `js_ws_server_new` additionally treats "object with no positive -/// port" as noServer, so this is a belt-and-suspenders signal that -/// also catches `{ noServer: true, ...other }` shapes regardless of -/// field order. -fn extract_no_server(opts_f64: f64) -> bool { - let bits = opts_f64.to_bits(); - if (bits & TAG_MASK) != POINTER_TAG { - return false; - } - let ptr = (bits & POINTER_MASK) as *const ObjectHeader; - if ptr.is_null() { - return false; - } - unsafe { - // #8113: the header's `field_count` word is gone; the live inline-slot - // bound comes from the runtime accessor. - let n = perry_ffi::js_object_live_slot_count(ptr); - let mut saw_true = false; - let mut saw_positive_port = false; - for i in 0..n { - let v = perry_ffi::js_object_get_field(ptr, i); - if v.is_bool() && v.to_bool() { - saw_true = true; - } - if v.is_number() { - let num = v.to_number(); - if num.is_finite() && num > 0.0 { - saw_positive_port = true; - } - } - } - saw_true && !saw_positive_port - } -} - fn drive_server_client_io( ws_id: usize, ws_stream: tokio_tungstenite::WebSocketStream, @@ -1137,10 +915,7 @@ where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, { ensure_runtime_hooks_registered(); - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -1176,22 +951,10 @@ where /// re-dispatch shim — it does NOT register another stream or perform /// another handshake. /// -/// Steps (mirror `WebSocketServer({port})`'s per-connection wiring): -/// 1. Decode `ws_id` from the POINTER_TAG-boxed `ws_id_f64`. -/// 2. Adopt the connection under this server (`WS_CLIENT_PARENT_SERVER` -/// + `client_ids`) so server-level `wss.on('message'|'close', …)` -/// handlers route, and the GC scanner pins the right listeners. -/// 3. Invoke the user's `cb(socket)` with `socket === ws_id_f64` -/// (the same NaN-boxed id `wss.on('connection', (ws) => …)` gets, -/// so `ws.send(...)` / `ws.on(...)` dispatch through the Client -/// class arm). -/// 4. Also push `PendingWsEvent::Connection` so a separately -/// registered `wss.on('connection', cb)` fires through the pump. -/// -/// `req_f64` / `head_f64` are accepted for API shape parity (Node's -/// `handleUpgrade(request, socket, head, callback)`); they're not -/// consumed here — the request metadata was already surfaced to the -/// `'upgrade'` handler. +/// Adopt the client into the server's tracked Set before invoking +/// `cb(socket, request)`. The callback decides whether to emit `connection`; +/// `handleUpgrade` itself never emits that event and returns `undefined`. +/// The HTTP transport has already consumed the head bytes. /// /// # Safety /// `cb`, when non-zero, must be a valid NaN-boxed / raw closure @@ -1200,48 +963,32 @@ where #[no_mangle] pub unsafe extern "C" fn js_ws_handle_upgrade( server_handle: i64, - _req_f64: f64, + req_f64: f64, ws_id_f64: f64, _head_f64: f64, cb: i64, -) -> i64 { +) { ensure_runtime_hooks_registered(); - // `ws_id_f64` is POINTER_TAG-boxed (the host upgrade path encodes - // it as `POINTER_TAG | (ws_id & POINTER_MASK)` so codegen's - // unbox_to_i64 round-trips it). Extract the low-48 bits. - let ws_id = (ws_id_f64.to_bits() & POINTER_MASK) as usize; - if ws_id == 0 { - return server_handle; + let scope = perry_ffi::TransientRootScope::enter(); + let cb = scope.root_addr((cb as u64 & POINTER_MASK) as i64); + let req = scope.root_nanbox(req_f64); + let ws_id = decode_client_id(ws_id_f64); + if get_handle_mut::(server_handle).is_none() + || !WS_CONNECTIONS.lock().unwrap().contains_key(&ws_id) + { + return; } - WS_CLIENT_PARENT_SERVER .lock() .unwrap() .insert(ws_id, server_handle); - // `ws` adds the socket to `clients` before invoking handleUpgrade's - // callback. Keep that ordering so the callback observes itself in the Set. track_server_client(server_handle, ws_id); - - if cb != 0 { - // Accept either a NaN-boxed POINTER_TAG closure or a raw - // pointer (same dual-shape the rest of the crate handles). - let raw = if (cb as u64 & TAG_MASK) == POINTER_TAG { - (cb as u64 & POINTER_MASK) as *const RawClosureHeader - } else { - cb as *const RawClosureHeader - }; - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - let _ = closure.call1(ws_id_f64); - } + // ws delegates connection emission to the callback. Emitting again here + // duplicates the usual `wss.emit("connection", ws, req)` idiom. + if cb.get() != 0 { + let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); + let _ = closure.call2(f64::from_bits(client_js_value(ws_id).bits()), req.get()); } - - // Also fire a Connection event so a `wss.on('connection', cb)` - // registered separately from `handleUpgrade`'s inline callback - // still runs through the normal pump. - push_ws_event(PendingWsEvent::Connection(server_handle, ws_id)); - notify_main_thread(); - server_handle } // ── Event-loop tick ─────────────────────────────────────────────── @@ -1268,9 +1015,11 @@ pub extern "C" fn js_ws_process_pending() -> i32 { for cb in listeners { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - // Pass client_id as f64 so user handler can - // pass it back to js_ws_send_to_client etc. - let _ = unsafe { closure.call1(client_id as f64) }; + // Use the same handle value as the clients Set and + // manual-upgrade callback, including dynamic dispatch. + let _ = unsafe { + closure.call1(f64::from_bits(client_js_value(client_id).bits())) + }; fired += 1; } } @@ -1297,7 +1046,12 @@ pub extern "C" fn js_ws_process_pending() -> i32 { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - let _ = unsafe { closure.call2(ws_id as f64, msg_f64) }; + let _ = unsafe { + closure.call2( + f64::from_bits(client_js_value(ws_id).bits()), + msg_f64, + ) + }; fired += 1; } } @@ -1327,7 +1081,9 @@ pub extern "C" fn js_ws_process_pending() -> i32 { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - let _ = unsafe { closure.call1(ws_id as f64) }; + let _ = unsafe { + closure.call1(f64::from_bits(client_js_value(ws_id).bits())) + }; fired += 1; } } @@ -1494,6 +1250,9 @@ mod tests { let server_handle = register_handle(WsServerHandle { listeners: HashMap::from([("connection".to_string(), vec![server_callback])]), port: 0, + host: "0.0.0.0".into(), + attached_server: None, + no_server: true, is_listening: false, client_ids: Vec::new(), clients_bits: clients_before, @@ -1596,12 +1355,18 @@ mod tests { assert!(!clients.is_null()); assert_eq!(perry_runtime::set::js_set_size(clients), 0); - let client_id = 9_325_001; + let client_id = register_handle(WsClientHandle) as usize; track_server_client(server_handle, client_id); let clients = JsValue::from_bits(js_ws_server_clients(server_handle).to_bits()) .as_pointer::(); assert_eq!(perry_runtime::set::js_set_size(clients), 1); - assert_eq!(perry_runtime::set::js_set_has(clients, client_id as f64), 1); + assert_eq!( + perry_runtime::set::js_set_has( + clients, + f64::from_bits(client_js_value(client_id).bits()) + ), + 1 + ); WS_CLIENT_PARENT_SERVER .lock() @@ -1612,6 +1377,7 @@ mod tests { .as_pointer::(); assert_eq!(perry_runtime::set::js_set_size(clients), 0); + drop_handle(client_id as i64); drop_handle(server_handle); } @@ -1639,10 +1405,19 @@ mod tests { } #[test] - fn extract_port_from_number_arg() { - assert_eq!(extract_port(8080.0), 8080); - assert_eq!(extract_port(0.0), 0); - assert_eq!(extract_port(-5.0), 0); + fn client_handles_do_not_alias_registered_servers() { + let server = js_ws_server_new(f64::from_bits(JsValue::UNDEFINED.bits())); + let client = register_handle(WsClientHandle); + assert_ne!(server, client); + assert!(get_handle_mut::(client).is_none()); + assert!(get_handle_mut::(server).is_none()); + assert_eq!( + decode_client_id(f64::from_bits(client_js_value(client as usize).bits())), + client as usize + ); + assert_eq!(decode_client_id(client as f64), client as usize); + perry_ffi::drop_handle(client); + perry_ffi::drop_handle(server); } /// #6117 — `readyState` walks the npm-ws lifecycle: CONNECTING (0) diff --git a/crates/perry-ext-ws/src/server.rs b/crates/perry-ext-ws/src/server.rs new file mode 100644 index 0000000000..1aeafbfff8 --- /dev/null +++ b/crates/perry-ext-ws/src/server.rs @@ -0,0 +1,338 @@ +//! WebSocket server construction and HTTP-server attachment. +use super::*; + +extern "C" { + fn js_object_get_field_by_name( + object: *const perry_ffi::ObjectHeader, + key: *const StringHeader, + ) -> JsValue; +} + +pub(super) fn value_string(value: JsValue) -> Option { + if value.is_short_string() { + let mut bytes = [0; 5]; + let len = value.short_string_to_buf(&mut bytes)?; + Some(String::from_utf8_lossy(&bytes[..len]).into_owned()) + } else { + unsafe { read_str(value.as_string_ptr()) } + } +} + +/// `new WebSocketServer({ port })` — sync ctor; spawns the accept loop. +/// +/// #1113: `new WebSocketServer({ noServer: true })` must NOT bind a +/// TCP port or spawn the accept loop — it's a passive registry whose +/// connections arrive exclusively via `wss.handleUpgrade(...)` driven +/// by a host server's `'upgrade'` event (fastify's `app.server` or +/// `node:http`). For that shape we register a listener-only handle and +/// return early; `WS_ACTIVE_SERVERS` is left untouched so a noServer +/// wss doesn't keep the event loop alive on its own (the host server's +/// has-active gate — `js_fastify_has_active` — does that). +#[no_mangle] +pub extern "C" fn js_ws_server_new(opts_f64: f64) -> Handle { + ensure_runtime_hooks_registered(); + let scope = perry_ffi::TransientRootScope::enter(); + let opts = scope.root_nanbox(opts_f64); + // Allocate each property key before reloading the rooted options receiver. + let field = |key| { + let key = alloc_string(key); + let value = JsValue::from_bits(opts.get().to_bits()); + if !value.is_pointer() { + return JsValue::UNDEFINED; + } + unsafe { js_object_get_field_by_name(value.as_pointer(), key.as_raw()) } + }; + let port_value = field("port"); + let port = if port_value.is_number() { + Some(port_value.to_number() as u16) + } else { + None + }; + let no_server = field("noServer").to_bool(); + let attached = field("server"); + let attached_server = if attached.is_pointer() { + Some((attached.bits() & POINTER_MASK) as i64) + } else { + None + }; + let host_value = field("host"); + let host = value_string(host_value).unwrap_or_else(|| "0.0.0.0".into()); + let clients_bits = alloc_set(4).bits(); + + if no_server || attached_server.is_some() || port.is_none() { + return register_handle(WsServerHandle { + listeners: HashMap::new(), + port: 0, + host, + attached_server, + no_server, + is_listening: false, + client_ids: Vec::new(), + clients_bits, + shutdown_tx: None, + }); + } + let port = port.unwrap(); + + let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); + let server_handle = register_handle(WsServerHandle { + listeners: HashMap::new(), + port, + host: host.clone(), + attached_server: None, + no_server: false, + is_listening: false, + client_ids: Vec::new(), + clients_bits, + shutdown_tx: Some(shutdown_tx), + }); + WS_ACTIVE_SERVERS.fetch_add(1, Ordering::Relaxed); + let handle_id = server_handle; + // Issue #606 — `spawn_blocking_with_reactor` already runs the closure + // inside a tokio worker task, so `Handle::current().block_on(fut)` panics + // with "Cannot start a runtime from within a runtime". Schedule the + // accept loop as a sibling task on the existing runtime instead. + // (Same root cause as the v0.5.691 sweep that fixed perry-ext-http's + // server.rs / https_server.rs / http2_server.rs and perry-ext-ws's + // `drive_server_client_io` — this site was missed in that sweep.) + spawn_blocking(move || { + tokio::spawn(async move { + let addr = (host.as_str(), port); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("WebSocketServer bind error: {}", e), + )); + WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); + return; + } + }; + if let Some(s) = get_handle_mut::(handle_id) { + s.is_listening = true; + if let Ok(address) = listener.local_addr() { + s.port = address.port(); + s.host = address.ip().to_string(); + } + } + push_ws_event(PendingWsEvent::Listening(handle_id)); + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((tcp_stream, _addr)) => { + match tokio_tungstenite::accept_async(tcp_stream).await { + Ok(ws_stream) => { + let ws_id = register_handle(WsClientHandle) as usize; + let (tx, rx) = mpsc::unbounded_channel::(); + WS_CONNECTIONS.lock().unwrap().insert(ws_id, WsConnection { + sender: tx, + messages: Vec::new(), + is_open: true, + is_closing: false, + is_closed: false, + }); + WS_CLIENT_LISTENERS.lock().unwrap().insert(ws_id, WsClientListeners { + listeners: HashMap::new(), + }); + if let Some(s) = get_handle_mut::(handle_id) { + s.client_ids.push(ws_id); + } + WS_CLIENT_PARENT_SERVER.lock().unwrap().insert(ws_id, handle_id); + push_ws_event(PendingWsEvent::Connection(handle_id, ws_id)); + drive_server_client_io(ws_id, ws_stream, rx); + } + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("WebSocket handshake error: {}", e), + )); + } + } + } + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("accept error: {}", e), + )); + } + } + } + _ = shutdown_rx.recv() => { + break; + } + } + } + if let Some(s) = get_handle_mut::(handle_id) { + s.is_listening = false; + } + WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); + }); + }); + server_handle +} + +/// Return the persistent `Set` exposed as `WebSocketServer.clients`. +/// +/// The Set is allocated with the server, updated before connection/close +/// callbacks run, and rooted through the server handle for its full lifetime. +#[no_mangle] +pub extern "C" fn js_ws_server_clients(handle: i64) -> f64 { + get_handle_mut::(handle) + .map(|server| f64::from_bits(server.clients_bits)) + .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())) +} + +// The HTTP wrapper depends on ws for stream handoff. A host-supplied address +// reader keeps that dependency one-way and stores only a code pointer. +type HostAddress = fn(Handle) -> Option<(String, u16)>; +static HOST_ADDRESS: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn register_http_address_reader(reader: HostAddress) { + let _ = HOST_ADDRESS.set(reader); +} + +fn attached_servers(host: Handle) -> Vec { + let mut result = Vec::new(); + perry_ffi::iter_handle_ids_of::(|id| result.push(id)); + result.retain(|id| { + get_handle_mut::(*id).is_some_and(|s| s.attached_server == Some(host)) + }); + result +} + +pub fn has_attached_server(host: Handle) -> bool { + !attached_servers(host).is_empty() +} + +/// Called on the JS thread after HTTP has adopted the upgraded stream. +pub fn accept_attached_connection(host: Handle, request: f64, client: i64) { + for server in attached_servers(host) { + WS_CLIENT_PARENT_SERVER + .lock() + .unwrap() + .insert(client as usize, server); + track_server_client(server, client as usize); + emit_server_event( + server, + "connection", + f64::from_bits(client_js_value(client as usize).bits()), + request, + 2, + ); + } +} + +pub fn attached_server_listening(host: Handle) { + for server in attached_servers(host) { + emit_server_event(server, "listening", undefined(), undefined(), 0); + } +} + +pub(super) fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +pub(super) fn decode_client_id(value: f64) -> usize { + if value.to_bits() & TAG_MASK == POINTER_TAG { + (value.to_bits() & POINTER_MASK) as usize + } else { + value as usize + } +} + +/// Snapshot and root listeners and arguments before invoking user code. +fn emit_server_event(handle: Handle, event: &str, first: f64, second: f64, argc: usize) -> i32 { + let scope = perry_ffi::TransientRootScope::enter(); + let listeners = scope.root_addrs(&listeners_on_server(handle, event)); + let first = scope.root_nanbox(first); + let second = scope.root_nanbox(second); + let had_listeners = !listeners.is_empty(); + for cb in listeners { + if cb.get() == 0 { + continue; + } + unsafe { + let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); + match argc { + 0 => { + closure.call0(); + } + 1 => { + closure.call1(first.get()); + } + _ => { + closure.call2(first.get(), second.get()); + } + } + } + } + i32::from(had_listeners) +} + +/// # Safety +/// `event` must point to a live runtime string. +#[no_mangle] +pub unsafe extern "C" fn js_ws_server_emit( + handle: i64, + event: *const StringHeader, + first: f64, + second: f64, +) -> i32 { + let Some(event) = read_str(event) else { + return 0; + }; + emit_server_event(handle, &event, first, second, 2) +} + +#[no_mangle] +pub extern "C" fn js_ws_server_address(handle: i64) -> f64 { + let Some((attached, no_server, listening, host, port)) = + get_handle_mut::(handle).map(|s| { + ( + s.attached_server, + s.no_server, + s.is_listening, + s.host.clone(), + s.port, + ) + }) + else { + return f64::from_bits(JsValue::NULL.bits()); + }; + if no_server { + perry_ffi::throw_with_code( + "The server is operating in \"noServer\" mode", + "ERR_WEBSOCKET_NO_SERVER", + perry_ffi::ErrorKind::Error, + ); + } + let address = if let Some(host) = attached { + HOST_ADDRESS.get().and_then(|reader| reader(host)) + } else if listening { + Some((host, port)) + } else { + None + }; + let Some((host, port)) = address else { + return f64::from_bits(JsValue::NULL.bits()); + }; + let scope = perry_ffi::TransientRootScope::enter(); + let family = if host.contains(':') { "IPv6" } else { "IPv4" }; + let address = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(&host).as_raw()).bits(), + )); + let family = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(family).as_raw()).bits(), + )); + let (keys, shape) = perry_ffi::build_object_shape(&["address", "family", "port"]); + unsafe { + let object = + perry_ffi::js_object_alloc_with_shape(shape, 3, keys.as_ptr(), keys.len() as u32); + perry_ffi::js_object_set_field(object, 0, JsValue::from_bits(address.get().to_bits())); + perry_ffi::js_object_set_field(object, 1, JsValue::from_bits(family.get().to_bits())); + perry_ffi::js_object_set_field(object, 2, JsValue::from_number(port as f64)); + f64::from_bits(JsValue::from_object_ptr(object).bits()) + } +} diff --git a/crates/perry-hir/src/analysis/value_types_tests.rs b/crates/perry-hir/src/analysis/value_types_tests.rs index 3380cc7448..215b8b5a07 100644 --- a/crates/perry-hir/src/analysis/value_types_tests.rs +++ b/crates/perry-hir/src/analysis/value_types_tests.rs @@ -1276,6 +1276,7 @@ fn infers_class_prototype_and_super_meta_value_shapes() { &Expr::SetFunctionPrototype { func: Box::new(Expr::FuncRef(1)), proto: Box::new(Expr::String("proto".to_string())), + strict: false, }, &env, ), diff --git a/crates/perry-hir/src/dynamic_import.rs b/crates/perry-hir/src/dynamic_import.rs index b40db35a31..a025778afd 100644 --- a/crates/perry-hir/src/dynamic_import.rs +++ b/crates/perry-hir/src/dynamic_import.rs @@ -30,12 +30,14 @@ pub const DYNAMIC_IMPORT_PATH_CAP: usize = 64; mod binding_origin; mod top_level_await; mod visitors; +mod worker_paths; use binding_origin::{resolve_binding_origin, BindingOrigin}; pub use top_level_await::detect_top_level_await; pub use visitors::{ for_each_dynamic_import, for_each_dynamic_import_mut, for_each_worker_new, for_each_worker_new_mut, }; +pub use worker_paths::resolve_worker_path; /// The result of const-folding a dynamic `import()` path argument. #[derive(Debug, Clone)] diff --git a/crates/perry-hir/src/dynamic_import/worker_paths.rs b/crates/perry-hir/src/dynamic_import/worker_paths.rs new file mode 100644 index 0000000000..d6404ff84c --- /dev/null +++ b/crates/perry-hir/src/dynamic_import/worker_paths.rs @@ -0,0 +1,416 @@ +//! Bounded interpretation of the small, pure helpers used by bundled Workers. +//! +//! This only discovers an import edge. Codegen still evaluates the original +//! filename expression, including its helper calls, when constructing a Worker. +use super::*; + +const DEPTH_LIMIT: usize = 64; +const WORK_LIMIT: usize = 4096; +const STRING_LIMIT: usize = 65_536; +type Paths = Result; + +// A URL is a carrier for a module edge, not its relative input string. Keep +// that distinction when substituting arguments: stringifying a URL would use +// its absolute href and must not silently concatenate the lexical input. +#[derive(Clone)] +struct PathValues { + paths: Vec, + is_url: bool, +} + +impl PathValues { + fn strings(paths: Vec) -> Self { + Self { + paths, + is_url: false, + } + } +} + +/// Extend the existing path grammar for Workers with local helper calls. Keep +/// dynamic import and eval-source resolution on their existing code paths. +pub fn resolve_worker_path>( + arg: &Expr, + module: &Module, + consts: &HashMap, + param_literals: &HashMap>, + local_literals: &HashMap>, +) -> Resolution { + let original = resolve_import_path_with_context( + arg, + consts, + param_literals, + local_literals, + &mut HashSet::new(), + ); + if matches!(original, Resolution::Set(_)) { + return original; + } + let mut resolver = WorkerPaths { + module, + consts, + param_literals, + local_literals, + arguments: HashMap::new(), + locals: HashSet::new(), + calls: HashSet::new(), + work: WORK_LIMIT, + }; + match resolver.resolve(arg, 0) { + Ok(values) => Resolution::Set(values.paths), + Err(reason) => Resolution::Unresolved(format!("Worker path helper: {reason}")), + } +} + +struct WorkerPaths<'a, V> { + module: &'a Module, + consts: &'a HashMap, + param_literals: &'a HashMap>, + local_literals: &'a HashMap>, + arguments: HashMap, + locals: HashSet, + calls: HashSet, + work: usize, +} + +impl> WorkerPaths<'_, V> { + fn tick(&mut self, depth: usize) -> Result<(), String> { + if depth >= DEPTH_LIMIT { + return Err(format!("resolution exceeds depth limit {DEPTH_LIMIT}")); + } + spend(&mut self.work) + } + + fn strings(&mut self, expr: &Expr, depth: usize) -> Result, String> { + let values = self.resolve(expr, depth)?; + if values.is_url { + return Err("URL string coercion is not a static path operation".into()); + } + Ok(values.paths) + } + + fn resolve(&mut self, expr: &Expr, depth: usize) -> Paths { + self.tick(depth)?; + match expr { + Expr::String(value) => bounded(vec![value.clone()], &mut self.work), + Expr::StringCoerce(value) => self.strings(value, depth + 1).map(PathValues::strings), + Expr::LocalGet(id) => { + if let Some(values) = self.arguments.get(id) { + return Ok(values.clone()); + } + if !self.locals.insert(*id) { + return Err("circular binding reference".into()); + } + let result = if let Some(init) = self.consts.get(id) { + self.resolve(init.borrow(), depth + 1) + } else if self.calls.is_empty() { + self.param_literals + .get(id) + .or_else(|| self.local_literals.get(id)) + .cloned() + .ok_or_else(|| "binding is mutable or has no static string value".into()) + .and_then(|paths| bounded(paths, &mut self.work)) + } else { + Err("helper reads a mutable or non-static binding".into()) + }; + self.locals.remove(id); + result + } + Expr::UrlNew { url, base } => { + let paths = self.strings(url, depth + 1)?; + match base { + Some(base) if matches!(base.as_ref(), Expr::ImportMetaUrl(_)) => { + Ok(PathValues { + paths, + is_url: true, + }) + } + Some(base) => { + let bases = self.strings(base, depth + 1)?; + if bases.iter().all(|base| base.starts_with("file:")) { + Ok(PathValues { + paths, + is_url: true, + }) + } else { + Err("URL base must be import.meta.url or a static file URL".into()) + } + } + None if paths.iter().all(|path| path.starts_with("file:")) => Ok(PathValues { + paths, + is_url: true, + }), + None => Err("one-argument URL must resolve to a static file URL".into()), + } + } + Expr::Binary { + op: BinaryOp::Add, + left, + right, + } => { + let left = self.strings(left, depth + 1)?; + let right = self.strings(right, depth + 1)?; + product(&left, &right, |a, b| format!("{a}{b}"), &mut self.work) + } + Expr::PathJoin(left, right) | Expr::PathResolveJoin(left, right) => { + let left = self.strings(left, depth + 1)?; + let right = self.strings(right, depth + 1)?; + product(&left, &right, static_path_join, &mut self.work) + } + Expr::StringReplace { + string, + pattern, + replacement, + } => self.replace(string, pattern, replacement, depth + 1), + Expr::Conditional { + condition, + then_expr, + else_expr, + } => { + self.pure_selector(condition, depth + 1)?; + let mut then_values = self.resolve(then_expr, depth + 1)?; + let else_values = self.resolve(else_expr, depth + 1)?; + if then_values.is_url != else_values.is_url { + return Err("conditional mixes URL and string values".into()); + } + then_values.paths.extend(else_values.paths); + let mut values = bounded(then_values.paths, &mut self.work)?; + values.is_url = then_values.is_url; + Ok(values) + } + Expr::Call { callee, args, .. } => { + if let Some(string) = static_string_replace_target(callee, args) { + return self.replace(string, &args[0], &args[1], depth + 1); + } + if is_static_path_join_call(callee) { + let mut paths = vec![String::new()]; + for arg in args { + let next = self.strings(arg, depth + 1)?; + paths = product(&paths, &next, static_path_join, &mut self.work)?.paths; + } + return Ok(PathValues::strings(if args.is_empty() { + vec![".".into()] + } else { + paths + })); + } + self.call(callee, args, depth + 1) + } + Expr::PropertyGet { object, .. } | Expr::IndexGet { object, .. } => { + if let Expr::IndexGet { index, .. } = expr { + self.pure_selector(index, depth + 1)?; + } + self.registry(object, depth + 1) + } + _ => Err( + "unsupported expression (effects, mutation and opaque calls are not evaluated)" + .into(), + ), + } + } + + fn replace( + &mut self, + string: &Expr, + pattern: &Expr, + replacement: &Expr, + depth: usize, + ) -> Paths { + let strings = self.strings(string, depth)?; + let patterns = self.strings(pattern, depth)?; + let replacements = self.strings(replacement, depth)?; + let mut paths = Vec::new(); + for string in &strings { + for pattern in &patterns { + for replacement in &replacements { + push_path( + &mut paths, + string.replacen(pattern, replacement, 1), + &mut self.work, + )?; + } + } + } + Ok(PathValues::strings(paths)) + } + + fn registry(&mut self, object: &Expr, depth: usize) -> Paths { + self.tick(depth)?; + let values: Vec<&Expr> = match object { + Expr::LocalGet(id) => { + if !self.locals.insert(*id) { + return Err("circular registry reference".into()); + } + let result = match self.consts.get(id) { + Some(init) => self.registry(init.borrow(), depth + 1), + None => Err("registry binding is mutable or opaque".into()), + }; + self.locals.remove(id); + return result; + } + Expr::Object(entries) => entries.iter().map(|(_, value)| value).collect(), + Expr::New { + class_name, args, .. + } if class_name.starts_with("__AnonShape_") => args.iter().collect(), + _ => return Err("member access is not a static path registry".into()), + }; + let mut paths = Vec::new(); + for value in values { + for path in self.strings(value, depth + 1)? { + if !is_relative_specifier(&path) { + return Err("registry values must be relative module paths".into()); + } + push_path(&mut paths, path, &mut self.work)?; + } + } + Ok(PathValues::strings(paths)) + } + + // Do not discard effects hidden in ternary conditions or registry indices. + fn pure_selector(&mut self, expr: &Expr, depth: usize) -> Result<(), String> { + self.tick(depth)?; + match expr { + Expr::Bool(_) | Expr::String(_) | Expr::Integer(_) | Expr::Number(_) => Ok(()), + Expr::LocalGet(id) if self.arguments.contains_key(id) => Ok(()), + Expr::LocalGet(id) => match self.consts.get(id) { + Some(init) => self.pure_selector(init.borrow(), depth + 1), + None => Err("selector reads a mutable or non-static binding".into()), + }, + _ => Err("selector may have side effects or is not static".into()), + } + } + + fn call(&mut self, callee: &Expr, args: &[Expr], depth: usize) -> Paths { + self.tick(depth)?; + let mut target = callee; + let mut aliases = HashSet::new(); + while let Expr::LocalGet(id) = target { + self.tick(depth + aliases.len())?; + if !aliases.insert(*id) { + return Err("circular callable binding".into()); + } + target = self + .consts + .get(id) + .ok_or("call target is mutable or is not a module-local helper")? + .borrow(); + } + let (id, params, body, asynchronous) = match target { + Expr::FuncRef(id) => { + let function = self + .module + .functions + .iter() + .find(|function| function.id == *id) + .ok_or("call target is not a module-local helper")?; + ( + *id, + &function.params, + &function.body, + function.is_async || function.is_generator || function.was_plain_async, + ) + } + Expr::Closure { + func_id, + params, + body, + is_async, + is_generator, + .. + } => (*func_id, params, body, *is_async || *is_generator), + _ => return Err("opaque call target is not a module-local helper".into()), + }; + if asynchronous { + return Err("async and generator helpers are not static path helpers".into()); + } + if params.len() != args.len() + || params.iter().any(|p| { + p.default.is_some() + || p.is_rest + || p.arguments_object.is_some() + || !p.decorators.is_empty() + }) + { + return Err( + "helper requires an exact list of simple static string/URL arguments".into(), + ); + } + let [Stmt::Return(Some(value))] = body.as_slice() else { + return Err("helper body must contain only a single return (no effects, mutation or multiple returns)".into()); + }; + // Resolve arguments before entering the callee so sibling/nested calls + // such as identity(identity(path)) are not mistaken for recursion. + let mut bindings = Vec::new(); + for (param, arg) in params.iter().zip(args) { + bindings.push((param.id, self.resolve(arg, depth + 1)?)); + } + if !self.calls.insert(id) { + return Err("recursive helper call".into()); + } + let saved: Vec<_> = bindings + .into_iter() + .map(|(id, paths)| (id, self.arguments.insert(id, paths))) + .collect(); + let result = self.resolve(value, depth + 1); + for (id, previous) in saved { + if let Some(previous) = previous { + self.arguments.insert(id, previous); + } else { + self.arguments.remove(&id); + } + } + self.calls.remove(&id); + result + } +} + +fn spend(work: &mut usize) -> Result<(), String> { + *work = work + .checked_sub(1) + .ok_or_else(|| format!("resolution exceeds work limit {WORK_LIMIT}"))?; + Ok(()) +} + +fn push_path(paths: &mut Vec, path: String, work: &mut usize) -> Result<(), String> { + spend(work)?; + if path.len() > STRING_LIMIT { + return Err(format!( + "resolved path exceeds string length limit {STRING_LIMIT}" + )); + } + if !paths.contains(&path) { + if paths.len() == DYNAMIC_IMPORT_PATH_CAP { + return Err(format!( + "candidate count exceeds limit {DYNAMIC_IMPORT_PATH_CAP}" + )); + } + paths.push(path); + } + Ok(()) +} + +fn bounded(paths: Vec, work: &mut usize) -> Paths { + let mut out = Vec::new(); + for path in paths { + push_path(&mut out, path, work)?; + } + Ok(PathValues::strings(out)) +} + +fn product( + left: &[String], + right: &[String], + combine: impl Fn(&str, &str) -> String, + work: &mut usize, +) -> Paths { + let mut out = Vec::new(); + for a in left { + for b in right { + push_path(&mut out, combine(a, b), work)?; + } + } + Ok(PathValues::strings(out)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs new file mode 100644 index 0000000000..972831e4d2 --- /dev/null +++ b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs @@ -0,0 +1,216 @@ +use super::*; + +fn resolve(source: &str) -> Resolution { + let ast = perry_parser::parse_typescript(source, "worker-helpers.ts").unwrap(); + let module = crate::lower_module(&ast, "worker-helpers", "worker-helpers.ts").unwrap(); + let consts = collect_module_const_locals(&module); + let params = collect_dynamic_import_param_literals(&module); + let locals = collect_dynamic_import_local_candidate_literals(&module, &consts, ¶ms); + let mut results = Vec::new(); + for_each_worker_new(&module, &mut |expr| { + if let Expr::WorkerNew { filename, .. } = expr { + results.push(resolve_worker_path( + filename, &module, &consts, ¶ms, &locals, + )); + } + }); + assert_eq!(results.len(), 1, "fixture must contain one Worker"); + results.remove(0) +} + +fn paths(source: &str, expected: &[&str]) { + match resolve(source) { + Resolution::Set(paths) => assert_eq!(paths, expected), + other => panic!("{other:?}\n{source}"), + } +} + +fn rejected(source: &str, diagnostic: &str) { + match resolve(source) { + Resolution::Unresolved(reason) => { + assert!(reason.contains(diagnostic), "{reason}\n{source}") + } + other => panic!("expected {diagnostic}, got {other:?}\n{source}"), + } +} + +#[test] +fn bun_file_url_helper_chain() { + paths( + r#" + const embeddedWorkerUrl = (path) => new URL(`file://${path}`); + const hooksWorkerUrl = () => embeddedWorkerUrl("/$bunfs/root/worker.js"); + new Worker(hooksWorkerUrl()); + "#, + &["file:///$bunfs/root/worker.js"], + ); +} + +#[test] +fn declarations_aliases_and_nested_argument_calls() { + paths( + r#" + import { Worker } from 'node:worker_threads'; + function identity(path: string) { return path; } + const same = identity; + const url = (path) => new URL(path, import.meta.url); + new Worker(url(same(identity('./worker.js')))); + "#, + &["./worker.js"], + ); + paths( + r#" + const identity = (path) => path; + new Worker(identity(identity(new URL('./worker.js', import.meta.url)))); + "#, + &["./worker.js"], + ); +} + +#[test] +fn return_expressions_reuse_static_path_operations_and_registries() { + paths( + r#" + import path from 'node:path'; + const registry = { worker: './worker.js' }; + const entry = (prefix) => path.join(prefix, registry.worker.replace('.js', '.ts')); + new Worker(entry('./sub')); + "#, + &["sub/worker.ts"], + ); + paths( + r#" + const choose = (key) => ({ a: './worker.js', b: './worker.js' })[key]; + const entry = () => true ? choose('a') : choose('b'); + new Worker(entry()); + "#, + &["./worker.js"], + ); +} + +#[test] +fn unsafe_helpers_stay_unresolved_with_reasons() { + for body in [ + "console.log('effect'); return './worker.js';", + "let x = './worker.js'; x = './other.js'; return x;", + "if (true) return './worker.js'; return './other.js';", + ] { + rejected( + &format!("function entry() {{ {body} }} new Worker(entry());"), + "single return", + ); + } + rejected( + "const entry = () => process.env.WORKER; new Worker(entry());", + "unsupported expression", + ); + rejected( + "const entry = () => opaque(); new Worker(entry());", + "opaque call", + ); + rejected("const entry = () => console.log('x') ? './worker.js' : './worker.js'; new Worker(entry());", "selector"); + rejected( + "const entry = () => './worker.js'; new Worker(entry(console.log('x')));", + "exact list", + ); + rejected( + "const entry = async () => './worker.js'; new Worker(entry());", + "async", + ); + rejected( + "const entry = (x = './worker.js') => x; new Worker(entry());", + "simple static", + ); + rejected( + "const entry = () => new URL('https://example.com/worker.js'); new Worker(entry());", + "static file URL", + ); +} + +#[test] +fn mutation_and_recursion_are_rejected() { + rejected( + "let entry = () => './worker.js'; entry = () => './other.js'; new Worker(entry());", + "mutable", + ); + rejected("function entry() { return './worker.js'; } entry = () => './other.js'; new Worker(entry());", "mutable"); + rejected("let path = './worker.js'; path = './other.js'; const entry = () => path; new Worker(entry());", "mutable"); + rejected( + "function entry() { return entry(); } new Worker(entry());", + "recursive", + ); + rejected( + "const a = () => b(); const b = () => a(); new Worker(a());", + "recursive", + ); +} + +#[test] +fn candidate_depth_and_expansion_limits_are_enforced() { + let candidates = (0..=DYNAMIC_IMPORT_PATH_CAP) + .map(|n| format!("true ? './worker{n}.js' : ")) + .collect::(); + // Balanced registry values reach the candidate cap without first hitting + // the depth cap of a long ternary expression. + let registry = (0..=DYNAMIC_IMPORT_PATH_CAP) + .map(|n| format!("p{n}: './worker{n}.js'")) + .collect::>() + .join(","); + rejected(&format!("const registry = {{{registry}}}; const entry = () => registry.p0; new Worker(entry());"), "candidate count"); + rejected( + &format!("const entry = () => {candidates}'./last.js'; new Worker(entry());"), + "depth limit", + ); + let helpers = (0..80) + .map(|n| format!("const h{n} = () => h{}();", n + 1)) + .collect::(); + rejected( + &format!("{helpers} const h80 = () => './worker.js'; new Worker(h0());"), + "depth limit", + ); + let nested = (0..20).fold("'./worker.js'".to_string(), |arg, _| { + format!("double({arg})") + }); + rejected( + &format!("const double = (path) => path + path; new Worker({nested});"), + "string length limit", + ); +} + +#[test] +fn dynamic_import_resolution_does_not_follow_helpers() { + let ast = perry_parser::parse_typescript( + "const entry = () => './worker.js'; import(entry());", + "test.ts", + ) + .unwrap(); + let module = crate::lower_module(&ast, "test", "test.ts").unwrap(); + let consts = collect_module_const_locals(&module); + let mut count = 0; + for_each_dynamic_import(&module, &mut |expr| { + if let Expr::DynamicImport { arg, .. } = expr { + count += 1; + assert!(matches!( + resolve_import_path_with_consts(arg, &consts, &mut HashSet::new()), + Resolution::Unresolved(_) + )); + } + }); + assert_eq!(count, 1); +} + +#[test] +fn urls_remain_carriers_when_substituted_and_are_not_coerced_to_relative_strings() { + rejected("const stringify = (url) => `${url}`; new Worker(stringify(new URL('./worker.js', import.meta.url)));", "URL string coercion"); + rejected("const prefix = (url) => './prefix' + url; new Worker(prefix(new URL('./worker.js', import.meta.url)));", "URL string coercion"); +} + +#[test] +fn branching_helper_expansion_has_a_shared_work_budget() { + let mut source = "const h0 = () => 'x';".to_string(); + for n in 1..=12 { + source.push_str(&format!("const h{n} = () => h{}() + h{}();", n - 1, n - 1)); + } + source.push_str("new Worker(h12());"); + rejected(&source, "work limit"); +} diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 831ead8020..ed4a85ecc4 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -596,18 +596,14 @@ pub enum Expr { captured_args: Vec, }, - // Issue #711 part 2: `.prototype = ` pattern, - // used by Effect's effectable.ts to declare prototype-based - // classes. Codegen emits a call to `js_set_function_prototype` - // which stores `func_value → synthetic_class_id` in a side-table - // and binds the object as the synthetic class's prototype source. - // When `class Derived extends ` evaluates later, the dynamic - // parent registration looks up that synthetic class_id and wires - // it into CLASS_REGISTRY so method dispatch on Derived instances - // walks through to the prototype object's methods. + // Static `.prototype` assignment. Evaluates the receiver once and performs + // ordinary PutValue, including strict-mode rejection. For function receivers + // the runtime also synchronizes the synthetic class prototype used by + // dynamic `class Derived extends Base` dispatch (#711). SetFunctionPrototype { func: Box, proto: Box, + strict: bool, }, // Issue #838: `.prototype. = ` and the diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 4473b46668..5e416db78c 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -43,8 +43,8 @@ pub use dynamic_import::{ collect_module_const_locals, detect_top_level_await, dynamic_import_glob_pattern, flatten_exports, for_each_dynamic_import, for_each_dynamic_import_mut, for_each_worker_new, for_each_worker_new_mut, resolve_import_path, resolve_import_path_with_consts, - resolve_import_path_with_consts_and_params, resolve_import_path_with_context, FlatExport, - Resolution, DYNAMIC_IMPORT_PATH_CAP, + resolve_import_path_with_consts_and_params, resolve_import_path_with_context, + resolve_worker_path, FlatExport, Resolution, DYNAMIC_IMPORT_PATH_CAP, }; pub use egress::{audit_module_egress, EgressRefusalReason, EgressViolation}; pub use enums::fix_imported_enums; @@ -61,9 +61,9 @@ pub use js_transform::{ }; pub use lockdown::{audit_module_lockdown, LockdownViolation}; pub use lower::{ - lower_module, lower_module_full, lower_module_with_class_id, - lower_module_with_class_id_and_types, lower_module_with_class_id_types_and_seed, - lower_module_with_class_id_types_seed_and_entry, + lower_module, lower_module_full, lower_module_full_with_platform_globals, + lower_module_with_class_id, lower_module_with_class_id_and_types, + lower_module_with_class_id_types_and_seed, lower_module_with_class_id_types_seed_and_entry, }; pub use monomorph::monomorphize_module; pub use native_profile::exported_native_pod_abi; diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 4c045db809..338b27193f 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -223,6 +223,7 @@ impl LoweringContext { object_static_method_aliases: HashMap::new(), array_static_method_aliases: HashMap::new(), is_entry_module: false, + platform_globals: HashSet::new(), saw_global_this_expr: false, reassigned_top_level_identifiers: HashSet::new(), module_strict: false, diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 869139fb51..c07f56e2bb 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -1108,23 +1108,15 @@ fn lower_assignment_target( match &member.prop { ast::MemberProp::Ident(ident) => { let property = ident.sym.to_string(); - // Issue #711 part 2: route `.prototype = - // ` through SetFunctionPrototype so the - // runtime binds the proto object as the function - // value's class-prototype source. Effect's - // effectable.ts uses this to declare classes via - // prototype assignment on a plain function. The - // runtime helper is a no-op when `object` doesn't - // resolve to a function at runtime (preserves the - // baseline for arbitrary `obj.prototype = X` - // writes — those are rare and meaningless on - // non-functions in practice). + // Ordinary property assignment, with function prototype + // metadata synchronized for dynamic class parents (#711). if property == "prototype" { return Ok(wrap_assign_object_prelude( prelude.take(), Expr::SetFunctionPrototype { func: object, proto: value, + strict: ctx.current_strict, }, )); } diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics.rs b/crates/perry-hir/src/lower/expr_call/intrinsics.rs index c3d84758c6..617833c7ac 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics.rs @@ -41,4 +41,4 @@ pub(super) use native_arena::{ }; pub(super) use native_scalars::validate_native_scalar_conversion_call; pub(super) use precompile_wasm::{try_embed_wasm, try_precompile}; -pub(super) use require::{try_dynamic_require, try_require_literal}; +pub(super) use require::{try_dynamic_require, try_import_meta_require, try_require_literal}; diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs index 2e17563f2e..63244f1c91 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs @@ -5,6 +5,9 @@ use swc_ecma_ast as ast; use super::super::super::{lower_expr, LoweringContext}; +#[cfg(test)] +mod tests; + /// Issue #668 / #5216: a string-literal `require("")` from user source. /// /// When `` statically resolves to a Perry-supported native/Node-builtin @@ -152,3 +155,67 @@ pub(crate) fn try_dynamic_require( synchronous: true, })) } + +fn strip_require_wrappers(mut expr: &ast::Expr) -> &ast::Expr { + loop { + expr = match expr { + ast::Expr::Paren(paren) => &paren.expr, + ast::Expr::TsAs(value) => &value.expr, + ast::Expr::TsNonNull(value) => &value.expr, + ast::Expr::TsTypeAssertion(value) => &value.expr, + _ => return expr, + }; + } +} + +/// Bun's direct `import.meta.require` calls use the same bounded synchronous +/// module dispatcher as computed CommonJS requires. Match the actual meta +/// property, so a local `require` binding or an ordinary object's method does +/// not change which function this syntax denotes. +pub(crate) fn try_import_meta_require( + ctx: &mut LoweringContext, + call: &ast::CallExpr, +) -> Result> { + let ast::Callee::Expr(callee) = &call.callee else { + return Ok(None); + }; + let ast::Expr::Member(member) = strip_require_wrappers(callee) else { + return Ok(None); + }; + if !matches!(strip_require_wrappers(&member.obj), ast::Expr::MetaProp(meta) + if meta.kind == ast::MetaPropKind::ImportMeta) + { + return Ok(None); + } + let is_require = match &member.prop { + ast::MemberProp::Ident(name) => name.sym == "require", + ast::MemberProp::Computed(key) => matches!(strip_require_wrappers(&key.expr), + ast::Expr::Lit(ast::Lit::Str(name)) if name.value.as_str() == Some("require")), + _ => false, + }; + if !is_require { + return Ok(None); + } + if call.args.len() != 1 || call.args[0].spread.is_some() { + crate::lower_bail!(call.span, "import.meta.require requires one non-spread path argument for ahead-of-time module resolution"); + } + let arg = strip_require_wrappers(&call.args[0].expr); + if let ast::Expr::Lit(ast::Lit::Str(specifier)) = arg { + if let Some(module) = crate::destructuring::resolvable_native_module_for_spec( + specifier.value.as_str().unwrap_or(""), + ) { + return Ok(Some(Expr::NativeModuleRef(if module == "process" { + "process.namespace".into() + } else { + module + }))); + } + } + Ok(Some(Expr::DynamicImport { + paths: Vec::new(), + arg: Box::new(lower_expr(ctx, arg)?), + byte_offset: call.span.lo.0, + deferred_error: None, + synchronous: true, + })) +} diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require/tests.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require/tests.rs new file mode 100644 index 0000000000..1755a2f6ea --- /dev/null +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require/tests.rs @@ -0,0 +1,53 @@ +use crate::{dynamic_import::for_each_dynamic_import, Expr}; + +fn lower(source: &str) -> crate::Module { + let ast = perry_parser::parse_typescript(source, "main.ts").unwrap(); + let hir = crate::lower::lower_module(&ast, "main", "main.ts").unwrap(); + crate::ir::clear_current_module_source(); + hir +} + +#[test] +fn import_meta_require_spellings_use_synchronous_dispatch() { + let hir = lower( + r#" + const require = (value: string) => value; + import.meta.require("./first.js"); + import.meta["require"]("./second.js"); + ((import.meta as any)[("require")])("./third.js"); + import.meta.require(process.argv[2]); + require("ordinary"); + const object = { require(value: string) { return value; } }; + object.require("ordinary"); + console.log(import.meta.url, import.meta.main); + "#, + ); + let mut args = Vec::new(); + for_each_dynamic_import(&hir, &mut |expr| { + let Expr::DynamicImport { + arg, + synchronous, + deferred_error, + .. + } = expr + else { + unreachable!(); + }; + assert!(*synchronous); + assert!(deferred_error.is_none()); + args.push(arg.as_ref().clone()); + }); + assert_eq!(args.len(), 4); + for (arg, path) in args.iter().zip(["./first.js", "./second.js", "./third.js"]) { + assert!(matches!(arg, Expr::String(value) if value == path)); + } + assert!(!matches!(args[3], Expr::Undefined)); +} + +#[test] +fn import_meta_require_native_literal_uses_the_existing_namespace() { + let hir = lower("const os = import.meta.require('node:os');"); + assert!(hir.init.iter().any(|stmt| matches!(stmt, + crate::Stmt::Let { init: Some(Expr::NativeModuleRef(name)), .. } if name == "os" + ))); +} diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index d18f494dbf..752b4a4d98 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -87,10 +87,11 @@ use inline_array_methods::try_inline_array_methods; use intrinsics::{ check_eval_function_call, try_bare_regexp_call, try_builtin_prototype_method_apply_call, try_dynamic_require, try_embed_wasm, try_function_return_this, try_iife_call_rewrite, - try_iterator_from, try_namespace_static_method_apply_call_bind, try_native_arena_intrinsics, - try_native_arena_public_api, try_native_memory_public_api, try_native_module_method_apply_call, - try_pod_layout_constants, try_precompile, try_require_literal, - try_strict_eval_arguments_assignment, validate_native_scalar_conversion_call, + try_import_meta_require, try_iterator_from, try_namespace_static_method_apply_call_bind, + try_native_arena_intrinsics, try_native_arena_public_api, try_native_memory_public_api, + try_native_module_method_apply_call, try_pod_layout_constants, try_precompile, + try_require_literal, try_strict_eval_arguments_assignment, + validate_native_scalar_conversion_call, }; use local_array_methods::try_local_array_methods; use module_class_static::try_module_class_static; @@ -224,6 +225,9 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result R // compile log lists every name that will be resolved at // runtime — #8882 could not be attributed from the log because // the `new` path never said which identifier it gave up on. - eprintln!( - " Warning: unknown identifier '{source_class_name}' — assuming global; `new {source_class_name}()` resolves it by name on globalThis at runtime (ReferenceError on a miss)" - ); + if !ctx.platform_globals.contains(source_class_name) { + eprintln!( + " Warning: unknown identifier '{source_class_name}' — assuming global; `new {source_class_name}()` resolves it by name on globalThis at runtime (ReferenceError on a miss)" + ); + } return Ok(Expr::NewDynamic { callee: Box::new(super::unresolved_global_get_expr( source_class_name.to_string(), diff --git a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs index 1f1535a78f..381f668fa8 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_ident.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_ident.rs @@ -300,7 +300,10 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) -> // WithBaseObject is undefined; Node: `toString()` → // "[object Undefined]" even in sloppy CJS), which the generic // call path already provides. - if ctx.unresolved_ident_as_global { + // Platform globals (Bun in Bun mode) are supplied at module + // initialization. Keep the same lookup so replacement on + // globalThis remains observable; only the warning is suppressed. + if ctx.unresolved_ident_as_global && !ctx.platform_globals.contains(&name) { eprintln!( " Warning: unknown identifier '{}' in {} — assuming global; resolved by name on globalThis (incl. Object.prototype-inherited members) at runtime", name, diff --git a/crates/perry-hir/src/lower/lower_expr/assignment.rs b/crates/perry-hir/src/lower/lower_expr/assignment.rs index c4db5b3a68..be59969e7d 100644 --- a/crates/perry-hir/src/lower/lower_expr/assignment.rs +++ b/crates/perry-hir/src/lower/lower_expr/assignment.rs @@ -64,23 +64,13 @@ pub(crate) fn lower_expr_assignment( let result = match &member.prop { ast::MemberProp::Ident(ident) => { let property = ident.sym.to_string(); - // Issue #711 part 2: `.prototype = ` - // pattern (Effect's effectable.ts uses this to - // declare prototype-based classes — `function - // Base() {}; Base.prototype = CommitPrototype`). - // Route through the SetFunctionPrototype HIR node - // so codegen calls - // `js_set_function_prototype(func, proto)`, which - // allocates a synthetic class id keyed by the - // function value. The runtime helper is a no-op - // when `object` doesn't evaluate to a function - // (preserves baseline for legitimate - // `someClass.prototype = X` writes on non-function - // values). + // Ordinary property assignment, with function prototype + // metadata synchronized for dynamic class parents (#711). if property == "prototype" { Expr::SetFunctionPrototype { func: object, proto: value, + strict: ctx.current_strict, } } else { Expr::PutValueSet { diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 9a59af0ad4..655759fb3b 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -867,6 +867,35 @@ pub fn lower_module_full( imported_class_accessors: Option<&std::collections::HashMap>, is_entry_module: bool, is_external_module: bool, +) -> Result<(Module, ClassId)> { + lower_module_full_with_platform_globals( + ast_module, + name, + source_file_path, + start_class_id, + resolved_types, + imported_class_fields, + imported_class_accessors, + is_entry_module, + is_external_module, + &[], + ) +} + +/// Lower with names supplied on globalThis by the selected platform (#9745). +/// These names suppress unknown-identifier warnings while retaining ordinary +/// lexical shadowing and the same runtime lookup as an undeclared global. +pub fn lower_module_full_with_platform_globals( + ast_module: &ast::Module, + name: &str, + source_file_path: &str, + start_class_id: ClassId, + resolved_types: Option>, + imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_accessors: Option<&std::collections::HashMap>, + is_entry_module: bool, + is_external_module: bool, + platform_globals: &[&str], ) -> Result<(Module, ClassId)> { // #6812: fold straight-line builder sequences (`const o = {…}; o.k = v;`) // into the literal they spell out, so they lower through the anon-shape @@ -880,6 +909,8 @@ pub fn lower_module_full( // same `__perry_cap_*` symbols. let mut ctx = LoweringContext::with_class_id_start_salted(source_file_path, name, start_class_id); + ctx.platform_globals + .extend(platform_globals.iter().map(|name| (*name).to_string())); // Static imports are hoisted. Register `perry/native` type and value // aliases before any pre-pass extracts annotations or lowers expressions, // including when the declaration appears after its first source use. diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index e809476848..d96b0c504e 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -972,6 +972,10 @@ pub struct LoweringContext { /// module reports `true` and every imported module reports `false`. Set /// by `lower_module_with_class_id_types_seed_and_entry`; default false. pub(crate) is_entry_module: bool, + /// Names installed on globalThis by the selected platform before module + /// initialization. They still use the by-name runtime lookup; this set + /// only prevents unknown-identifier diagnostics after lexical resolution. + pub(crate) platform_globals: HashSet, /// #5833: true once lowering has produced at least one `Expr::GlobalThisExpr` /// from a top-level `this` (global-script mode, `PERRY_GLOBAL_SCRIPT_THIS`). /// `Module::references_global_this` gates codegen's reflection of diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 9351a55ad7..a48bbaa5d0 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -111,9 +111,9 @@ pub(crate) use array_fold::{ mod lower_module_fn; pub use lower_module_fn::{ - lower_module, lower_module_full, lower_module_with_class_id, - lower_module_with_class_id_and_types, lower_module_with_class_id_types_and_seed, - lower_module_with_class_id_types_seed_and_entry, + lower_module, lower_module_full, lower_module_full_with_platform_globals, + lower_module_with_class_id, lower_module_with_class_id_and_types, + lower_module_with_class_id_types_and_seed, lower_module_with_class_id_types_seed_and_entry, }; mod lower_expr; diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index f9be1c6e73..a3343d29bf 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -364,24 +364,16 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result = if fresh_binding { @@ -451,11 +444,11 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { tag(h, 12233); class_name.hash(h); key_expr.as_ref().hash(h); method_name.hash(h); is_static.hash(h); param_count.hash(h); has_rest.hash(h); definition_order.hash(h); } Expr::RegisterClassComputedAccessor { class_name, key_expr, getter_name, setter_name, is_static, definition_order } => { tag(h, 12234); class_name.hash(h); key_expr.as_ref().hash(h); getter_name.hash(h); setter_name.hash(h); is_static.hash(h); definition_order.hash(h); } Expr::ClassExprFresh { template, evaluation_owner, named_statics, computed_keys, computed_statics, static_init_order, captured_args, } => { tag(h, 12026); template.hash(h); evaluation_owner.hash(h); for (n, v) in named_statics { n.hash(h); v.hash(h); } for (n, k) in computed_keys { n.hash(h); k.hash(h); } for (n, v) in computed_statics { n.hash(h); v.hash(h); } for step in static_init_order { match step { ClassFreshStaticInit::Named(index) => { tag(h, 0); index.hash(h); }, ClassFreshStaticInit::Computed(index) => { tag(h, 1); index.hash(h); }, ClassFreshStaticInit::Block(index) => { tag(h, 2); index.hash(h); }, } } for a in captured_args { a.hash(h); } } - Expr::SetFunctionPrototype { func, proto } => { tag(h, 448); func.as_ref().hash(h); proto.as_ref().hash(h); } + Expr::SetFunctionPrototype { func, proto, strict } => { tag(h, 448); func.as_ref().hash(h); proto.as_ref().hash(h); strict.hash(h); } Expr::RegisterPrototypeMethod { class_name, method_name, value, } => { tag(h, 463); class_name.hash(h); method_name.hash(h); value.as_ref().hash(h); } Expr::RegisterFunctionPrototypeMethod { func, method_name, value, } => { tag(h, 464); func.as_ref().hash(h); method_name.hash(h); value.as_ref().hash(h); } Expr::GetFunctionPrototypeMethod { func, method_name } => { tag(h, 1465); func.as_ref().hash(h); method_name.hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index fbc7b699e6..b4a15663b4 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -637,7 +637,7 @@ where f(a); } } - Expr::SetFunctionPrototype { func, proto } => { + Expr::SetFunctionPrototype { func, proto, .. } => { f(func); f(proto); } diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index f991d28488..dc2ca8c855 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -638,7 +638,7 @@ where f(a); } } - Expr::SetFunctionPrototype { func, proto } => { + Expr::SetFunctionPrototype { func, proto, .. } => { f(func); f(proto); } diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 16a41515dd..e05f322da0 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -207,8 +207,15 @@ pub struct AsyncResourceHandle { event_emitter: i64, } +/// Is `handle` a live `AsyncResource` backing? One relaxed load answers "no" +/// while none was ever created; only then the registry lock. The generic +/// property-read ladder asks this BEFORE decoding or copying the key, so an +/// ordinary receiver — the overwhelming case — pays neither. +#[inline] pub(crate) fn is_async_resource_handle(handle: i64) -> bool { - handle != 0 && ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) + ASYNC_RESOURCE_HANDLE_COUNT.load(Ordering::Relaxed) != 0 + && handle != 0 + && ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) } /// Resolve either a native `AsyncResource` handle or the ordinary object used @@ -1371,9 +1378,7 @@ fn async_resource_bind_method_value(handle: i64) -> f64 { } pub fn try_async_resource_property_dispatch(handle: i64, property: &str) -> Option { - if ASYNC_RESOURCE_HANDLE_COUNT.load(Ordering::Relaxed) == 0 - || !ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) - { + if !is_async_resource_handle(handle) { return None; } // User-defined own properties shadow AsyncResource.prototype just as they diff --git a/crates/perry-runtime/src/box/release_tests.rs b/crates/perry-runtime/src/box/release_tests.rs index acdf163f5e..bd6d8233aa 100644 --- a/crates/perry-runtime/src/box/release_tests.rs +++ b/crates/perry-runtime/src/box/release_tests.rs @@ -511,6 +511,10 @@ fn foreign_pointer_release_is_a_total_noop() { /// asyncpipe_big). #[test] fn completed_activation_residue_is_bounded_not_linear() { + crate::test_support::isolated_test(completed_activation_residue_body); +} + +fn completed_activation_residue_body() { super::test_clear_box_registry(); const TURNS: usize = 100; const ACTIVATIONS_PER_TURN: usize = 20; @@ -544,22 +548,18 @@ fn completed_activation_residue_is_bounded_not_linear() { flush_released_boxes(); } let (a1, r1, _) = box_release_stats(); - // The counters are process-global; sibling tests on other threads - // also allocate boxes, so assert lower bounds and give the residue - // bound slack instead of demanding exact equality. + // These process-global counters now measure only this fixture, so a + // sibling's allocations cannot hide reuse or push residue over the bound. let total_allocs = (a1 - a0) as usize; let residue = total_allocs.saturating_sub((r1 - r0) as usize); let own_allocs = TURNS * ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; - assert!( - total_allocs >= own_allocs, - "every lifecycle allocates its frame ({total_allocs} < {own_allocs})" + assert_eq!( + total_allocs, own_allocs, + "every lifecycle allocates exactly its frame" ); // One turn's working set (the first turn mints real cells; every - // later turn reuses them), plus generous slack for whatever the - // parallel sibling tests allocate (they use a handful of cells - // each). The pre-fix residue is TURNS * the per-turn bound, two - // orders of magnitude past this. - let bound = 4 * ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; + // later turn reuses them). The pre-fix residue is TURNS * this bound. + let bound = ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; assert!( residue <= bound, "malloc residue must be bounded by one turn's working set: \ diff --git a/crates/perry-runtime/src/bun_compat/jsc.rs b/crates/perry-runtime/src/bun_compat/jsc.rs new file mode 100644 index 0000000000..5e8f8a5b63 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/jsc.rs @@ -0,0 +1,69 @@ +//! `bun:jsc.heapStats` reports Perry's own per-thread heap, without forcing GC. +//! +//! Sizes include arena allocations and tracked malloc GC cells, with headers. +//! Type names come from Perry's GC registry. "Protected" counts approximate +//! native protection using pinned cells; protected globals and external bytes +//! are not separately tracked and report zero. One global context is reported +//! for the calling thread. The compatibility `mimalloc` object contains Perry +//! arena/malloc counters, not measurements from a JavaScriptCore allocator. +use crate::gc::{RuntimeHandle, RuntimeHandleScope}; +use crate::object::{js_object_alloc, js_object_set_field_by_name, ObjectHeader}; +use crate::string::js_string_from_bytes; +use crate::value::JSValue; + +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_BUN_JSC_HEAP_STATS: extern "C" fn(f64) -> f64 = js_bun_jsc_heap_stats; + +fn object(scope: &RuntimeHandleScope, capacity: usize) -> RuntimeHandle<'_> { + scope.root_raw_mut_ptr(js_object_alloc(0, capacity as u32)) +} + +fn number(target: &RuntimeHandle<'_>, name: &str, value: u64) { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + target.with_mut_ptr(|t| js_object_set_field_by_name(t, key, value as f64)); +} + +fn nested(target: &RuntimeHandle<'_>, name: &str, value: &RuntimeHandle<'_>) { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = value.with_mut_ptr::(|v| JSValue::pointer(v.cast())); + target.with_mut_ptr(|t| js_object_set_field_by_name(t, key, f64::from_bits(value.bits()))); +} + +/// Bun's optional compatibility argument is accepted and ignored. Taking a +/// snapshot does not collect; users can call `Bun.gc(true)` before comparing. +#[no_mangle] +pub extern "C" fn js_bun_jsc_heap_stats(_compatibility: f64) -> f64 { + let stats = crate::gc::heap_stats(); + let scope = RuntimeHandleScope::new(); + let report = object(&scope, 10); + let types = object(&scope, stats.types.len()); + let protected_types = object(&scope, stats.types.len()); + let allocator = object(&scope, 4); + for (name, count, pinned) in stats.types { + number(&types, name, count); + if pinned != 0 { + number(&protected_types, name, pinned); + } + } + let used = stats.arena_used.saturating_add(stats.malloc_bytes); + let capacity = stats + .arena_reserved + .saturating_add(stats.malloc_bytes) + .max(used); + number(&report, "heapSize", used); + number(&report, "heapCapacity", capacity); + number(&report, "extraMemorySize", 0); + number(&report, "objectCount", stats.object_count); + number(&report, "protectedObjectCount", stats.pinned_count); + number(&report, "globalObjectCount", 1); + number(&report, "protectedGlobalObjectCount", 0); + nested(&report, "objectTypeCounts", &types); + nested(&report, "protectedObjectTypeCounts", &protected_types); + number(&allocator, "arenaUsed", stats.arena_used); + number(&allocator, "arenaReserved", stats.arena_reserved); + number(&allocator, "gcMallocBytes", stats.malloc_bytes); + number(&allocator, "gcMallocObjectCount", stats.malloc_count); + nested(&report, "mimalloc", &allocator); + report.with_mut_ptr::(|r| f64::from_bits(JSValue::pointer(r.cast()).bits())) +} diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 01ba45b3f7..e91831f4c0 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -26,6 +26,7 @@ mod cli_utils; #[cfg(not(feature = "bun-cli-utils"))] mod cli_utils_stub; mod glob; +mod jsc; mod spawn; mod string_width; mod width_tables; @@ -48,6 +49,7 @@ pub use cli_utils::*; #[cfg(not(feature = "bun-cli-utils"))] pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; +pub use jsc::js_bun_jsc_heap_stats; pub use spawn::{js_bun_spawn, js_bun_terminal_new}; pub use string_width::bun_string_width; pub use wyhash::wyhash; diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index 5f7c03693e..1f062cb14e 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -195,30 +195,7 @@ mod walk { #[cfg(all(target_os = "linux", not(target_vendor = "apple")))] fn stack_top_uncached() -> usize { - unsafe extern "C" { - fn pthread_self() -> usize; - fn pthread_getattr_np(thread: usize, attr: *mut u8) -> i32; - fn pthread_attr_getstack( - attr: *const u8, - stackaddr: *mut *mut core::ffi::c_void, - stacksize: *mut usize, - ) -> i32; - fn pthread_attr_destroy(attr: *mut u8) -> i32; - } - let mut attr = [0u8; 128]; - let mut addr: *mut core::ffi::c_void = core::ptr::null_mut(); - let mut size: usize = 0; - unsafe { - if pthread_getattr_np(pthread_self(), attr.as_mut_ptr()) != 0 { - return 0; - } - let ok = pthread_attr_getstack(attr.as_ptr(), &mut addr, &mut size) == 0; - pthread_attr_destroy(attr.as_mut_ptr()); - if !ok { - return 0; - } - } - (addr as usize).saturating_add(size) + crate::native_stack::stack_top() } // The bound is a property of the thread, and `new Error` is frequent diff --git a/crates/perry-runtime/src/gc/census.rs b/crates/perry-runtime/src/gc/census.rs index 891035533f..dc4c00c0d9 100644 --- a/crates/perry-runtime/src/gc/census.rs +++ b/crates/perry-runtime/src/gc/census.rs @@ -43,7 +43,7 @@ static SIGNAL_PENDING: AtomicBool = AtomicBool::new(false); static SIGNAL_INSTALLED: AtomicBool = AtomicBool::new(false); static MAIN_THREAD: OnceLock = OnceLock::new(); -thread_local! { +crate::perry_thread_local! { static ARMED: Cell = const { Cell::new(false) }; static SEQ: Cell = const { Cell::new(0) }; static LABEL: RefCell<&'static str> = const { RefCell::new("manual") }; @@ -177,12 +177,18 @@ fn census_service_signal() { super::js_gc_collect(); } -thread_local! { +crate::perry_thread_local! { /// Pass-1 snapshot: sorted header addresses that were marked when mark /// propagation finished (see the module docs). static PASS1_MARKED: RefCell>> = const { RefCell::new(None) }; } +/// The snapshot must be consumed before the collector returns to the mutator. +#[cfg(test)] +pub(crate) fn test_has_pass1_snapshot() -> bool { + PASS1_MARKED.with(|p| p.borrow().is_some()) +} + #[inline] fn header_is_marked(header: *const GcHeader) -> bool { // SAFETY: caller hands out headers of walkable objects inside mapped diff --git a/crates/perry-runtime/src/gc/heap_stats.rs b/crates/perry-runtime/src/gc/heap_stats.rs new file mode 100644 index 0000000000..8f8c0f64cb --- /dev/null +++ b/crates/perry-runtime/src/gc/heap_stats.rs @@ -0,0 +1,69 @@ +//! Numeric heap census for `bun:jsc.heapStats`, covering the calling thread. +//! +//! No JS allocations or collection may occur during this walk. Only counters +//! leave the walk, so constructing the JS report afterwards cannot invalidate +//! a saved pointer. As with heap snapshots, uncollected arena residents can +//! appear, but free-list slots and forwarding headers do not. +use super::*; + +pub(crate) struct HeapStats { + pub(crate) arena_used: u64, + pub(crate) arena_reserved: u64, + pub(crate) malloc_bytes: u64, + pub(crate) malloc_count: u64, + pub(crate) object_count: u64, + pub(crate) pinned_count: u64, + pub(crate) types: Vec<(&'static str, u64, u64)>, +} + +pub(crate) fn heap_stats() -> HeapStats { + let mut arena_used = 0; + let mut arena_reserved = 0; + crate::arena::js_arena_stats(&mut arena_used, &mut arena_reserved); + let free_slots: std::collections::HashSet<*mut u8> = + ARENA_FREE_LIST.with(|slots| slots.borrow().iter().map(|&(ptr, _)| ptr).collect()); + let mut counts = [0u64; GC_TYPE_MAX as usize + 1]; + let mut pinned = [0u64; GC_TYPE_MAX as usize + 1]; + let mut malloc_bytes = 0u64; + let mut malloc_count = 0u64; + let mut visit = |ptr: *mut u8, malloc: bool| unsafe { + let header = &*ptr.cast::(); + if gc_type_info(header.obj_type).is_none() + || header.size == 0 + || header.gc_flags & GC_FLAG_FORWARDED != 0 + || free_slots.contains(&ptr) + || free_slots.contains(&ptr.add(GC_HEADER_SIZE)) + { + return; + } + let index = header.obj_type as usize; + counts[index] += 1; + if header.gc_flags & GC_FLAG_PINNED != 0 { + pinned[index] += 1; + } + if malloc { + malloc_count += 1; + malloc_bytes = malloc_bytes.saturating_add(header.size as u64); + } + }; + crate::arena::arena_walk_objects(|ptr| visit(ptr, false)); + MALLOC_STATE.with(|state| { + for &header in &state.borrow().objects { + visit(header.cast(), true); + } + }); + HeapStats { + arena_used, + arena_reserved, + malloc_bytes, + malloc_count, + object_count: counts.iter().sum(), + pinned_count: pinned.iter().sum(), + types: gc_type_infos() + .filter_map(|info| { + let index = info.type_id as usize; + (counts[index] != 0).then_some((info.name, counts[index], pinned[index])) + }) + .collect(), + } +} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 81c6aa9ff7..2bbc530e0a 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -247,9 +247,11 @@ pub use verify::*; pub(crate) mod census; #[cfg(feature = "diagnostics")] mod heap_snapshot; +mod heap_stats; pub use census::{census_poll_signal, gc_census_enabled}; #[cfg(feature = "diagnostics")] pub use heap_snapshot::gc_build_v8_heap_snapshot_json; +pub(crate) use heap_stats::heap_stats; pub fn gc_collect_minor() -> u64 { if defer_gc_request(DeferredGcRequest::DirectMinor) { diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 331996b047..d243c4e976 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -755,30 +755,7 @@ pub(super) fn get_stack_bottom() -> usize { #[cfg(target_os = "linux")] pub(super) fn get_stack_bottom() -> usize { - extern "C" { - fn pthread_self() -> usize; - fn pthread_attr_init(attr: *mut [u64; 8]) -> i32; - fn pthread_getattr_np(thread: usize, attr: *mut [u64; 8]) -> i32; - fn pthread_attr_getstack( - attr: *const [u64; 8], - stackaddr: *mut *mut u8, - stacksize: *mut usize, - ) -> i32; - fn pthread_attr_destroy(attr: *mut [u64; 8]) -> i32; - } - unsafe { - let thread = pthread_self(); - let mut attr = [0u64; 8]; - pthread_attr_init(&mut attr); - if pthread_getattr_np(thread, &mut attr) != 0 { - return 0; - } - let mut stackaddr: *mut u8 = std::ptr::null_mut(); - let mut stacksize: usize = 0; - pthread_attr_getstack(&attr, &mut stackaddr, &mut stacksize); - pthread_attr_destroy(&mut attr); - stackaddr as usize + stacksize - } + crate::native_stack::stack_top() } // Windows: read TEB.StackBase. Works on every supported Windows version diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 4e52ed3915..842b41ea36 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -1781,37 +1781,11 @@ mod fp_chain { } /// Linux (#7173): stack bounds via pthread attrs — the returned address - /// is the LOW end, so the exclusive top is addr + size. Runtime gates - /// pending a Linux host; a failure here returns 0 and the caller falls - /// back to the platform unwinder (fail-closed like every other anomaly). + /// is the LOW end, so the exclusive top is addr + size. A failure returns + /// 0 and the caller falls back to the platform unwinder. #[cfg(target_os = "linux")] fn stack_top() -> usize { - unsafe extern "C" { - fn pthread_self() -> usize; - fn pthread_getattr_np(thread: usize, attr: *mut u8) -> i32; - fn pthread_attr_getstack( - attr: *const u8, - stackaddr: *mut *mut c_void, - stacksize: *mut usize, - ) -> i32; - fn pthread_attr_destroy(attr: *mut u8) -> i32; - } - // pthread_attr_t is at most 64 bytes on glibc/musl for the supported - // targets; over-allocate defensively. - let mut attr = [0u8; 128]; - let mut addr: *mut c_void = std::ptr::null_mut(); - let mut size: usize = 0; - unsafe { - if pthread_getattr_np(pthread_self(), attr.as_mut_ptr()) != 0 { - return 0; - } - let ok = pthread_attr_getstack(attr.as_ptr(), &mut addr, &mut size) == 0; - pthread_attr_destroy(attr.as_mut_ptr()); - if !ok { - return 0; - } - } - (addr as usize).saturating_add(size) + crate::native_stack::stack_top() } pub(super) fn visit( diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs index b945db9185..347f74c987 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs @@ -247,6 +247,11 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn discovers_a_map_from_a_later_loaded_shared_object() { + crate::test_support::isolated_test(discovers_later_loaded_map_body); + } + + #[cfg(target_os = "linux")] + fn discovers_later_loaded_map_body() { use std::ffi::CString; use std::fmt::Write as _; use std::os::unix::ffi::OsStrExt; @@ -327,6 +332,13 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn rejects_an_unreadable_loaded_shared_object() { + // This fixture deliberately poisons the process's loaded-image set. + // Isolate the writer too, so no sibling stack-map scan can observe it. + crate::test_support::isolated_test(rejects_unreadable_loaded_object_body); + } + + #[cfg(target_os = "linux")] + fn rejects_unreadable_loaded_object_body() { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; use std::process::Command; diff --git a/crates/perry-runtime/src/gc/tests/census.rs b/crates/perry-runtime/src/gc/tests/census.rs index b8cd23c33c..2672146c4d 100644 --- a/crates/perry-runtime/src/gc/tests/census.rs +++ b/crates/perry-runtime/src/gc/tests/census.rs @@ -7,8 +7,13 @@ use super::super::*; use super::support::*; fn take(label: &'static str) { + assert!(!super::super::census::test_has_pass1_snapshot()); super::super::census::census_arm(label); gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)); + assert!( + !super::super::census::test_has_pass1_snapshot(), + "the untraced snapshot must not outlive the synchronous full cycle" + ); } fn read_lines(path: &str) -> Vec { @@ -88,6 +93,9 @@ fn census_reports_a_known_composition_and_sees_deadness() { let lines = read_lines(&path_str); let _ = std::fs::remove_file(&path); assert_eq!(lines.len(), 3, "one census line per armed full collection"); + for line in &lines { + assert_eq!(line["totals"]["reachability_pass"], true, "pass 1 must run"); + } let (b, a, d) = (&lines[0], &lines[1], &lines[2]); assert_eq!(b["label"], "baseline"); assert_eq!(a["label"], "populated"); diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs new file mode 100644 index 0000000000..059f6e153a --- /dev/null +++ b/crates/perry-runtime/src/hot_diag.rs @@ -0,0 +1,489 @@ +//! Counter-first instruments for the mutator paths a TUI keystroke exercises. +//! +//! * `PERRY_REGEX_DIAG=` — RegExp construction / lazy build / cache +//! clears / exec-family calls, plus a per-pattern table (keyed by the +//! pattern `StringHeader` address, merged by content prefix at dump time). +//! * `PERRY_IC_DIAG=` — property-read inline-cache misses split by the +//! REASON the handler took (receiver kind, own/inherited, prime outcome), +//! with a per-site table keyed by the site's cache slot. +//! +//! `` is a file; `1`/`stderr` writes to stderr. A snapshot is written +//! every ~1 s of activity — the measurement rig kills the process with +//! `SIGKILL`, so an exit hook alone would never fire — and the snapshot +//! replaces the previous one (write to `.tmp`, then rename). Both +//! instruments are diagnostic only: nothing may branch on them for behaviour, +//! and when the variable is unset every probe is one relaxed atomic load. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Instant; + +/// How the diag output is delivered. +#[derive(Clone)] +enum Sink { + Stderr, + File(String), +} + +fn sink_from_env(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + let raw = raw.trim(); + match raw { + "" | "0" | "off" | "false" | "no" => None, + "1" | "stderr" | "on" | "true" | "yes" => Some(Sink::Stderr), + path => Some(Sink::File(path.to_string())), + } +} + +fn write_sink(sink: &Sink, text: &str) { + match sink { + Sink::Stderr => eprint!("{text}"), + Sink::File(path) => { + let tmp = format!("{path}.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } + } +} + +/// Events between two "should we dump?" clock reads. +const TICK_EVERY: u32 = 256; +const DUMP_INTERVAL_MS: u128 = 1000; + +// --------------------------------------------------------------------------- +// RegExp +// --------------------------------------------------------------------------- + +static REGEX_SINK: OnceLock> = OnceLock::new(); +static REGEX_ON: AtomicBool = AtomicBool::new(false); + +/// One-time env parse; arms [`REGEX_ON`]. Called from the first probe. +fn regex_sink() -> &'static Option { + REGEX_SINK.get_or_init(|| { + let sink = sink_from_env("PERRY_REGEX_DIAG"); + REGEX_ON.store(sink.is_some(), Ordering::Relaxed); + sink + }) +} + +/// Is the regex instrument armed? One relaxed load once initialised. +#[inline] +pub fn regex_on() -> bool { + if REGEX_SINK.get().is_none() { + regex_sink(); + } + REGEX_ON.load(Ordering::Relaxed) +} + +#[derive(Default)] +struct PatStat { + prefix: String, + byte_len: u32, + flags: String, + news: u64, + builds: u64, + execs: u64, + tests: u64, + replaces: u64, + matches: u64, +} + +#[derive(Default)] +pub struct RegexDiag { + started: Option, + last_dump: Option, + events: u32, + pub new_calls: u64, + /// `js_regexp_new` found `(pattern, flags)` in `VALIDATED_PATTERNS`. + pub new_validated_hit: u64, + /// `js_regexp_new` answered from the literal-site cache (no validation, + /// no owned copies, programs installed eagerly). + pub new_site_hit: u64, + /// Sum of pattern bytes seen by `js_regexp_new` (what a content hash or + /// copy of the pattern costs per construction). + pub new_pattern_bytes: u64, + pub compiles_std: u64, + pub compiles_fancy: u64, + pub compiles_repeat: u64, + pub cache_clears: u64, + /// `lazy::build_and_install_programs` runs (one per header that is + /// executed at least once). + pub lazy_builds: u64, + /// Of those, the standard-engine program came from `REGEX_CACHE`. + pub lazy_cache_hits: u64, + pub exec_calls: u64, + pub exec_matched: u64, + pub exec_capture_slots: u64, + pub exec_capture_bytes: u64, + pub test_calls: u64, + /// `test` on a global/sticky receiver (used to build a full exec array). + pub test_global: u64, + pub match_calls: u64, + pub replace_calls: u64, + pub replace_matches: u64, + pub split_calls: u64, + per_pattern: HashMap, +} + +thread_local! { + static REGEX_DIAG: RefCell = RefCell::new(RegexDiag::default()); +} + +/// Run `f` against the thread's regex counters, then maybe dump. +#[inline] +pub fn regex_with(f: impl FnOnce(&mut RegexDiag)) { + REGEX_DIAG.with(|d| { + let mut d = d.borrow_mut(); + if d.started.is_none() { + d.started = Some(Instant::now()); + d.last_dump = d.started; + } + f(&mut d); + d.events = d.events.wrapping_add(1); + if d.events % TICK_EVERY == 0 { + let due = d + .last_dump + .is_some_and(|t| t.elapsed().as_millis() >= DUMP_INTERVAL_MS); + if due { + d.last_dump = Some(Instant::now()); + if let Some(sink) = regex_sink() { + write_sink(sink, &d.render()); + } + } + } + }); +} + +impl RegexDiag { + fn pat(&mut self, pattern_addr: usize, pattern: &[u8], flags: &str) -> &mut PatStat { + let entry = self.per_pattern.entry(pattern_addr).or_default(); + if entry.prefix.is_empty() && entry.byte_len == 0 { + let n = pattern.len().min(48); + entry.prefix = String::from_utf8_lossy(&pattern[..n]).into_owned(); + entry.byte_len = pattern.len() as u32; + entry.flags = flags.to_string(); + } + entry + } + + /// Record one `js_regexp_new`. + pub fn note_new( + &mut self, + pattern_addr: usize, + pattern: &[u8], + flags: &str, + validated_hit: bool, + site_hit: bool, + ) { + self.new_calls += 1; + self.new_pattern_bytes += pattern.len() as u64; + if validated_hit { + self.new_validated_hit += 1; + } + if site_hit { + self.new_site_hit += 1; + } + self.pat(pattern_addr, pattern, flags).news += 1; + } + + /// Record one lazy program build for a header. + pub fn note_build( + &mut self, + pattern_addr: usize, + pattern: &[u8], + flags: &str, + cache_hit: bool, + ) { + self.lazy_builds += 1; + if cache_hit { + self.lazy_cache_hits += 1; + } + self.pat(pattern_addr, pattern, flags).builds += 1; + } + + /// Record one exec-family call against a header's pattern. + pub fn note_op(&mut self, pattern_addr: usize, pattern: &[u8], flags: &str, op: RegexOp) { + let stat = self.pat(pattern_addr, pattern, flags); + match op { + RegexOp::Exec => { + stat.execs += 1; + } + RegexOp::Test => { + stat.tests += 1; + } + RegexOp::Replace => { + stat.replaces += 1; + } + RegexOp::Match => { + stat.matches += 1; + } + } + match op { + RegexOp::Exec => self.exec_calls += 1, + RegexOp::Test => self.test_calls += 1, + RegexOp::Replace => self.replace_calls += 1, + RegexOp::Match => self.match_calls += 1, + } + } + + fn render(&self) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(4096); + let secs = self.started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let _ = writeln!( + out, + "[regex-diag] t={secs:.1}s new={} validated_hit={} site_hit={} pattern_bytes={} \ + compiles std={} fancy={} repeat={} cache_clears={} lazy_builds={} lazy_cache_hits={} \ + exec={} exec_matched={} capture_slots={} capture_bytes={} test={} test_global={} \ + match={} replace={} replace_matches={} split={}", + self.new_calls, + self.new_validated_hit, + self.new_site_hit, + self.new_pattern_bytes, + self.compiles_std, + self.compiles_fancy, + self.compiles_repeat, + self.cache_clears, + self.lazy_builds, + self.lazy_cache_hits, + self.exec_calls, + self.exec_matched, + self.exec_capture_slots, + self.exec_capture_bytes, + self.test_calls, + self.test_global, + self.match_calls, + self.replace_calls, + self.replace_matches, + self.split_calls, + ); + // Merge by content (prefix, len, flags): distinct literal sites with + // the same pattern are one row. + let mut merged: HashMap<(String, u32, String), PatStat> = HashMap::new(); + for p in self.per_pattern.values() { + let e = merged + .entry((p.prefix.clone(), p.byte_len, p.flags.clone())) + .or_default(); + e.news += p.news; + e.builds += p.builds; + e.execs += p.execs; + e.tests += p.tests; + e.replaces += p.replaces; + e.matches += p.matches; + } + let mut rows: Vec<_> = merged.into_iter().collect(); + rows.sort_by_key(|(_, s)| { + std::cmp::Reverse(s.news * (1 + s.builds) + s.execs + s.tests + s.replaces + s.matches) + }); + let _ = writeln!( + out, + " news builds execs tests replaces matches len flags pattern-prefix ({} distinct)", + rows.len() + ); + for ((prefix, len, flags), s) in rows.iter().take(40) { + let _ = writeln!( + out, + " {:5} {:6} {:5} {:5} {:8} {:7} {len:5} /{flags}/ {}", + s.news, + s.builds, + s.execs, + s.tests, + s.replaces, + s.matches, + prefix.replace('\n', "\\n") + ); + } + out + } +} + +/// Which exec-family entry point recorded an operation. +#[derive(Clone, Copy)] +pub enum RegexOp { + Exec, + Test, + Replace, + Match, +} + +// --------------------------------------------------------------------------- +// Property-read inline-cache misses +// --------------------------------------------------------------------------- + +static IC_SINK: OnceLock> = OnceLock::new(); +static IC_ON: AtomicBool = AtomicBool::new(false); + +fn ic_sink() -> &'static Option { + IC_SINK.get_or_init(|| { + let sink = sink_from_env("PERRY_IC_DIAG"); + IC_ON.store(sink.is_some(), Ordering::Relaxed); + sink + }) +} + +/// Is the IC-miss instrument armed? One relaxed load once initialised. +#[inline] +pub fn ic_on() -> bool { + if IC_SINK.get().is_none() { + ic_sink(); + } + IC_ON.load(Ordering::Relaxed) +} + +/// Why `js_object_get_field_ic_miss` answered the way it did. The order is +/// the order of the handler's ladder. +#[derive(Clone, Copy, Debug)] +#[repr(u8)] +pub enum IcMissReason { + /// SSO (short-string) receiver — never cacheable. + SsoReceiver = 0, + /// Null receiver or key. + NullArgs, + /// Proxy id band. + Proxy, + /// Async-resource handle property. + AsyncResource, + /// Array-subclass elements store answered. + SubclassElements, + /// `.length` on a dense array / object-backed Array subclass. + ArrayLength, + /// Closure receiver (function object with expando props). + ClosureProp, + /// Registered Buffer receiver. + Buffer, + /// Registered typed array receiver. + TypedArray, + /// Small native handle (timers, text codecs, handle dispatch). + SmallHandle, + /// Receiver is a heap pointer but not `GC_TYPE_OBJECT` (array, string, + /// map, set, promise, ...): the IC can never serve it. + NonObjectGcType, + /// `GC_TYPE_OBJECT` whose shape kind is not `Ordinary` (dictionary / + /// exotic) or whose header is forwarded. + ObjectIrregular, + /// Ordinary object with no keys array yet. + ObjectNoKeys, + /// Own inline field found: primed the MRU entry and returned. + OwnInlinePrimed, + /// Own overflow field found: primed with the overflow bit. + OwnOverflowPrimed, + /// Own field found but the receiver carries descriptors (or the overflow + /// value was not readable) — fell through to the generic read. + OwnDescriptorFallthrough, + /// Key is not an own property of the receiver: inherited (prototype + /// method / accessor) or absent. The generic read walks the chain. + NotOwn, +} + +pub const IC_MISS_REASONS: usize = 17; + +const IC_REASON_NAMES: [&str; IC_MISS_REASONS] = [ + "sso_receiver", + "null_args", + "proxy", + "async_resource", + "subclass_elements", + "array_length", + "closure_prop", + "buffer", + "typed_array", + "small_handle", + "non_object_gc_type", + "object_irregular", + "object_no_keys", + "own_inline_primed", + "own_overflow_primed", + "own_descriptor_fallthrough", + "not_own", +]; + +#[derive(Default)] +struct SiteStat { + key: String, + misses: u64, + by_reason: [u32; IC_MISS_REASONS], +} + +#[derive(Default)] +pub struct IcDiag { + started: Option, + last_dump: Option, + events: u32, + pub misses: u64, + by_reason: [u64; IC_MISS_REASONS], + sites: HashMap, +} + +thread_local! { + static IC_DIAG: RefCell = RefCell::new(IcDiag::default()); +} + +/// Record one IC miss. `site` is the per-site cache slot address (stable for +/// the process lifetime), `key` the property-name string bytes. +pub fn ic_note(site: usize, key: &[u8], reason: IcMissReason) { + IC_DIAG.with(|d| { + let mut d = d.borrow_mut(); + if d.started.is_none() { + d.started = Some(Instant::now()); + d.last_dump = d.started; + } + d.misses += 1; + d.by_reason[reason as usize] += 1; + let s = d.sites.entry(site).or_default(); + if s.key.is_empty() { + s.key = String::from_utf8_lossy(&key[..key.len().min(40)]).into_owned(); + } + s.misses += 1; + s.by_reason[reason as usize] += 1; + d.events = d.events.wrapping_add(1); + if d.events % TICK_EVERY == 0 { + let due = d + .last_dump + .is_some_and(|t| t.elapsed().as_millis() >= DUMP_INTERVAL_MS); + if due { + d.last_dump = Some(Instant::now()); + if let Some(sink) = ic_sink() { + write_sink(sink, &d.render()); + } + } + } + }); +} + +impl IcDiag { + fn render(&self) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(4096); + let secs = self.started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let _ = write!( + out, + "[ic-diag] t={secs:.1}s misses={} sites={}", + self.misses, + self.sites.len() + ); + for (i, name) in IC_REASON_NAMES.iter().enumerate() { + if self.by_reason[i] != 0 { + let _ = write!(out, " {name}={}", self.by_reason[i]); + } + } + out.push('\n'); + let mut rows: Vec<&SiteStat> = self.sites.values().collect(); + rows.sort_by_key(|s| std::cmp::Reverse(s.misses)); + let _ = writeln!(out, " misses key reasons"); + for s in rows.iter().take(40) { + let mut reasons = String::new(); + let mut idx: Vec = (0..IC_MISS_REASONS) + .filter(|&i| s.by_reason[i] != 0) + .collect(); + idx.sort_by(|a, b| s.by_reason[*b].cmp(&s.by_reason[*a])); + for i in idx.iter().take(3) { + let _ = write!(reasons, " {}={}", IC_REASON_NAMES[*i], s.by_reason[*i]); + } + let _ = writeln!(out, " {:6} {:<24}{reasons}", s.misses, s.key); + } + out + } +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 92276b544f..6b17a7a6f1 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -88,6 +88,7 @@ pub mod ffi; pub mod frame; pub mod fs; pub mod gc; +pub mod hot_diag; pub mod intl; pub mod iter_result; pub mod iterator_helpers; @@ -100,6 +101,8 @@ pub mod module_require; pub mod native_abi; pub mod native_arena; pub mod native_handle; +#[cfg(target_os = "linux")] +mod native_stack; pub mod native_value_profile; pub mod navigator; pub mod net_validate; diff --git a/crates/perry-runtime/src/native_stack.rs b/crates/perry-runtime/src/native_stack.rs new file mode 100644 index 0000000000..2961703652 --- /dev/null +++ b/crates/perry-runtime/src/native_stack.rs @@ -0,0 +1,63 @@ +//! Linux stack bounds shared by the collector and error-frame walkers. + +/// Return the exclusive upper bound of this thread's native stack, or zero +/// when pthread cannot supply it. Callers use zero to abandon the stack walk. +pub(crate) fn stack_top() -> usize { + // libc supplies the target's exact size, alignment and declarations. + // Hand-written byte buffers and externs previously disagreed across the + // three consumers, causing clashing_extern_declarations in Linux CI. + let mut attr = std::mem::MaybeUninit::::uninit(); + let mut addr: *mut libc::c_void = std::ptr::null_mut(); + let mut size: usize = 0; + // SAFETY: pthread_getattr_np initializes attr on success. Only then may + // getstack read it and destroy release its resources. Both output slots + // are live, correctly typed locals; the stack address is never dereferenced. + let ok = unsafe { + if libc::pthread_getattr_np(libc::pthread_self(), attr.as_mut_ptr()) != 0 { + return 0; + } + let ok = libc::pthread_attr_getstack(attr.as_ptr(), &mut addr, &mut size) == 0; + libc::pthread_attr_destroy(attr.as_mut_ptr()); + ok + }; + if !ok || addr.is_null() || size == 0 { + return 0; + } + (addr as usize).checked_add(size).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::stack_top; + + #[test] + fn stack_top_encloses_a_current_thread_local() { + let local = 0u8; + let address = std::hint::black_box(&local) as *const u8 as usize; + assert!( + stack_top() > address, + "pthread must bound the current stack" + ); + } + + #[test] + fn stack_top_respects_custom_thread_stack_sizes() { + for stack_size in [256 * 1024, 2 * 1024 * 1024] { + std::thread::Builder::new() + .stack_size(stack_size) + .spawn(move || { + let local = 0u8; + let address = std::hint::black_box(&local) as *const u8 as usize; + let top = stack_top(); + assert!(top > address, "worker stack bound must enclose its local"); + assert!( + top - address <= stack_size, + "bound must belong to this worker" + ); + }) + .unwrap() + .join() + .unwrap(); + } + } +} diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 0ab60a0bb3..99c9443007 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1183,7 +1183,7 @@ pub(crate) unsafe fn replay_class_object_constructor( inst: *mut ObjectHeader, args_ptr: *const f64, args_len: usize, -) { +) -> f64 { // Callers scope their argument read with `with_mut_ptr`; establish this // function's own roots before any constructor-replay path can allocate. let scope = crate::gc::RuntimeHandleScope::new(); @@ -1219,7 +1219,7 @@ pub(crate) unsafe fn replay_class_object_constructor( inst_handle.with_mut_ptr::(|inst| { default_error_init_for_implicit_chain(class_cid, inst, args_ptr, args_len); }); - return; + return f64::from_bits(crate::value::TAG_UNDEFINED); }; // Read the snapshotted captures (an own array, in capture-param order). @@ -1348,7 +1348,7 @@ pub(crate) unsafe fn replay_class_object_constructor( let _active_evaluation = super::class_registry::push_active_class_evaluation(capture_owner_handle.get_nanbox_f64()); inst_handle.with_mut_ptr::(|inst| { - let _ = call_vtable_method( + call_vtable_method( ctor_ptr, inst as i64, final_args.as_ptr(), @@ -1358,8 +1358,8 @@ pub(crate) unsafe fn replay_class_object_constructor( // Capture-forwarding constructor args are materialized positionally // above (including any caps), so no trailing rest re-packing here. false, - ); - }); + ) + }) } /// Replay a registered class declaration constructor for an INT32-tagged @@ -1371,7 +1371,7 @@ pub(crate) unsafe fn replay_registered_class_constructor( inst: *mut ObjectHeader, args_ptr: *const f64, args_len: usize, -) { +) -> f64 { // Spec: a derived class with no own `constructor` gets the implicit // `constructor(...args) { super(...args) }` — the nearest ancestor's ctor // runs with the same argument list. `lookup_class_constructor` holds OWN @@ -1400,7 +1400,7 @@ pub(crate) unsafe fn replay_registered_class_constructor( // #6469: all-implicit ctor chain to a native base — run the spec // default Error-init instead of silently constructing message-less. default_error_init_for_implicit_chain(class_cid, inst, args_ptr, args_len); - return; + return f64::from_bits(crate::value::TAG_UNDEFINED); }; // A function-nested class declaration may carry a decl-site capture @@ -1456,7 +1456,7 @@ pub(crate) unsafe fn replay_registered_class_constructor( for slot in 0..sig_caps as usize { final_args.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } - let _ = call_vtable_method( + call_vtable_method( ctor_ptr, inst as i64, final_args.as_ptr(), @@ -1464,5 +1464,5 @@ pub(crate) unsafe fn replay_registered_class_constructor( total_params, false, false, - ); + ) } diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 4fcf63054f..4c7de9a7a5 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -96,7 +96,9 @@ pub(crate) use prototype_objects::{ function_value_for_class_id, resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, }; -pub use prototype_objects::{js_set_function_prototype, NEXT_SYNTHETIC_CLASS_ID}; +pub use prototype_objects::{ + js_set_function_prototype, js_set_prototype_property, NEXT_SYNTHETIC_CLASS_ID, +}; // ── class_meta.rs ─────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 454accd707..597cac4f79 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -964,15 +964,43 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( // capture params from the snapshotted `__perry_ctor_caps`. The // mechanism lives in `class_constructors` to keep this file under // the 2,000-line CI gate. - inst_handle.with_mut_ptr::(|inst| { + // Publish this exact class evaluation as newTarget while replaying + // the standalone constructor. Dynamic builtin `super()` uses it + // to give a replacement receiver the evaluation-specific + // prototype and private brand (#9503). + let prev_new_target = crate::object::js_new_target_get(); + let prev_new_target_handle = scope.root_nanbox_f64(prev_new_target); + let active_new_target = class_handle.get_nanbox_f64(); + crate::object::js_new_target_set(active_new_target); + let prev_current_new_target = + CURRENT_NEW_TARGET.with(|value| value.replace(active_new_target.to_bits())); + let prev_current_new_target_handle = scope.root_nanbox_u64(prev_current_new_target); + let ctor_result = inst_handle.with_mut_ptr::(|inst| { super::super::class_constructors::replay_class_object_constructor( class_handle.get_nanbox_f64(), class_cid, inst, args_ptr, args_len, - ); + ) }); + CURRENT_NEW_TARGET + .with(|value| value.set(prev_current_new_target_handle.get_nanbox_u64())); + crate::object::js_new_target_set(prev_new_target_handle.get_nanbox_f64()); + // The standalone constructor publishes its final `this` when a + // dynamic super-constructor can replace the provisional receiver. + // A class-object replay used to discard that result and return the + // allocation above, so writes after `super()` landed on an object + // that `new` never exposed (#9503). Return an actual replacement + // immediately; when the constructor retained the allocation, keep + // the native-backing completion paths below unchanged. + let current_inst = inst_handle + .with_mut_ptr::(|i| crate::value::js_nanbox_pointer(i as i64)); + if constructor_return_overrides_this(ctor_result) + && ctor_result.to_bits() != current_inst.to_bits() + { + return ctor_result; + } // `class X extends Request/Response {}` constructed via the dynamic // (class-expression value) path: the replayed ctor's `super()` // can't statically route an aliased parent, so attach the native @@ -1215,7 +1243,7 @@ pub(crate) fn js_value_is_constructor(value: f64) -> bool { // number of `.bind()` layers first so the checks below (class ref, // proxy, arrow, non-constructable builtin) see the real callee. let value = resolve_bound_target(value); - if constructor_class_ref_id(value).is_some() { + if constructor_class_ref_id(value).is_some() || is_class_object_value(value) { return true; } if crate::proxy::js_proxy_is_proxy(value) == 1 { @@ -1499,12 +1527,22 @@ unsafe fn construct_registered_class_ref( let prev_current_new_target = CURRENT_NEW_TARGET.with(|value| value.replace(new_target.to_bits())); let prev_current_new_target_handle = scope.root_nanbox_u64(prev_current_new_target); - super::super::class_constructors::replay_registered_class_constructor( + let ctor_result = super::super::class_constructors::replay_registered_class_constructor( target_cid, inst, args_ptr, args_len, ); let inst: *mut ObjectHeader = inst_handle.get_raw_mut_ptr(); CURRENT_NEW_TARGET.with(|value| value.set(prev_current_new_target_handle.get_nanbox_u64())); crate::object::js_new_target_set(prev_new_target_handle.get_nanbox_f64()); + // A replayed standalone constructor can bind a replacement `this` from + // its dynamic super-constructor. Preserve that result across the runtime + // ClassRef construction boundary instead of publishing the provisional + // allocation whose fields the constructor stopped updating (#9503). + let current_inst = crate::value::js_nanbox_pointer(inst as i64); + if constructor_return_overrides_this(ctor_result) + && ctor_result.to_bits() != current_inst.to_bits() + { + return ctor_result; + } // ClassRef `new` of a Request/Response subclass — attach the native fetch // handle on the dynamic path (mirrors the class-expression arm above). if let Some(kind) = fetch_parent_kind_in_chain(target_cid) { @@ -1636,6 +1674,9 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( crate::error::js_throw_bigint_constructor_type_error() }; } + if ta_name == "Object" { + return construct_object_with_new_target(nt); + } // `Reflect.construct(Date, args, newTarget)` (#5989) — Next.js 16's // cacheComponents Date extension constructs through exactly this // shape: its installed wrapper runs diff --git a/crates/perry-runtime/src/object/class_registry/construct/class_object.rs b/crates/perry-runtime/src/object/class_registry/construct/class_object.rs index 1883a4d8a2..72b5675be4 100644 --- a/crates/perry-runtime/src/object/class_registry/construct/class_object.rs +++ b/crates/perry-runtime/src/object/class_registry/construct/class_object.rs @@ -11,9 +11,52 @@ fn link_class_object_instance_prototype(class_value: f64, instance: *mut ObjectH unsafe { super::super::field_get_set::class_object_prototype_value(class_obj) }; let prototype = scope.root_heap_word_u64(prototype.bits()); instance.with_mut_ptr::(|instance| { - super::super::prototype_chain::object_link_class_default_prototype( + super::super::prototype_chain::object_link_class_evaluation_prototype( instance as usize, prototype.get_heap_word_u64(), ) }); } + +/// Object's constructor has special newTarget semantics: when invoked as the +/// super-constructor of a derived class it ignores `value` and performs +/// OrdinaryCreateFromConstructor(newTarget). Calling the ordinary Object thunk +/// would instead coerce and return the first argument, binding an unrelated +/// object as the derived `this` and losing its class prototype and brand. +unsafe fn construct_object_with_new_target(new_target: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let new_target = scope.root_nanbox_f64(new_target); + let instance_cid = new_target_class_id(new_target.get_nanbox_f64()) + .unwrap_or_else(|| synthetic_class_id_for_function(new_target.get_nanbox_f64())); + let instance = + if let Some((keys_array, field_count)) = registered_class_keys_array(instance_cid) { + js_object_alloc_class_inline_keys(instance_cid, 0, field_count, keys_array) + } else { + js_object_alloc( + instance_cid, + crate::object::learned_inline_field_count(instance_cid), + ) + }; + let instance = scope.root_raw_mut_ptr(instance); + let prototype = new_target_custom_object_prototype(new_target.get_nanbox_f64()) + .or_else(global_object_prototype_bits) + .map(|bits| scope.root_heap_word_u64(bits)); + let new_target_value = new_target.get_nanbox_f64(); + if is_class_object_value(new_target_value) { + instance.with_mut_ptr::(|instance| { + super::super::field_get_set::stamp_private_evaluation_brand( + instance, + new_target_value, + ) + }); + } + if let Some(prototype) = prototype { + instance.with_mut_ptr::(|instance| { + super::super::prototype_chain::object_set_static_prototype( + instance as usize, + prototype.get_heap_word_u64(), + ) + }); + } + instance.with_mut_ptr::(|i| crate::value::js_nanbox_pointer(i as i64)) +} diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 047a4d3e90..d5c41c34bb 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -156,9 +156,67 @@ per_test_global! { std::sync::atomic::AtomicU32::new(0x8000_0000); } -/// Register a function's prototype object. Called by codegen-emitted -/// init code whenever the HIR detects `.prototype = ` at -/// the assignment-statement level (lower_expr_assignment Member arm). +/// Perform ordinary `.prototype` assignment, then synchronize the synthetic +/// class metadata used when a class extends a function (#711, #9365). +#[no_mangle] +pub extern "C" fn js_set_prototype_property(receiver: f64, value: f64, strict: i32) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let value = scope.root_nanbox_f64(value); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + b"prototype".as_ptr(), + 9, + )); + let key = key + .with_const_ptr::(|key| crate::value::js_nanbox_string(key as i64)); + crate::proxy::js_put_value_set( + receiver.get_nanbox_f64(), + key, + value.get_nanbox_f64(), + receiver.get_nanbox_f64(), + strict, + ); + + // Synchronize from the actual own property. A sloppy rejected write or an + // accessor must not install the attempted RHS as a class prototype, and + // synchronization must not reset the property's descriptor attributes. + // These probes and side-table updates have no JS/GC safepoints; the receiver + // keeps its own prototype live throughout. Ownership is checked before any + // header read so proxies and other synthetic pointer values are harmless. + let func = receiver.get_nanbox_f64(); + let func_value = JSValue::from_bits(func.to_bits()); + if func_value.is_pointer() { + let func_ptr = func_value.as_pointer::() as usize; + let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(func_ptr) }; + if header + .is_some_and(|header| unsafe { header.as_ref().obj_type == crate::gc::GC_TYPE_CLOSURE }) + && get_accessor_descriptor(func_ptr, "prototype").is_none() + { + if let Some(proto) = crate::closure::closure_get_own_dynamic_prop(func_ptr, "prototype") + { + let proto = JSValue::from_bits(proto.to_bits()); + if proto.is_pointer() { + let proto_ptr = proto.as_pointer::() as *mut ObjectHeader; + let header = unsafe { + crate::value::addr_class::try_read_tracked_gc_header(proto_ptr as usize) + }; + if header.is_some_and(|header| unsafe { + header.as_ref().obj_type == crate::gc::GC_TYPE_OBJECT + }) { + let class_id = synthetic_class_id_for_function(func); + class_prototype_object_root_store(class_id, proto_ptr); + crate::typed_feedback::invalidate_method_change(class_id); + crate::object::prop_plan::prop_plan_epoch_bump(); + } + } + } + } + } + value.get_nanbox_f64() +} + +/// Legacy function-prototype registration ABI. New codegen uses +/// `js_set_prototype_property` to preserve ordinary property semantics. /// /// Returns the synthetic class_id allocated for this function (0 if /// validation fails). The synthetic id is folded into CLASS_REGISTRY diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index ddde8fd752..f8aeb554f9 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -1487,9 +1487,8 @@ mod sso_tests_1781 { } } -/// Gate for O(1) tombstone deletes (`PERRY_OBJECT_TOMBSTONES=1`). Default OFF -/// while the walker audit and differentials bake; the sibling Map tombstones -/// (#9020) shipped default-on after the same sequence. +/// Gate for O(1) tombstone deletes (`PERRY_OBJECT_TOMBSTONES`). +/// The default and its rationale live beside the environment parsing below. fn object_tombstone_deletes_enabled() -> bool { // Test override first: the OnceLock latches at the FIRST delete anywhere // in the test process, which is long before a tombstone test's own diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 6e8d91ed66..8c477cdb50 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -623,9 +623,7 @@ pub(crate) unsafe fn primitive_builtin_prototype_property( // inside `invoke_accessor_getter` — not the prototype object the accessor // happens to live on (which a plain field read below would hand it). if crate::state::state().descriptors.accessors_in_use.get() { - let key_ptr = (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) { + if let Some(name) = crate::string::header_str_checked(key) { if let Some(acc) = get_accessor_descriptor(proto_ptr as usize, name) { if acc.get == 0 { return Some(JSValue::undefined()); @@ -678,9 +676,7 @@ pub(crate) unsafe fn array_subclass_prototype_field( { return None; } - let key_ptr = crate::object::string_header_payload(key); - let key_len = (*key).byte_len as usize; - let name = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)).ok()?; + let name = crate::string::header_str_checked(key)?; // `array_prototype_property_value` copies `name` before its first // allocation and roots the receiver across the prototype lookup. array_prototype_property_value(name, obj as usize) diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 2066cbb217..59d47729e2 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -106,7 +106,7 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { if let Some(parent_proto) = parent_proto { let parent_proto = scope.root_heap_word_u64(parent_proto); proto.with_mut_ptr::(|proto| { - super::super::prototype_chain::object_set_static_prototype( + super::super::prototype_chain::object_link_class_evaluation_prototype( proto as usize, parent_proto.get_heap_word_u64(), ) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index b13d3ae8fd..242f726e75 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -610,6 +610,17 @@ pub extern "C" fn js_object_get_field_by_name( { return JSValue::from_bits(v.to_bits()); } + // Fresh nested Promise subclasses inherit the same reified + // statics as ClassRef receivers (`P.all`, `P.resolve`, ...). + if super::super::promise_parent_in_chain(class_id) + && super::super::promise_static_function_spec(name).is_some() + { + let value = + super::super::js_promise_static_function_value(name_ptr, name_len); + if value.to_bits() != crate::value::TAG_UNDEFINED { + return JSValue::from_bits(value.to_bits()); + } + } } } } diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs index 427f7c4832..0120b49a3c 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs @@ -7,7 +7,7 @@ pub(crate) fn async_resource_property( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> Option { - if key.is_null() { + if key.is_null() || !crate::async_hooks::is_async_resource_handle(obj as i64) { return None; } let key = unsafe { crate::string::OwnedStringBytes::copy_from_header(key) }; diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 783b27021d..a81839c513 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1387,9 +1387,7 @@ pub(crate) unsafe fn closure_dynamic_prop_by_key( if key.is_null() { return None; } - let key_ptr = (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let name = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)).ok()?; + let name = crate::string::header_str_checked(key)?; let val = crate::closure::closure_get_dynamic_prop(obj, name); if val.to_bits() != crate::value::TAG_UNDEFINED { return Some(val); diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index be3027adb6..e4c6020c66 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -476,23 +476,56 @@ pub extern "C" fn js_object_get_field_ic_overflow_load( /// Overflow fields (slot >= alloc_limit) are NOT cached and fall through to /// the slow path — the fast path loads from `obj_ptr + 24 + slot*8` which /// would read past the inline allocation. +/// `PERRY_IC_DIAG`: record why this miss took the arm it took. `key` may be +/// null on the earliest exits. +#[inline(never)] +#[cold] +fn ic_diag_note( + cache_slot: *mut PicCacheSlot, + key: *const crate::StringHeader, + reason: crate::hot_diag::IcMissReason, +) { + // #9219 class: a bare `< 0x1000` floor admits the whole handle band, which + // holds REAL addresses on Linux (mmap base 0x1000) while macOS's higher base + // hides it. Ask the canonical predicate instead — this is a `#[cold]` + // diagnostic path, so the extra check costs nothing on the hot IC route. + let bytes: &[u8] = if key.is_null() + || !crate::value::addr_class::is_above_handle_band(key as usize) + { + b"" + } else { + unsafe { + std::slice::from_raw_parts(crate::string::string_data(key), (*key).byte_len as usize) + } + }; + crate::hot_diag::ic_note(cache_slot as usize, bytes, reason); +} + #[no_mangle] pub extern "C" fn js_object_get_field_ic_miss( obj: *const ObjectHeader, key: *const crate::StringHeader, cache_slot: *mut PicCacheSlot, ) -> f64 { + use crate::hot_diag::IcMissReason as R; + let diag = crate::hot_diag::ic_on(); // SSO receiver — never cacheable. Route through the SSO-aware // `js_object_get_field_by_name` which handles `.length` inline // and returns undefined for other keys. if !key.is_null() { let obj_bits = obj as u64; if (obj_bits & crate::value::TAG_MASK) == crate::value::SHORT_STRING_TAG { + if diag { + ic_diag_note(cache_slot, key, R::SsoReceiver); + } let v = js_object_get_field_by_name(obj, key); return f64::from_bits(v.bits()); } } if obj.is_null() || key.is_null() { + if diag { + ic_diag_note(cache_slot, key, R::NullArgs); + } return f64::from_bits(crate::value::TAG_UNDEFINED); } // A Proxy value may reach the inline-cache miss handler when a fused @@ -511,6 +544,9 @@ pub extern "C" fn js_object_get_field_ic_miss( const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; let boxed = f64::from_bits(POINTER_TAG | (addr & 0x0000_FFFF_FFFF_FFFF)); if crate::proxy::js_proxy_is_proxy(boxed) != 0 { + if diag { + ic_diag_note(cache_slot, key, R::Proxy); + } let key_f64 = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); return crate::proxy::js_proxy_get(boxed, key_f64); } @@ -526,14 +562,15 @@ pub extern "C" fn js_object_get_field_ic_miss( // `< 0x100000` proxy / HANDLE_PROPERTY_DISPATCH routing below — matching // the ordering in `js_object_get_field_by_name`. The macOS heap floor // (0x200_0000_0000 in is_valid_obj_ptr) masked this; Linux's is 0x1000. - if !key.is_null() { + if !key.is_null() && crate::async_hooks::is_async_resource_handle(obj as i64) { unsafe { - let key_ptr = crate::string::string_data(key); - let key_len = (*key).byte_len as usize; - if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) { + if let Some(name) = crate::string::header_str_checked(key) { if let Some(value) = crate::async_hooks::try_async_resource_property_dispatch(obj as i64, name) { + if diag { + ic_diag_note(cache_slot, key, R::AsyncResource); + } return value; } } @@ -572,6 +609,9 @@ pub extern "C" fn js_object_get_field_ic_miss( if let Some(value) = unsafe { crate::array::subclass_elements::get_by_key(elements, elements_key) } { + if diag { + ic_diag_note(cache_slot, key, R::SubclassElements); + } return value; } } @@ -579,6 +619,9 @@ pub extern "C" fn js_object_get_field_ic_miss( if unsafe { key_bytes_are(key, b"length") } { match unsafe { gc_type_of(obj) } { Some(crate::gc::GC_TYPE_ARRAY) => { + if diag { + ic_diag_note(cache_slot, key, R::ArrayLength); + } let arr = obj as *const crate::array::ArrayHeader; return crate::array::js_array_length(arr) as f64; } @@ -594,6 +637,9 @@ pub extern "C" fn js_object_get_field_ic_miss( // lookup below for every case it cannot prove. let receiver = crate::value::js_nanbox_pointer(obj as i64); if let Some(length) = crate::array::array_subclass_fast_length(receiver) { + if diag { + ic_diag_note(cache_slot, key, R::ArrayLength); + } return length; } } @@ -602,16 +648,25 @@ pub extern "C" fn js_object_get_field_ic_miss( } unsafe { if let Some(val) = closure_dynamic_prop_by_key(obj as usize, key) { + if diag { + ic_diag_note(cache_slot, key, R::ClosureProp); + } return val; } // Buffers have no GcHeader. The generic IC-miss object path below may // inspect GC/object metadata, so mirror js_object_get_field_by_name's // buffer-first dispatch here. if crate::buffer::is_registered_buffer(obj as usize) { + if diag { + ic_diag_note(cache_slot, key, R::Buffer); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } if crate::typedarray::lookup_typed_array_kind(obj as usize).is_some() { + if diag { + ic_diag_note(cache_slot, key, R::TypedArray); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } @@ -626,6 +681,9 @@ pub extern "C" fn js_object_get_field_ic_miss( // dispatch to the per-module accessor instead of silently // returning undefined. if crate::value::addr_class::is_small_handle(obj as usize) { + if diag { + ic_diag_note(cache_slot, key, R::SmallHandle); + } // #2846: a revocable Proxy is encoded as a small fake pointer in the // proxy-id range (also `< 0x100000`). A generic `proxy.key` read funnels // here via the IC-miss path; route it to the proxy get dispatch (which @@ -720,8 +778,12 @@ pub extern "C" fn js_object_get_field_ic_miss( return f64::from_bits(crate::value::TAG_UNDEFINED); } if (obj as usize) < 0x10000 { + if diag { + ic_diag_note(cache_slot, key, R::SmallHandle); + } return f64::from_bits(crate::value::TAG_UNDEFINED); } + let mut miss_reason = R::NotOwn; unsafe { // Issue #72: validate this really is a GC_TYPE_OBJECT before reading // crate::object::object_keys_array(obj) — otherwise an Array/String/Buffer/etc. receiver @@ -760,6 +822,15 @@ pub extern "C" fn js_object_get_field_ic_miss( let is_regular = shape.is_some_and(|shape| { shape.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary }); + if diag { + miss_reason = if !is_object { + R::NonObjectGcType + } else if !is_regular { + R::ObjectIrregular + } else { + R::NotOwn + }; + } // Descriptor-bearing receivers ordinarily must not prime a raw-load // PIC. One narrow exception is an object-backed Array subclass whose // complete class-declared prefix has been proved data-only: its @@ -774,6 +845,9 @@ pub extern "C" fn js_object_get_field_ic_miss( }; let keys = shape.keys as usize as *mut crate::array::ArrayHeader; if keys.is_null() || (keys as usize) <= 0x10000 { + if diag { + ic_diag_note(cache_slot, key, R::ObjectNoKeys); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } @@ -813,12 +887,16 @@ pub extern "C" fn js_object_get_field_ic_miss( token, (i as u32 | crate::proxy::IC_SLOT_OVERFLOW_BIT) as i64, ); + if diag { + ic_diag_note(cache_slot, key, R::OwnOverflowPrimed); + } return f64::from_bits(bits); } } } // Field is in the overflow map — fall through to the // slow path which handles overflow correctly. + miss_reason = R::OwnDescriptorFallthrough; break; } // The codegen IC fast path computes `obj + object_header_size + slot*8` @@ -854,11 +932,15 @@ pub extern "C" fn js_object_get_field_ic_miss( 0 }; if has_own_descriptors && named_prefix_token == 0 { + miss_reason = R::OwnDescriptorFallthrough; break; } let cache = pic_slot_resolve(cache_slot); (*cache)[2] = named_prefix_token; pic_prime_get(cache, token, i as i64); + if diag { + ic_diag_note(cache_slot, key, R::OwnInlinePrimed); + } let field_ptr = (obj as *const u8) .add(std::mem::size_of::() + i * 8) as *const f64; @@ -867,6 +949,9 @@ pub extern "C" fn js_object_get_field_ic_miss( } } } + if diag { + ic_diag_note(cache_slot, key, miss_reason); + } let value = js_object_get_field_by_name(obj, key); f64::from_bits(value.bits()) } diff --git a/crates/perry-runtime/src/object/field_get_set/prototype_override.rs b/crates/perry-runtime/src/object/field_get_set/prototype_override.rs index 97b48f07c7..d65b9685e4 100644 --- a/crates/perry-runtime/src/object/field_get_set/prototype_override.rs +++ b/crates/perry-runtime/src/object/field_get_set/prototype_override.rs @@ -23,9 +23,9 @@ use crate::value::JSValue; /// /// This is the same polarity `canonical_shape_excludes_own_property` uses: a /// question we cannot answer here defers to the tail rather than fabricating a -/// verdict. The dedicated user-origin signal excludes internal runtime wiring, -/// even when that wiring uses the loud setter and its conservative cache -/// invalidations. +/// verdict. Evaluated class prototypes also take this path (#9502): their +/// heritage can differ between evaluations of one template. Other internal +/// runtime wiring retains its existing fallback behavior. /// /// `None` therefore means either no override, or an override that does not /// carry this key — in both cases the caller keeps its existing fallback. @@ -36,7 +36,7 @@ pub(super) fn inherited_field_if_overridden( if key.is_null() { return None; } - if !crate::object::prototype_chain::object_has_user_prototype_override(obj as usize) { + if !crate::object::prototype_chain::object_has_individual_class_prototype(obj as usize) { return None; } crate::object::prototype_chain::resolve_inherited_field(obj as usize, key) 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 867d43e1fe..2051ae5807 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -826,7 +826,28 @@ pub unsafe extern "C" fn js_fetch_or_value_super( if cid == 0 { return undef; } - let new_target = crate::object::class_constructor_ref_value(cid); + // A per-evaluation class object is its own newTarget. Collapsing it + // to the shared template ClassRef loses that evaluation's + // prototype and private brand when the builtin allocates a + // replacement receiver (#9503). Static/inlined construction does + // not always publish the runtime cell, so retain the ClassRef + // fallback when no matching dynamic newTarget is active. + let active_new_target = crate::object::js_new_target_get(); + let active_matches = crate::object::class_ref_id(active_new_target) == Some(cid) + || if super::super::class_registry::is_class_object_value(active_new_target) { + let class_object = + crate::value::JSValue::from_bits(active_new_target.to_bits()) + .as_pointer::(); + !class_object.is_null() + && crate::object::js_object_get_class_id(class_object) == cid + } else { + false + }; + let new_target = if active_matches { + active_new_target + } else { + crate::object::class_constructor_ref_value(cid) + }; return crate::object::js_new_function_construct_with_new_target( parent_val, args_ptr, args_len, new_target, ); @@ -923,7 +944,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // `tag`. Use the class-object replay path so this // exact parent's `__perry_ctor_caps` supplies its // synthesized capture params. - super::super::class_constructors::replay_class_object_constructor( + return super::super::class_constructors::replay_class_object_constructor( parent_val, parent_cid, obj, args_ptr, args_len, ); } diff --git a/crates/perry-runtime/src/object/has_own_helpers.rs b/crates/perry-runtime/src/object/has_own_helpers.rs index bd6b1d185c..83de47f064 100644 --- a/crates/perry-runtime/src/object/has_own_helpers.rs +++ b/crates/perry-runtime/src/object/has_own_helpers.rs @@ -72,10 +72,7 @@ unsafe fn string_header_as_str<'a>(key: *const crate::StringHeader) -> Option<&' if key.is_null() { return None; } - let len = (*key).byte_len as usize; - let data = (key as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - std::str::from_utf8(bytes).ok() + crate::string::header_str_checked(key) } pub(super) unsafe fn string_primitive_own_key_present( diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index c42eea5afe..8c537cacbc 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -252,16 +252,22 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { return js_instanceof(value, class_id); } } - // #1789: `x instanceof C` where C is a heap class object (the value a - // class EXPRESSION evaluates to, e.g. `const C = make(x); c instanceof - // C`). Read its class_id (the compile-time template) and walk the - // candidate's class chain against it. + // #9502: a heap class object's template id identifies its code, not its + // evaluation. Compare the actual prototype objects so sibling evaluations + // remain distinct and a chain through earlier evaluations still matches. if is_class_object_value(type_ref) { - let obj = crate::JSValue::from_bits(bits).as_pointer::(); - let class_id = js_object_get_class_id(obj); - if class_id != 0 { - return js_instanceof(value, class_id); - } + // Static/forward `new C()` sites can still construct by template id + // without attaching an evaluated prototype. Retain that representation's + // class-id check; recorded individual chains are authoritative. + if !super::prototype_chain::object_has_prototype_divergence(value_addr(value)) { + let obj = crate::JSValue::from_bits(bits).as_pointer::(); + return js_instanceof(value, js_object_get_class_id(obj)); + } + return f64::from_bits(if ordinary_has_instance_prototype_walk(value, type_ref) { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); } // A builtin constructor held in a VARIABLE — `const RS = ReadableStream; body // instanceof RS` — arrives here as the ClosureHeader-backed function installed diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 3d8bc8563a..f598712f9b 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1640,6 +1640,7 @@ pub struct ObjectMeta { pub(crate) const OBJECT_META_FLAG_PROTO_DIVERGED: u64 = 1; pub(crate) const OBJECT_META_FLAG_USER_PROTO_OVERRIDE: u64 = 1 << 3; +pub(crate) const OBJECT_META_FLAG_CLASS_EVALUATION_PROTO: u64 = 1 << 4; /// Authoritative ordinary-object discriminator. RegExp has its own GC kind, /// and heap class-expression values carry their kind in the immutable ShapeId diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 1e340f88c7..05d32b1a39 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1307,11 +1307,13 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // property lookup before any class/native dispatch: that lookup preserves // own-property precedence and, for a miss, the per-instance chain is // authoritative rather than falling back to the original class vtable. + // #9502: an evaluated class's chain has the same precedence because two + // instances with the same template id can inherit different parent methods. if jsval().is_pointer() { let candidate = jsval().as_pointer::() as usize; if crate::value::addr_class::is_above_handle_band(candidate) && crate::object::is_valid_obj_ptr(candidate as *const u8) - && super::prototype_chain::object_has_user_prototype_override(candidate) + && super::prototype_chain::object_has_individual_class_prototype(candidate) { let method_key = crate::string::js_string_from_bytes(method_name.as_ptr(), method_name.len() as u32); diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index 39f7e6d8bb..47ac13f0c5 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -325,6 +325,37 @@ pub(super) unsafe fn dispatch_primitive( args.len(), )); } + // #9502: a fresh Promise subclass inherits reified builtin statics. + // Read the property first so own fields/accessors keep their precedence, + // then call with the evaluated subclass as the capability constructor. + if crate::object::promise_parent_in_chain(class_id) + && crate::object::promise_static_function_spec(method_name).is_some() + { + let key = crate::string::js_string_from_bytes( + method_name_ptr as *const u8, + method_name_len as u32, + ); + let receiver = JSValue::from_bits(object_handle.get_nanbox_f64().to_bits()) + .as_pointer::(); + let method = js_object_get_field_by_name(receiver, key); + if method.is_pointer() + && crate::closure::is_closure_ptr(crate::value::js_nanbox_get_pointer( + f64::from_bits(method.bits()), + ) as usize) + { + let method = root_scope.root_nanbox_u64(method.bits()); + let receiver = object_handle.get_nanbox_f64(); + let bound = + crate::closure::clone_closure_rebind_this(method.get_nanbox_u64(), receiver); + let _this = ImplicitThisScope::bind(object_handle.get_nanbox_f64()); + let args = refreshed_args(); + return Some(crate::closure::js_native_call_value( + f64::from_bits(bound), + args.as_ptr(), + args.len(), + )); + } + } } // #5142: a promise can carry user-attached own expando methods. diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 3f9a534909..e90820f12b 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -13,6 +13,7 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(3), ("bun.ant", "getPeerPid" | "getPeerUid") => Some(1), ("bun.ant", "memoryPressureLevel") => Some(0), + ("bun:jsc", "heapStats") => Some(0), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), ("bun:ffi", "ptr" | "CString" | "CFunction" | "linkSymbols") => Some(1), @@ -338,6 +339,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("viewSource", 2), ], ), + ("bun:jsc", &[("heapStats", 0)]), ("child_process", &[("_forkChild", 2)]), ( "cluster", diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 2d8710d4df..1525b75ef4 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -67,6 +67,9 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st if module == "bun.ant" && matches!(prop, "getPeerPid" | "getPeerUid" | "memoryPressureLevel") { return true; } + if module == "bun:jsc" && prop == "heapStats" { + return true; + } // bun:ffi (#6562). `FFIType` and `suffix` are constants, not callables; // the not-yet-supported exports are callable so they throw their // stage-1 error instead of "undefined is not a function". diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 6466442f48..c361cd4726 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -126,6 +126,7 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "viewSource", ], ), + ("bun:jsc", &["heapStats"]), ( "child_process", &[ diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index eaefbd9fa9..bb6a4c914e 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1628,6 +1628,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati "sqlite.constants" => Some(SQLITE_CONSTANTS_KEYS), // #9599: the opt-in globalThis.Bun object and `import * as bun from // "bun"` share this one enumerable native-module surface. + "bun:jsc" => Some(&[b"heapStats"]), "bun" => Some(&[ b"Glob", b"JSONL", diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index 19dc94b4bc..b1c5d8d864 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -272,6 +272,7 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name typed_kind ); match (module_name, method_name) { + ("bun:jsc", "heapStats") => crate::bun_compat::js_bun_jsc_heap_stats(arg(0)), ("bun", "spawn") => crate::bun_compat::js_bun_spawn(arg(0), arg(1)), ("bun", "Terminal") => crate::bun_compat::js_bun_terminal_new(arg(0)), ("bun", "serve") => { diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 7441476847..7cb3ed658f 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -79,7 +79,7 @@ fn nm_module_index(name: &str) -> Option { "async_hooks" => Some(NmBucket::AsyncHooks), "bigint" => Some(NmBucket::Bigint), "buffer" | "buffer.Buffer" => Some(NmBucket::Buffer), - "bun" | "bun.ant" => Some(NmBucket::Bun), + "bun" | "bun.ant" | "bun:jsc" => Some(NmBucket::Bun), // #6562: the `bun:` prefix is part of the name (not stripped like // `node:`). "bun:ffi" | "ffi" | "ffi.default" => Some(NmBucket::BunFfi), diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 89a7694adb..2dc1109e76 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -531,6 +531,22 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { } return function_prototype_or_null(); } + // #9502: a fresh class value is a constructor, not an instance + // of its template. Its [[Prototype]] is the evaluated parent; + // `.prototype` is a separate object with a separate chain. + if super::super::class_registry::is_class_object_ptr(obj as *const u8) { + if let Some(parent) = + super::super::class_registry::class_object_pinned_parent(obj) + { + if !matches!( + parent.to_bits(), + crate::value::TAG_NULL | crate::value::TAG_UNDEFINED + ) { + return parent; + } + } + return function_prototype_or_null(); + } // Fast [[Prototype]] for a DECLARED-class instance: resolve // directly from the class id instead of the generic // `constructor_dynamic_prototype` probe, which reads the diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index b080b38544..ba3b1d3ca9 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -157,6 +157,7 @@ pub(crate) unsafe fn meta_capable_object(obj_ptr: usize) -> Option<*mut crate::O #[derive(Clone, Copy, PartialEq, Eq)] enum PrototypeLinkKind { ClassDefault, + ClassEvaluation, RuntimeWiring, UserOverride, } @@ -189,6 +190,13 @@ pub(crate) fn object_link_class_default_prototype(obj_ptr: usize, proto_bits: u6 object_set_static_prototype_impl(obj_ptr, proto_bits, PrototypeLinkKind::ClassDefault) } +/// An evaluated class and its instances share a prototype within that +/// evaluation, but not with other evaluations of the same template (#9502). +/// Preserve that distinction for property/method lookup and class-keyed caches. +pub(crate) fn object_link_class_evaluation_prototype(obj_ptr: usize, proto_bits: u64) { + object_set_static_prototype_impl(obj_ptr, proto_bits, PrototypeLinkKind::ClassEvaluation) +} + fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: PrototypeLinkKind) { let prototype_diverged = link_kind != PrototypeLinkKind::ClassDefault; let user_override = link_kind == PrototypeLinkKind::UserOverride; @@ -213,13 +221,16 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // A per-instance prototype override invalidates class-keyed interception // verdicts (the overridden chain can differ from the class chain), and the // object itself must never satisfy a class-keyed plan again. - if prototype_diverged { + if matches!( + link_kind, + PrototypeLinkKind::RuntimeWiring | PrototypeLinkKind::UserOverride + ) { crate::object::prop_plan::prop_plan_epoch_bump(); // #7480: a `[[Prototype]]` swap on a live instance is prototype // surgery — the same class of event as writing onto `C.prototype`, so // it retires every outstanding element-shape proof. Deliberately - // inside the `prototype_diverged` gate: the quiet sibling - // (`object_link_class_default_prototype`) fires on every `new F()`. + // restricted to changes of existing chains: fresh default/evaluation + // links cannot invalidate a proof about a previously allocated object. crate::array::invalidate_all_element_shapes(); } // #6759 Phase B: shaped objects store the recorded prototype in their @@ -244,6 +255,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: if user_override { (*meta).flags |= crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE; } + if link_kind == PrototypeLinkKind::ClassEvaluation { + (*meta).flags |= crate::object::OBJECT_META_FLAG_CLASS_EVALUATION_PROTO; + } // GC_STORE_AUDIT(BARRIERED): meta-record prototype slot store — // the record is an arena allocation, so the ordinary object-slot // barrier applies (parent = the meta record). @@ -340,6 +354,18 @@ pub(crate) fn object_has_user_prototype_override(obj_ptr: usize) -> bool { object_has_prototype_flag(obj_ptr, crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE) } +/// Whether ordinary property lookup must consult the receiver's own chain +/// before the shared class vtable: user overrides and evaluated classes both +/// have this requirement; unrelated runtime prototype wiring does not. +#[inline] +pub(crate) fn object_has_individual_class_prototype(obj_ptr: usize) -> bool { + object_has_prototype_flag( + obj_ptr, + crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE + | crate::object::OBJECT_META_FLAG_CLASS_EVALUATION_PROTO, + ) +} + pub(crate) fn default_object_prototype_bits() -> Option { let object_ctor = super::js_get_global_this_builtin_value(b"Object".as_ptr(), 6); let ctor_bits = object_ctor.to_bits(); @@ -647,6 +673,18 @@ mod tests { ); assert!(!object_has_prototype_divergence(class_default as usize)); + let evaluated = crate::object::js_object_alloc(0, 0); + object_link_class_evaluation_prototype(evaluated as usize, crate::value::TAG_NULL); + assert!(object_has_prototype_divergence(evaluated as usize)); + assert!(object_has_individual_class_prototype(evaluated as usize)); + assert!(!object_has_user_prototype_override(evaluated as usize)); + assert!(!object_has_individual_class_prototype( + class_default as usize + )); + assert!(!object_has_individual_class_prototype( + runtime_wired as usize + )); + let user_overridden = crate::object::js_object_alloc(0, 0); object_set_user_prototype(user_overridden as usize, crate::value::TAG_NULL); let user_meta = unsafe { (*user_overridden).meta }; diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 1975eedc69..66cc23fc2b 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1469,7 +1469,10 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { // The null-receiver guard stays BEFORE the coercion: `key_to_rust_string` // can run a user `toString`, and moving it earlier would make that side // effect observable on a path that previously short-circuited. - if extract_pointer(target.to_bits()) as usize == 0 { + // ClassRef constructors are non-pointer values with an own prototype. + if extract_pointer(target.to_bits()) as usize == 0 + && crate::object::class_ref_id(target).is_none() + { return None; } // #6943: `key_to_rust_string` runs the GC-capable `js_string_coerce`, and @@ -1485,6 +1488,14 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { let target_handle = scope.root_heap_word_u64(target.to_bits()); let key_name = key_to_rust_string(key)?; let target = f64::from_bits(target_handle.get_heap_word_u64()); + // Class constructors have an immutable own prototype even though their + // ClassRef representation has no heap address or descriptor side table. + if key_name == "prototype" + && crate::object::class_ref_id(target).is_some() + && crate::object::class_prototype_ref_id(target).is_none() + { + return Some(OwnSetDescriptor::Data { writable: false }); + } let obj_ptr = extract_pointer(target.to_bits()) as usize; if obj_ptr == 0 { return None; @@ -1532,6 +1543,16 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { } if crate::closure::is_closure_ptr(obj_ptr) { if crate::object::has_own_helpers::closure_own_key_present(obj_ptr, &key_name) { + // A function's lazily synthesized prototype is already an own + // property. Preserve its attributes when the first operation is + // an assignment, before any read materializes the default object. + if key_name == "prototype" && crate::object::function_would_have_own_prototype(target) { + crate::object::set_builtin_property_attrs( + obj_ptr, + key_name.clone(), + crate::object::PropertyAttrs::new(true, false, false), + ); + } return Some(OwnSetDescriptor::Data { writable: !matches!(key_name.as_str(), "name" | "length"), }); diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index bfa75950ab..714a4f81c6 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -6,9 +6,8 @@ #[cfg(feature = "regex-engine")] use regex::Regex; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ptr; -#[cfg(feature = "regex-engine")] use std::sync::Arc; #[cfg(feature = "regex-engine")] @@ -36,6 +35,8 @@ mod escape; #[cfg(feature = "regex-engine")] mod exec_array; #[cfg(feature = "regex-engine")] +mod flags; +#[cfg(feature = "regex-engine")] mod global_scan; #[cfg(feature = "regex-engine")] mod grammar; @@ -49,6 +50,8 @@ mod repeat_matcher; mod replace_expand; mod replace_fn; #[cfg(feature = "regex-engine")] +mod site_cache; +#[cfg(feature = "regex-engine")] mod unicode17; #[cfg(feature = "regex-engine")] mod unicode17_data; @@ -57,6 +60,7 @@ mod utf16; use class_range_validate::has_out_of_order_double_dash_class_range; #[cfg(feature = "regex-engine")] pub use compile::js_regexp_compile_value; +use escape::escape_regexp_source; pub use escape::js_regexp_escape; #[cfg(feature = "regex-engine")] use exec_array::{ @@ -64,6 +68,8 @@ use exec_array::{ set_exec_array_metadata_value, utf16_index_to_byte, OwnedCapture, OwnedExecMatch, }; #[cfg(feature = "regex-engine")] +use flags::validate_and_canonicalize_flags; +#[cfg(feature = "regex-engine")] use grammar::{ collapse_redos_guard_quantifiers, has_invalid_repeated_quantifier, has_unicode_forbidden_legacy_escape, has_unicode_forbidden_pattern, js_regex_to_rust, @@ -112,7 +118,7 @@ crate::perry_thread_local! { /// delimiter from a string delimiter when the codegen can't tell /// statically. GC move/death hooks rekey and remove entries as cells /// relocate or die. Header magic remains the primary identity check. - static REGEX_POINTERS: RefCell> = RefCell::new(HashSet::new()); + static REGEX_POINTERS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_set()); /// Issue #637: Owned copies of pattern and flags strings keyed by /// the RegExpHeader pointer. The header's `pattern_ptr` / `flags_ptr` @@ -123,7 +129,12 @@ crate::perry_thread_local! { /// `.flags` reads dereference dangling memory. We side-table an /// owned `String` copy at construction time; readers prefer this /// over `pattern_ptr` whenever an entry exists. - static REGEX_SOURCE_TABLE: RefCell> = RefCell::new(HashMap::new()); + /// + /// The copies are `Arc` shared with `regex::site_cache`: every + /// header built from the same literal text bumps two refcounts instead + /// of copying the pattern (12 KB for emoji-class patterns, once per + /// evaluation of the literal). + static REGEX_SOURCE_TABLE: RefCell, Arc)>> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } /// Check whether `ptr` is a RegExpHeader pointer that was allocated in @@ -279,7 +290,7 @@ pub(crate) fn test_alloc_nursery_regexp_for_move(source: &str, flags: &str) -> * REGEX_SOURCE_TABLE.with(|table| { table .borrow_mut() - .insert(ptr as usize, (source.to_string(), flags.to_string())); + .insert(ptr as usize, (Arc::from(source), Arc::from(flags))); }); ptr } @@ -464,6 +475,9 @@ const REGEX_CACHE_MAX_ENTRIES: usize = 512; fn evict_regex_cache_if_full(cache: &mut HashMap<(String, String), V>) { if cache.len() >= REGEX_CACHE_MAX_ENTRIES { cache.clear(); + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.cache_clears += 1); + } } } @@ -496,6 +510,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { return true; } if let Some(repeat_matcher) = repeat_matcher::compile(pattern, flags) { + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_repeat += 1); + } REPEAT_MATCHER_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); evict_regex_cache_if_full(&mut cache); @@ -520,6 +537,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { // callers don't crash. let fancy_ok = FANCY_CACHE.with(|fc| { if let Ok(fre) = build_fancy_regex(®ex_pattern) { + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_fancy += 1); + } let mut fc = fc.borrow_mut(); evict_regex_cache_if_full(&mut fc); fc.insert( @@ -537,6 +557,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { Regex::new(r"[^\s\S]").unwrap() } }; + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_std += 1); + } REGEX_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); evict_regex_cache_if_full(&mut cache); @@ -771,53 +794,12 @@ fn ensure_replace_all_regex_global(re: *const RegExpHeader) { /// Throw a `SyntaxError` with the given message and never return. #[cfg(feature = "regex-engine")] -fn throw_regexp_syntax_error(message: &str) -> ! { +pub(super) fn throw_regexp_syntax_error(message: &str) -> ! { let msg = js_string_from_str(message); let err = crate::error::js_syntaxerror_new(msg); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } -/// #2829: validate a RegExp flags string the way the spec's -/// `RegExpInitialize` does — each flag must be one of `dgimsuvy` and must not -/// repeat. Returns the flags in canonical (sorted) order, or throws a -/// `SyntaxError` mirroring Node's "Invalid flags supplied to RegExp -/// constructor ''" message. -/// -/// Note: the `v` flag (unicodeSets) is accepted as a valid flag for parity but -/// its set-notation matching semantics are not implemented (the regex crate -/// has no equivalent); it behaves like an ordinary unicode pattern. -#[cfg(feature = "regex-engine")] -fn validate_and_canonicalize_flags(flags: &str) -> String { - // Spec order of the flag bits: d g i m s u v y. - const FLAG_ORDER: &[char] = &['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']; - let mut seen = [false; 8]; - for ch in flags.chars() { - match FLAG_ORDER.iter().position(|&f| f == ch) { - Some(idx) => { - if seen[idx] { - throw_regexp_syntax_error(&format!( - "Invalid flags supplied to RegExp constructor '{}'", - flags - )); - } - seen[idx] = true; - } - None => { - throw_regexp_syntax_error(&format!( - "Invalid flags supplied to RegExp constructor '{}'", - flags - )); - } - } - } - FLAG_ORDER - .iter() - .enumerate() - .filter(|(i, _)| seen[*i]) - .map(|(_, c)| *c) - .collect() -} - /// Create a new RegExp from pattern and flags strings /// Returns a pointer to RegExpHeader /// @@ -872,6 +854,28 @@ pub extern "C" fn js_regexp_new( let unicode = flags_str.contains('u') || flags_str.contains('v'); let has_indices = flags_str.contains('d'); + // Content-keyed construction cache (`regex::site_cache`): a verified hit + // means this exact `(pattern, canonical flags)` already cleared the + // validation below — validity is a pure function of the pair — and hands + // back the shared owned copies plus, once some header built from this + // text has been executed, its compiled programs. The probe is one + // fingerprint and one byte compare; everything below it that copies or + // hashes the pattern is skipped. + let site_hit = site_cache::lookup(pattern_str, flags_str); + let validated_hit = + site_hit.is_some() || lazy::pattern_already_validated(pattern_str, flags_str); + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| { + d.note_new( + pattern as usize, + pattern_str.as_bytes(), + flags_str, + validated_hit && site_hit.is_none(), + site_hit.is_some(), + ) + }); + } + // #2829: reject invalid pattern syntax with a SyntaxError. A pattern the // `regex` crate rejects is only a real error if `fancy-regex` (which // covers the full JS feature set: lookbehind/lookahead/backreferences) @@ -892,7 +896,7 @@ pub extern "C" fn js_regexp_new( // hit, which worked only because construction also COMPILED; with the // build deferred, the fact is recorded directly in `VALIDATED_PATTERNS`. { - if !lazy::pattern_already_validated(pattern_str, flags_str) { + if !validated_hit { if has_invalid_repeated_quantifier(pattern_str) { throw_regexp_syntax_error(&format!( "Invalid regular expression: /{}/: invalid pattern", @@ -976,10 +980,16 @@ pub extern "C" fn js_regexp_new( // ★ Last use of the borrowed pattern text before this function allocates. // `pattern_str` borrows the GC string; the two allocations below can move // it, and everything after this point reads the pattern from `owned_pattern` - // (a Rust `String`, which relocation cannot invalidate) or from + // (a shared `Arc`, which relocation cannot invalidate) or from // `pattern_root` (a runtime handle the collector rewrites). Nothing below // may use `pattern_str` or the incoming `pattern` argument again. - let owned_pattern = pattern_str.to_string(); + let (owned_pattern, owned_flags, programs) = match site_hit { + Some(hit) => (hit.pattern, hit.flags, hit.programs), + None => { + let (p, f) = site_cache::insert(pattern_str, flags_str); + (p, f, None) + } + }; #[allow(unused_variables)] let pattern_str: () = (); @@ -1079,6 +1089,19 @@ pub extern "C" fn js_regexp_new( // a sound built/not-built flag. (*ptr).fancy_ptr = std::ptr::null(); (*ptr).repeat_matcher_ptr = std::ptr::null(); + // Born built: the site cache already holds the programs the first + // execution of this text compiled. Install the same three owned + // references `lazy::build_and_install_programs` would, publishing + // `regex_ptr` last for the same reason it does. + if let Some(programs) = programs { + (*ptr).fancy_ptr = programs + .fancy + .map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); + (*ptr).repeat_matcher_ptr = programs + .repeat + .map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); + (*ptr).regex_ptr = Arc::into_raw(programs.std) as *mut Regex; + } // Record the pointer so that js_string_split can detect // `s.split(regex)` without a dedicated runtime decl. @@ -1092,7 +1115,7 @@ pub extern "C" fn js_regexp_new( // `.source` / `.flags` survive GC of the input StringHeaders. REGEX_SOURCE_TABLE.with(|t| { t.borrow_mut() - .insert(ptr as usize, (owned_pattern.clone(), flags_str.to_string())); + .insert(ptr as usize, (owned_pattern, owned_flags)); }); ptr @@ -1125,7 +1148,7 @@ pub extern "C" fn js_regexp_construct(pattern: f64, flags: f64) -> *mut RegExpHe let re = pv.as_pointer::(); let entry = REGEX_SOURCE_TABLE.with(|t| t.borrow().get(&(re as usize)).cloned()); match entry { - Some((pat, fl)) => (pat, Some(fl)), + Some((pat, fl)) => (pat.to_string(), Some(fl.to_string())), None => (String::new(), Some(String::new())), } } else if pv.is_undefined() { @@ -1240,13 +1263,25 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader let str_data = string_as_str(s); unsafe { + if crate::hot_diag::regex_on() { + diag_note_op(re, crate::hot_diag::RegexOp::Test); + if (*re).global || (*re).sticky { + crate::hot_diag::regex_with(|d| d.test_global += 1); + } + } // For global/sticky regexes `test` is stateful — it must consult and - // advance `lastIndex` (and anchor for sticky) exactly like `exec`. Route - // through `exec` so the lastIndex bookkeeping stays in one place; `test` - // just reports whether a match was produced. + // advance `lastIndex` (and anchor for sticky) exactly like `exec`. The + // find-only twin of `exec`'s engine phase does that bookkeeping without + // materializing a result array: `test` only reports whether a match + // was produced, and building the captures array plus one string per + // capture per call was the allocation `ansi-regex`-style `g` tests + // paid on every text segment. if (*re).global || (*re).sticky { - let arr = js_regexp_exec(re as *mut RegExpHeader, s); - return if arr.is_null() { 0 } else { 1 }; + return if exec::regexp_find_advancing(re as *mut RegExpHeader, s).is_some() { + 1 + } else { + 0 + }; } if let Some(repeat_matcher) = lookup_repeat_matcher(re) { @@ -1273,6 +1308,27 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader } } +/// `PERRY_REGEX_DIAG`: attribute one exec-family operation to the receiver's +/// pattern. Callers have already validated `re`. +#[cfg(feature = "regex-engine")] +pub(super) fn diag_note_op(re: *const RegExpHeader, op: crate::hot_diag::RegexOp) { + unsafe { + let pattern_ptr = (*re).pattern_ptr; + let flags_ptr = (*re).flags_ptr; + let pattern = if is_valid_ptr(pattern_ptr) { + string_as_bytes(pattern_ptr) + } else { + b"" + }; + let flags = if is_valid_ptr(flags_ptr) { + string_as_str(flags_ptr) + } else { + "" + }; + crate::hot_diag::regex_with(|d| d.note_op(pattern_ptr as usize, pattern, flags, op)); + } +} + /// Look up a fancy-regex fallback for the given header, if one was /// registered at compile-time because the `regex` crate rejected the /// pattern (backreferences, lookbehind, etc.). @@ -1288,7 +1344,17 @@ pub(crate) fn lookup_fancy_regex(re: *const RegExpHeader) -> Option *mut StringHeader { js_string_from_str("(?:)") } -/// ECMA-262 22.2.6.10 EscapeRegExpPattern: produce a string that, placed -/// between two `/` characters, parses as the same pattern. An empty pattern -/// becomes `"(?:)"`; an unescaped `/` outside a character class becomes `\/`; -/// the four LineTerminators become their `\n`/`\r`/`
`/`
` escapes -/// (even inside a character class). A backslash escapes the following code -/// point, which is copied verbatim. -fn escape_regexp_source(pattern: &str) -> String { - if pattern.is_empty() { - return "(?:)".to_string(); - } - let mut out = String::with_capacity(pattern.len() + 2); - let mut in_class = false; - let mut chars = pattern.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => { - out.push('\\'); - if let Some(&next) = chars.peek() { - out.push(next); - chars.next(); - } - } - '[' if !in_class => { - in_class = true; - out.push('['); - } - ']' if in_class => { - in_class = false; - out.push(']'); - } - '/' if !in_class => out.push_str("\\/"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\u{2028}' => out.push_str("\\u2028"), - '\u{2029}' => out.push_str("\\u2029"), - _ => out.push(c), - } - } - out -} - /// Get regex.flags — returns the flags string #[no_mangle] pub extern "C" fn js_regexp_get_flags(re: *const RegExpHeader) -> *mut StringHeader { diff --git a/crates/perry-runtime/src/regex/compile.rs b/crates/perry-runtime/src/regex/compile.rs index c1d989b647..6105ebc8da 100644 --- a/crates/perry-runtime/src/regex/compile.rs +++ b/crates/perry-runtime/src/regex/compile.rs @@ -208,10 +208,8 @@ pub extern "C" fn js_regexp_compile_value( (*re).unicode = flags_str.contains('u') || flags_str.contains('v'); (*re).has_indices = flags_str.contains('d'); super::REGEX_SOURCE_TABLE.with(|t| { - t.borrow_mut().insert( - re as usize, - (pattern_str.to_string(), flags_str.to_string()), - ); + t.borrow_mut() + .insert(re as usize, (Arc::from(pattern_str), Arc::from(flags_str))); }); } // Spec RegExpInitialize step 12: `Set(obj, "lastIndex", 0, true)` runs LAST, diff --git a/crates/perry-runtime/src/regex/escape.rs b/crates/perry-runtime/src/regex/escape.rs index e5a2fb8a51..0a3b88acc4 100644 --- a/crates/perry-runtime/src/regex/escape.rs +++ b/crates/perry-runtime/src/regex/escape.rs @@ -142,3 +142,44 @@ pub extern "C" fn js_regexp_escape(input: f64) -> f64 { #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_REGEXP_ESCAPE: extern "C" fn(f64) -> f64 = js_regexp_escape; + +/// ECMA-262 22.2.6.10 EscapeRegExpPattern: produce a string that, placed +/// between two `/` characters, parses as the same pattern. An empty pattern +/// becomes `"(?:)"`; an unescaped `/` outside a character class becomes `\/`; +/// the four LineTerminators become their `\n`/`\r`/`
`/`
` escapes +/// (even inside a character class). A backslash escapes the following code +/// point, which is copied verbatim. +pub(super) fn escape_regexp_source(pattern: &str) -> String { + if pattern.is_empty() { + return "(?:)".to_string(); + } + let mut out = String::with_capacity(pattern.len() + 2); + let mut in_class = false; + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + out.push('\\'); + if let Some(&next) = chars.peek() { + out.push(next); + chars.next(); + } + } + '[' if !in_class => { + in_class = true; + out.push('['); + } + ']' if in_class => { + in_class = false; + out.push(']'); + } + '/' if !in_class => out.push_str("\\/"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + _ => out.push(c), + } + } + out +} diff --git a/crates/perry-runtime/src/regex/exec.rs b/crates/perry-runtime/src/regex/exec.rs index 3b18cbb660..dd09e6e44f 100644 --- a/crates/perry-runtime/src/regex/exec.rs +++ b/crates/perry-runtime/src/regex/exec.rs @@ -20,6 +20,10 @@ pub extern "C" fn js_regexp_exec( return ptr::null_mut(); } + if crate::hot_diag::regex_on() { + super::diag_note_op(re, crate::hot_diag::RegexOp::Exec); + } + // Spec RegExpBuiltinExec step 4 is `ToLength(Get(R, "lastIndex"))`, and it // runs before anything else. The ToNumber half may execute user JS, so root // both arguments and take the subject payload borrow only after it returns @@ -150,6 +154,15 @@ pub extern "C" fn js_regexp_exec( (owned, has_indices) }; + if crate::hot_diag::regex_on() { + let (slots, bytes) = owned.capture_stats(); + crate::hot_diag::regex_with(|d| { + d.exec_matched += 1; + d.exec_capture_slots += slots as u64; + d.exec_capture_bytes += bytes as u64; + }); + } + // Phase 2 (allocating, no subject borrow): copy each snapshotted range from // the current rooted subject address. `string_copy_range` roots and re-reads // the source after its destination allocation. @@ -160,3 +173,72 @@ pub extern "C" fn js_regexp_exec( LAST_EXEC_GROUPS.with(|g| *g.borrow_mut() = groups); result } + +/// The engine phase of `RegExpBuiltinExec` for a global/sticky receiver, +/// without the result: search from `lastIndex`, honour `sticky`, and advance +/// or reset `lastIndex` exactly as [`js_regexp_exec`] does — but stop at the +/// full-match byte range. `test` needs nothing more, and the captures array +/// plus one string per capture that `exec` builds is pure allocation on that +/// path (every `ansi-regex`-style `/…/g.test(segment)` paid it). +/// +/// Engine order (backtracking matcher, fancy fallback, standard) and the +/// `lastIndex > length` / no-match resets mirror `js_regexp_exec` line for +/// line; a divergence here would make `test` and `exec` disagree on where the +/// next search starts. +#[cfg(feature = "regex-engine")] +pub(super) fn regexp_find_advancing( + re: *mut RegExpHeader, + s: *const StringHeader, +) -> Option<(usize, usize)> { + // Same rooting discipline as `js_regexp_exec`: the `ToLength(lastIndex)` + // read may run user JS. + let scope = crate::gc::RuntimeHandleScope::new(); + let re_handle = scope.root_raw_mut_ptr(re); + let s_handle = scope.root_string_ptr(s); + let ((last_index, re), s) = s_handle.across_const::(|| { + re_handle + .across_mut::(|| re_handle.with_const_ptr(regex_last_index_offset)) + }); + unsafe { + let str_data = string_as_str(s); + let regex = super::lazy::header_std_regex(re); + let sticky = (*re).sticky; + if last_index > (*s).utf16_len as usize { + set_last_index_throwing(re, 0); + return None; + } + let search_start_byte = if last_index > 0 { + super::exec_array::utf16_index_to_byte(str_data, last_index) + } else { + 0 + }; + let found = if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + repeat_matcher + .regex + .find_from(str_data, search_start_byte) + .next() + .filter(|matched| !sticky || matched.start() == search_start_byte) + .map(|matched| (matched.start(), matched.end())) + } else if let Some(fre) = lookup_fancy_regex(re) { + match fre.find_from_pos(str_data, search_start_byte) { + Ok(Some(matched)) if !sticky || matched.start() == search_start_byte => { + Some((matched.start(), matched.end())) + } + _ => None, + } + } else { + regex + .find_at(str_data, search_start_byte) + .filter(|matched| !sticky || matched.start() == search_start_byte) + .map(|matched| (matched.start(), matched.end())) + }; + match found { + Some((_, end)) => set_last_index_throwing( + re, + super::exec_array::byte_index_to_utf16_index(str_data, end), + ), + None => set_last_index_throwing(re, 0), + } + found + } +} diff --git a/crates/perry-runtime/src/regex/exec_array.rs b/crates/perry-runtime/src/regex/exec_array.rs index 461136cdab..923a9e942a 100644 --- a/crates/perry-runtime/src/regex/exec_array.rs +++ b/crates/perry-runtime/src/regex/exec_array.rs @@ -76,6 +76,17 @@ pub(super) struct OwnedExecMatch { } impl OwnedExecMatch { + /// `PERRY_REGEX_DIAG`: (result-array slots, bytes copied for captures). + pub(super) fn capture_stats(&self) -> (usize, usize) { + let bytes = self + .captures + .iter() + .flatten() + .map(|c| c.byte_len as usize) + .sum(); + (self.captures.len(), bytes) + } + pub(super) fn from_standard( str_data: &str, regex: ®ex::Regex, diff --git a/crates/perry-runtime/src/regex/flags.rs b/crates/perry-runtime/src/regex/flags.rs new file mode 100644 index 0000000000..606e414d6a --- /dev/null +++ b/crates/perry-runtime/src/regex/flags.rs @@ -0,0 +1,46 @@ +//! RegExp flags validation (`RegExpInitialize`, #2829). +//! +//! Split out of `regex.rs` to keep that file under the 2000-line size gate. + +use super::throw_regexp_syntax_error; + +/// #2829: validate a RegExp flags string the way the spec's +/// `RegExpInitialize` does — each flag must be one of `dgimsuvy` and must not +/// repeat. Returns the flags in canonical (sorted) order, or throws a +/// `SyntaxError` mirroring Node's "Invalid flags supplied to RegExp +/// constructor ''" message. +/// +/// Note: the `v` flag (unicodeSets) is accepted as a valid flag for parity but +/// its set-notation matching semantics are not implemented (the regex crate +/// has no equivalent); it behaves like an ordinary unicode pattern. +#[cfg(feature = "regex-engine")] +pub(super) fn validate_and_canonicalize_flags(flags: &str) -> String { + // Spec order of the flag bits: d g i m s u v y. + const FLAG_ORDER: &[char] = &['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']; + let mut seen = [false; 8]; + for ch in flags.chars() { + match FLAG_ORDER.iter().position(|&f| f == ch) { + Some(idx) => { + if seen[idx] { + throw_regexp_syntax_error(&format!( + "Invalid flags supplied to RegExp constructor '{}'", + flags + )); + } + seen[idx] = true; + } + None => { + throw_regexp_syntax_error(&format!( + "Invalid flags supplied to RegExp constructor '{}'", + flags + )); + } + } + } + FLAG_ORDER + .iter() + .enumerate() + .filter(|(i, _)| seen[*i]) + .map(|(_, c)| *c) + .collect() +} diff --git a/crates/perry-runtime/src/regex/lazy.rs b/crates/perry-runtime/src/regex/lazy.rs index ec6b194b50..438d8eca4b 100644 --- a/crates/perry-runtime/src/regex/lazy.rs +++ b/crates/perry-runtime/src/regex/lazy.rs @@ -163,22 +163,22 @@ pub(super) fn mark_pattern_validated(pattern: &str, flags: &str) { /// Prefers the GC-survivable side table (issue #637) and falls back to the /// header's own string payloads, which — unlike the thread-local table — are /// readable from a second statically-linked copy of the runtime (Wall 18). -pub(super) fn source_and_flags(re: *const RegExpHeader) -> (String, String) { +pub(super) fn source_and_flags(re: *const RegExpHeader) -> (Arc, Arc) { if let Some(source) = REGEX_SOURCE_TABLE.with(|table| table.borrow().get(&(re as usize)).cloned()) { return source; } unsafe { - let pattern = if is_valid_ptr((*re).pattern_ptr) { - string_as_str((*re).pattern_ptr).to_string() + let pattern: Arc = if is_valid_ptr((*re).pattern_ptr) { + Arc::from(string_as_str((*re).pattern_ptr)) } else { - String::new() + Arc::from("") }; - let flags = if is_valid_ptr((*re).flags_ptr) { - string_as_str((*re).flags_ptr).to_string() + let flags: Arc = if is_valid_ptr((*re).flags_ptr) { + Arc::from(string_as_str((*re).flags_ptr)) } else { - String::new() + Arc::from("") }; (pattern, flags) } @@ -229,20 +229,48 @@ fn build_and_install_programs(re: *const RegExpHeader) { return; } let (pattern, flags) = source_and_flags(re); - let arc = get_or_compile_regex(&pattern, &flags); - let regex_ptr = Arc::into_raw(arc) as *mut Regex; + if crate::hot_diag::regex_on() { + let cache_hit = super::REGEX_CACHE.with(|cache| { + cache + .borrow() + .contains_key(&(pattern.to_string(), flags.to_string())) + }); + unsafe { + let pattern_ptr = (*re).pattern_ptr; + crate::hot_diag::regex_with(|d| { + d.note_build(pattern_ptr as usize, pattern.as_bytes(), &flags, cache_hit) + }); + } + } + let std_arc = get_or_compile_regex(&pattern, &flags); + let fancy_arc: Option> = FANCY_CACHE.with(|fc| { + fc.borrow() + .get(&(pattern.to_string(), flags.to_string())) + .cloned() + }); + let repeat_arc: Option> = REPEAT_MATCHER_CACHE + .with(|cache| { + cache + .borrow() + .get(&(pattern.to_string(), flags.to_string())) + .cloned() + }); + // Remember the built programs against the pattern text, so the next + // construction of the same literal is born built (`js_regexp_new`). + super::site_cache::install_programs( + &pattern, + &flags, + super::site_cache::Programs { + std: std_arc.clone(), + fancy: fancy_arc.clone(), + repeat: repeat_arc.clone(), + }, + ); + let regex_ptr = Arc::into_raw(std_arc) as *mut Regex; let fancy_ptr: *const () = - FANCY_CACHE.with( - |fc| match fc.borrow().get(&(pattern.clone(), flags.clone())) { - Some(arc) => Arc::into_raw(arc.clone()) as *const (), - None => std::ptr::null(), - }, - ); + fancy_arc.map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); let repeat_matcher_ptr: *const () = - REPEAT_MATCHER_CACHE.with(|cache| match cache.borrow().get(&(pattern, flags)) { - Some(arc) => Arc::into_raw(arc.clone()) as *const (), - None => std::ptr::null(), - }); + repeat_arc.map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); unsafe { let re = re as *mut RegExpHeader; (*re).fancy_ptr = fancy_ptr; diff --git a/crates/perry-runtime/src/regex/match_string.rs b/crates/perry-runtime/src/regex/match_string.rs index 9b06e60bd8..6611923493 100644 --- a/crates/perry-runtime/src/regex/match_string.rs +++ b/crates/perry-runtime/src/regex/match_string.rs @@ -73,6 +73,9 @@ pub extern "C" fn js_string_match( if !is_valid_ptr(s) || !is_valid_regex_ptr(re) { return ptr::null_mut(); } + if crate::hot_diag::regex_on() { + super::diag_note_op(re, crate::hot_diag::RegexOp::Match); + } // Phase 1 (borrowing, no JS allocation): capture byte ranges and all // UTF-16/WTF-8 metadata while the engine's `Captures` may borrow `s`. diff --git a/crates/perry-runtime/src/regex/repeat_matcher.rs b/crates/perry-runtime/src/regex/repeat_matcher.rs index 107e17dded..e2e19129c9 100644 --- a/crates/perry-runtime/src/regex/repeat_matcher.rs +++ b/crates/perry-runtime/src/regex/repeat_matcher.rs @@ -304,7 +304,8 @@ fn source_and_flags(re: *const super::RegExpHeader) -> (String, String) { // One definition, shared with the lazy first-use builder: both need the // `(source, flags)` a header was constructed from, and a second copy of // the side-table-then-header fallback would be a place for them to drift. - super::lazy::source_and_flags(re) + let (source, flags) = super::lazy::source_and_flags(re); + (source.to_string(), flags.to_string()) } fn decode_wtf8_units(bytes: &[u8]) -> Vec { diff --git a/crates/perry-runtime/src/regex/site_cache.rs b/crates/perry-runtime/src/regex/site_cache.rs new file mode 100644 index 0000000000..b8e68af0da --- /dev/null +++ b/crates/perry-runtime/src/regex/site_cache.rs @@ -0,0 +1,258 @@ +//! Content-keyed construction cache for `RegExp`. +//! +//! # Why +//! +//! `js_regexp_new` runs once per EVALUATION of a regex literal (ECMA-262: a +//! literal is a new object every time), and TUI code evaluates literals inside +//! hot functions: `string-width`'s `emojiRegex()` returns a fresh ~12 KB +//! `/…/g` on every call, once per text segment per layout pass, and +//! `ansi-regex` builds the same `new RegExp(parts.join("|"), "g")` per call. +//! Each construction used to copy the pattern three times (the +//! `VALIDATED_PATTERNS` probe key, `owned_pattern`, the `REGEX_SOURCE_TABLE` +//! entry) and SipHash all of it once; the first operation on each header then +//! did the same three more times — `build_and_install_programs` probes the +//! three `(String, String)`-keyed program caches — and, for the common +//! no-fallback pattern, `lookup_fancy_regex` / `lookup_repeat_matcher` +//! re-probed two of them on EVERY exec. On the claude-code keystroke profile +//! SipHash over pattern text was 31 % of the post-turn window (regex 38 % +//! inclusive), all of it under these five functions. +//! +//! # What +//! +//! A direct-mapped, thread-local table keyed by a cheap CONTENT fingerprint +//! (length, first / middle / last 8 bytes, canonical flags) and verified by a +//! full byte compare — identity never depends on an address, so nothing is +//! rekeyed on a GC move and a dynamic `new RegExp(sameText)` hits too; a hit +//! costs one `memcmp` instead of a hash plus three copies. An entry owns the +//! pattern and canonical flags as `Arc` (shared into +//! `REGEX_SOURCE_TABLE`, so a header costs two refcount bumps instead of two +//! `String`s) and, once the first header built from it has been executed, the +//! compiled programs: a later construction installs those eagerly, so the +//! header is born built and never touches the `(pattern, flags)` caches. +//! +//! Validity is a pure function of `(pattern, flags)`, so a hit legitimately +//! skips validation: an entry is only ever written on the validated path, and +//! the programs it hands out were built for exactly this text. +//! +//! Kill switch: `PERRY_REGEX_SITE_CACHE=0` (lookups miss, nothing is stored). + +use std::cell::RefCell; +use std::sync::Arc; + +use regex::Regex; + +/// The compiled programs a header owns, in the form `lazy` installs them. +pub(super) struct Programs { + pub(super) std: Arc, + pub(super) fancy: Option>, + pub(super) repeat: Option>, +} + +impl Clone for Programs { + fn clone(&self) -> Self { + Self { + std: self.std.clone(), + fancy: self.fancy.clone(), + repeat: self.repeat.clone(), + } + } +} + +/// What a construction gets back on a hit. +pub(super) struct Hit { + pub(super) pattern: Arc, + pub(super) flags: Arc, + pub(super) programs: Option, +} + +struct Entry { + fp: u64, + pattern: Arc, + flags: Arc, + programs: Option, +} + +/// Direct-mapped slots (2-way: a fingerprint may live in `slot` or +/// `slot ^ 1`). Sized for a bundle's live literal working set; the +/// claude-code TUI cycles through a few dozen per render. +const SLOTS: usize = 1024; + +crate::perry_thread_local! { + static SITE_CACHE: RefCell>> = RefCell::new(Vec::new()); +} + +fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + crate::gc::env_default_on_from_value( + std::env::var("PERRY_REGEX_SITE_CACHE").ok().as_deref(), + ) + }) +} + +/// Cheap content fingerprint: length, three 8-byte windows of the pattern, +/// the (≤ 8 byte) canonical flags. Collisions are harmless — every hit is +/// verified by a full compare — they only cost the verify and a re-insert. +fn fingerprint(pattern: &[u8], flags: &[u8]) -> u64 { + #[inline] + fn window(bytes: &[u8], at: usize) -> u64 { + let mut w = [0u8; 8]; + let end = (at + 8).min(bytes.len()); + if at < end { + w[..end - at].copy_from_slice(&bytes[at..end]); + } + u64::from_le_bytes(w) + } + #[inline] + fn mix(h: u64, w: u64) -> u64 { + (h ^ w).wrapping_mul(0xC6BC_2796_92B5_C323).rotate_left(29) + } + let n = pattern.len(); + let mut h = (n as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + h = mix(h, window(pattern, 0)); + h = mix(h, window(pattern, n / 2)); + h = mix(h, window(pattern, n.saturating_sub(8))); + h = mix(h, window(flags, 0)); + h +} + +#[inline] +fn slot_of(fp: u64) -> usize { + (fp as usize) & (SLOTS - 1) +} + +fn entry_matches(entry: &Entry, fp: u64, pattern: &str, flags: &str) -> bool { + entry.fp == fp && &*entry.flags == flags && &*entry.pattern == pattern +} + +/// Find the verified entry for `(pattern, canonical flags)`. +pub(super) fn lookup(pattern: &str, flags: &str) -> Option { + if !enabled() { + return None; + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let cache = cache.borrow(); + if cache.is_empty() { + return None; + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return Some(Hit { + pattern: entry.pattern.clone(), + flags: entry.flags.clone(), + programs: entry.programs.clone(), + }); + } + } + } + None + }) +} + +/// Record a validated `(pattern, canonical flags)`, returning the shared +/// owned copies a header should keep. An existing verified entry is reused +/// (its programs are kept); otherwise the fresh entry has none yet. +pub(super) fn insert(pattern: &str, flags: &str) -> (Arc, Arc) { + if !enabled() { + return (Arc::from(pattern), Arc::from(flags)); + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.is_empty() { + cache.resize_with(SLOTS, || None); + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return (entry.pattern.clone(), entry.flags.clone()); + } + } + } + let victim = if cache[slot].is_none() { + slot + } else if cache[slot ^ 1].is_none() { + slot ^ 1 + } else { + slot ^ ((fp >> 11) as usize & 1) + }; + let pattern: Arc = Arc::from(pattern); + let flags: Arc = Arc::from(flags); + cache[victim] = Some(Entry { + fp, + pattern: pattern.clone(), + flags: flags.clone(), + programs: None, + }); + (pattern, flags) + }) +} + +/// Attach the programs the first execution built to the entry for +/// `(pattern, canonical flags)`, so every later construction of the same +/// text is born built. Inserts the entry if it was evicted meanwhile. +pub(super) fn install_programs(pattern: &str, flags: &str, programs: Programs) { + if !enabled() { + return; + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.is_empty() { + cache.resize_with(SLOTS, || None); + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &mut cache[s] { + if entry_matches(entry, fp, pattern, flags) { + if entry.programs.is_none() { + entry.programs = Some(programs); + } + return; + } + } + } + let victim = if cache[slot].is_none() { + slot + } else if cache[slot ^ 1].is_none() { + slot ^ 1 + } else { + slot ^ ((fp >> 11) as usize & 1) + }; + cache[victim] = Some(Entry { + fp, + pattern: Arc::from(pattern), + flags: Arc::from(flags), + programs: Some(programs), + }); + }); +} + +#[cfg(test)] +pub(super) fn test_reset() { + SITE_CACHE.with(|cache| cache.borrow_mut().clear()); +} + +#[cfg(test)] +pub(super) fn test_has_programs(pattern: &str, flags: &str) -> Option { + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let cache = cache.borrow(); + if cache.is_empty() { + return None; + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return Some(entry.programs.is_some()); + } + } + } + None + }) +} diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index ea91ba279e..e41c2abc61 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -1648,3 +1648,112 @@ fn global_replace_substitutes_at_every_empty_match() { let out = js_string_replace_regex_named(make_string("a"), named, make_string("[$]")); assert_eq!(string_as_str(out), "[a][]"); } + +/// The construction cache (`regex::site_cache`): once a header built from +/// some `(pattern, flags)` has been executed, the next construction of the +/// same text is born built — it shares the executed header's program and +/// never runs the lazy build. Fails on a runtime without the cache (the +/// second header stays lazy). +#[test] +fn site_cache_reconstruction_is_born_built() { + let _lock = crate::gc::global_side_table_test_lock(); + site_cache::test_reset(); + let re1 = js_regexp_new(make_string("born[0-9]+built"), make_string("g")); + assert!( + unsafe { (*re1).regex_ptr.is_null() }, + "construction stays lazy" + ); + assert_eq!( + site_cache::test_has_programs("born[0-9]+built", "g"), + Some(false), + "construction records the validated text without programs" + ); + assert!(js_regexp_test(re1, make_string("xx born42built")) != 0); + assert_eq!( + site_cache::test_has_programs("born[0-9]+built", "g"), + Some(true), + "the first execution's build is remembered against the text" + ); + let re2 = js_regexp_new(make_string("born[0-9]+built"), make_string("g")); + assert!( + !unsafe { (*re2).regex_ptr.is_null() }, + "the second construction installs the programs eagerly" + ); + assert!( + std::ptr::eq(unsafe { (*re1).regex_ptr }, unsafe { (*re2).regex_ptr }), + "both headers share one compiled program" + ); + // The owned source copies are shared too (two refcount bumps per header, + // not two `String`s). + let (p1, p2) = REGEX_SOURCE_TABLE.with(|t| { + let t = t.borrow(); + ( + t.get(&(re1 as usize)).map(|(p, _)| p.clone()).unwrap(), + t.get(&(re2 as usize)).map(|(p, _)| p.clone()).unwrap(), + ) + }); + assert!(Arc::ptr_eq(&p1, &p2), "source text is shared, not copied"); + assert_eq!(js_regexp_test(re2, make_string("born7built")), 1); + assert_eq!(js_regexp_test(re2, make_string("nothing")), 0); + // Different flags are a different entry. + let re3 = js_regexp_new(make_string("born[0-9]+built"), make_string("i")); + assert!(unsafe { (*re3).regex_ptr.is_null() }); +} + +/// `test` on a global/sticky receiver advances `lastIndex` exactly like +/// `exec` and resets it on failure, through the find-only engine phase (no +/// exec array). Pinned against node for every branch of that bookkeeping. +#[test] +fn global_test_advances_and_resets_last_index() { + let _lock = crate::gc::global_side_table_test_lock(); + let re = js_regexp_new(make_string("a"), make_string("g")); + let s = make_string("aXa"); + assert_eq!(js_regexp_test(re, s), 1); + assert_eq!(js_regexp_get_last_index(re), 1.0); + assert_eq!(js_regexp_test(re, s), 1); + assert_eq!(js_regexp_get_last_index(re), 3.0); + assert_eq!(js_regexp_test(re, s), 0); + assert_eq!(js_regexp_get_last_index(re), 0.0); + + // `lastIndex > length` is "no match" and resets. + js_regexp_set_last_index(re, 10.0); + assert_eq!(js_regexp_test(re, s), 0); + assert_eq!(js_regexp_get_last_index(re), 0.0); + + // sticky anchors at lastIndex. + let sticky = js_regexp_new(make_string("a"), make_string("y")); + let t = make_string("ba"); + assert_eq!(js_regexp_test(sticky, t), 0); + assert_eq!(js_regexp_get_last_index(sticky), 0.0); + js_regexp_set_last_index(sticky, 1.0); + assert_eq!(js_regexp_test(sticky, t), 1); + assert_eq!(js_regexp_get_last_index(sticky), 2.0); + + // lastIndex counts UTF-16 code units, not bytes. + let astral = js_regexp_new(make_string("b"), make_string("g")); + let u = make_string("😀b😀b"); + assert_eq!(js_regexp_test(astral, u), 1); + assert_eq!(js_regexp_get_last_index(astral), 3.0); + assert_eq!(js_regexp_test(astral, u), 1); + assert_eq!(js_regexp_get_last_index(astral), 6.0); + assert_eq!(js_regexp_test(astral, u), 0); + + // The fancy-regex fallback (lookbehind) takes the same path. + let fancy = js_regexp_new(make_string("(?<=x)a"), make_string("g")); + let f = make_string("xa xa a"); + assert_eq!(js_regexp_test(fancy, f), 1); + assert_eq!(js_regexp_get_last_index(fancy), 2.0); + assert_eq!(js_regexp_test(fancy, f), 1); + assert_eq!(js_regexp_get_last_index(fancy), 5.0); + assert_eq!(js_regexp_test(fancy, f), 0); + assert_eq!(js_regexp_get_last_index(fancy), 0.0); + + // The backtracking matcher (quantified capture) likewise. + let repeat = js_regexp_new(make_string("(a?b??)*c"), make_string("g")); + let r = make_string("abc c"); + assert_eq!(js_regexp_test(repeat, r), 1); + assert_eq!(js_regexp_get_last_index(repeat), 3.0); + assert_eq!(js_regexp_test(repeat, r), 1); + assert_eq!(js_regexp_get_last_index(repeat), 5.0); + assert_eq!(js_regexp_test(repeat, r), 0); +} diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 5bb25eade3..95629053e2 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -795,7 +795,9 @@ fn is_string_like(bits: u64) -> bool { // content-compared them by reinterpreting `ObjectHeader` as `StringHeader` // (class_id became byte_len, etc.) — colliding empty objects in `Set.add`. let ptr = extract_string_ptr_from_value(bits); - if ptr.is_null() || (ptr as usize) < 0x1000 { + // Native handles (including WebSocket clients) are identities, never + // string allocations, even when their ids have grown beyond one page. + if !crate::value::addr_class::is_above_handle_band(ptr as usize) { return false; } unsafe { @@ -2620,6 +2622,39 @@ mod tests { assert_eq!(js_set_has(set, 0.0), 0); } + #[test] + fn native_handles_are_set_identities_across_scan_and_hash_paths() { + use crate::value::{addr_class, POINTER_TAG}; + let handles = [ + 1, + 4095, + 4096, + 4097, + 8192, + 65535, + addr_class::COMMON_HANDLE_BAND_END - 1, + addr_class::FETCH_HANDLE_BAND_START, + addr_class::ZLIB_HANDLE_BAND_START, + addr_class::HANDLE_BAND_MAX - 1, + ]; + let set = js_set_alloc(4); + for &handle in &handles { + let value = f64::from_bits(POINTER_TAG | handle as u64); + assert_eq!(js_set_has(set, value), 0); + js_set_add(set, value); + assert_eq!(js_set_has(set, value), 1); + } + assert_eq!(js_set_size(set), handles.len() as u32); + for &handle in &handles { + let value = f64::from_bits(POINTER_TAG | handle as u64); + assert_eq!(js_set_has(set, value), 1); + assert_eq!(js_set_has(set, handle as f64), 0); + assert_eq!(js_set_delete(set, value), 1); + assert_eq!(js_set_has(set, value), 0); + } + assert_eq!(js_set_size(set), 0); + } + // #2872: helper to pass a Set pointer as the NaN-boxed `other` argument. // A raw heap pointer (top16 == 0) is returned unchanged by `clean_set_ptr`, // so reinterpreting the pointer bits as f64 round-trips through diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 77c33aa738..f673d45d22 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -1027,6 +1027,32 @@ pub(crate) fn is_ascii_string(s: *const StringHeader) -> bool { unsafe { (*s).utf16_len == (*s).byte_len } } +/// Borrow a header's payload as `&str`, answering `None` for a WTF-8 payload +/// (lone surrogates), like `std::str::from_utf8(..).ok()` — but without the +/// scan when the header already proves the answer: `utf16_len == byte_len` +/// holds iff every byte is a one-byte code unit, i.e. pure ASCII, which is +/// what nearly every property key is. The generic property-read ladder +/// decodes the key at several layers per read (`ic_miss`, closure expandos, +/// accessor and reflection probes, async-resource dispatch), and +/// `core::str::from_utf8` was 2 % of the claude-code keystroke profile on +/// those decodes alone. +/// +/// Same borrow rule as [`string_as_str`]: the slice must not outlive any +/// call that can move the payload. +/// +/// # Safety +/// `s` must point at a live `StringHeader`. +#[inline] +pub(crate) unsafe fn header_str_checked<'a>(s: *const StringHeader) -> Option<&'a str> { + let len = (*s).byte_len as usize; + let bytes = slice::from_raw_parts(string_data(s), len); + if (*s).utf16_len as usize == len { + Some(str::from_utf8_unchecked(bytes)) + } else { + str::from_utf8(bytes).ok() + } +} + /// `PERRY_GC_CENSUS`: the fixed-size intern table (slots, bytes). Entries /// point into the GC heap; only the table itself is counted. pub(crate) fn intern_table_census() -> (usize, usize) { diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index 5968961bce..c001ba15d9 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -1296,3 +1296,36 @@ mod split_empty_delimiter_code_units { assert_eq!(crate::array::js_array_length(arr), 3); } } + +/// `header_str_checked` answers exactly like `from_utf8(..).ok()` — a pure +/// ASCII key without the scan, a non-ASCII scalar key by validation, and a +/// WTF-8 payload (lone surrogate) as `None`. +#[test] +fn header_str_checked_matches_from_utf8_on_every_payload_class() { + let scope = crate::gc::RuntimeHandleScope::new(); + let ascii = scope.root_string_ptr(js_string_from_bytes(b"userName".as_ptr(), 8)); + let cjk = "名前"; + let scalar = scope.root_string_ptr(js_string_from_bytes(cjk.as_ptr(), cjk.len() as u32)); + let lone = [0xEDu8, 0xA0, 0x80, b'x']; + let wtf8 = scope.root_string_ptr(js_string_from_wtf8_bytes(lone.as_ptr(), lone.len() as u32)); + let empty = scope.root_string_ptr(js_string_from_bytes(b"".as_ptr(), 0)); + for (root, expect) in [ + (&ascii, Some("userName")), + (&scalar, Some(cjk)), + (&wtf8, None), + (&empty, Some("")), + ] { + let got = root.with_const_ptr::(|s| unsafe { header_str_checked(s) }); + assert_eq!(got, expect); + let via_std = root.with_const_ptr::(|s| { + std::str::from_utf8(string_as_bytes_for_test(s)) + .ok() + .map(|s| s.to_string()) + }); + assert_eq!(got.map(|s| s.to_string()), via_std); + } +} + +fn string_as_bytes_for_test<'a>(s: *const StringHeader) -> &'a [u8] { + unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) } +} diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 503d041ccf..a4a7d78148 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -1427,6 +1427,10 @@ mod own_data_ic_tests { #[test] fn composed_symbol_field_cache_reloads_mutated_final_slot() { + crate::test_support::isolated_test(composed_symbol_field_cache_body); + } + + fn composed_symbol_field_cache_body() { let _global = crate::gc::global_side_table_test_lock(); unsafe { crate::gc::gc_suppress(); diff --git a/crates/perry-runtime/src/test_support.rs b/crates/perry-runtime/src/test_support.rs index cf9b396e46..d5666b534e 100644 --- a/crates/perry-runtime/src/test_support.rs +++ b/crates/perry-runtime/src/test_support.rs @@ -1,5 +1,4 @@ -//! Test-only serialization for PROCESS-global state that is neither a runtime -//! side table (those use `gc::global_side_table_test_lock`) nor thread-local. +//! Test-only isolation and serialization for process-global fixtures. //! //! First resident: the process working directory. `std::env::set_current_dir` //! is process-wide, so a test that changes it (`typed_feedback`'s @@ -21,3 +20,35 @@ pub(crate) fn process_cwd_test_lock() -> std::sync::MutexGuard<'static, ()> { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + +/// Run a libtest case in its own process when its fixture needs exclusive +/// ownership of process-global state (#9197). A lock shared by only a few +/// tests cannot exclude the runtime's other side-table readers or counters. +/// +/// Use the harness's current test name so renaming/moving a test cannot leave +/// a stale filter. Require a marker emitted AFTER the body as well as a clean +/// exit: selecting zero tests or exiting early must not produce a false pass. +pub(crate) fn isolated_test(body: impl FnOnce()) { + const CHILD_ENV: &str = "PERRY_RUNTIME_ISOLATED_TEST_NAME"; + let thread = std::thread::current(); + let name = thread.name().expect("libtest must name the test thread"); + let completed = format!("perry isolated test completed: {name}"); + if std::env::var(CHILD_ENV).ok().as_deref() == Some(name) { + body(); + println!("{completed}"); + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("current test binary")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, name) + .output() + .expect("launch isolated runtime test"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.lines().any(|line| line.ends_with(&completed)), + "isolated test {name} did not complete: {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); +} diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 339bc3ccff..b165f6659a 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -147,17 +147,17 @@ fn method_direct_call_contract( (shape_addr, class_id, gc_type, name_hash, valid) } -fn key_as_str(key: *const crate::StringHeader) -> Option { +/// Borrow the key text for the guard's side-table lookups. Every consumer +/// (`class_getter_in_chain`, `descriptor_blocks_class_field_*`, +/// `get_accessor_descriptor`, `get_property_attrs`) reads Rust-side tables +/// and allocates nothing on the GC heap, so the payload cannot move while the +/// borrow is live; the `String` this used to return was one `malloc` + UTF-8 +/// scan per guarded class-field access. +fn key_as_str<'a>(key: *const crate::StringHeader) -> Option<&'a str> { if !valid_string_key(key) { return None; } - unsafe { - let len = (*key).byte_len as usize; - let data = (key as *const u8).add(std::mem::size_of::()); - std::str::from_utf8(std::slice::from_raw_parts(data, len)) - .ok() - .map(|s| s.to_string()) - } + unsafe { crate::string::header_str_checked(key) } } fn class_setter_in_chain(class_id: u32, key_name: &str) -> bool { @@ -314,8 +314,8 @@ fn class_field_get_contract( expected_field_index, require_raw_f64, ) - && !class_getter_in_chain(class_id, &key_name) - && !descriptor_blocks_class_field_get(object_addr, class_id, &key_name); + && !class_getter_in_chain(class_id, key_name) + && !descriptor_blocks_class_field_get(object_addr, class_id, key_name); (shape_addr, class_id, gc_type, valid) } } @@ -596,8 +596,8 @@ fn class_field_set_contract( expected_field_index, true, ))) - && !class_setter_in_chain(class_id, &key_name) - && !descriptor_blocks_class_field_set(object_addr, class_id, &key_name); + && !class_setter_in_chain(class_id, key_name) + && !descriptor_blocks_class_field_set(object_addr, class_id, key_name); (shape_addr, class_id, gc_type, valid) } } diff --git a/crates/perry-runtime/src/typedarray_props.rs b/crates/perry-runtime/src/typedarray_props.rs index b32a57b78c..c56fff0d30 100644 --- a/crates/perry-runtime/src/typedarray_props.rs +++ b/crates/perry-runtime/src/typedarray_props.rs @@ -172,9 +172,7 @@ unsafe fn string_header_str<'a>(key: *const crate::string::StringHeader) -> Opti if key.is_null() || (key as usize) < 0x10000 { return None; } - let len = (*key).byte_len as usize; - let data = (key as *const u8).add(std::mem::size_of::()); - std::str::from_utf8(std::slice::from_raw_parts(data, len)).ok() + crate::string::header_str_checked(key) } fn unsigned_canonical_index(name: &str) -> Option { diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 12bbd5ec1f..b6a2b7b8d9 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -1577,7 +1577,7 @@ fn throw_radix_range_error() -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } -/// V8-style `DoubleToRadixCString`: render a finite, non-integer f64 in +/// V8-style `DoubleToRadixCString`: render a finite f64 in /// `radix` (2..=36) producing the shortest digit sequence that round-trips /// back to the same double. Mirrors ECMAScript `Number::toString` for /// non-decimal radices, including the fractional part (`(10.5).toString(2)` @@ -1610,7 +1610,7 @@ fn double_to_radix_string(value: f64, radix: u32) -> String { let digit = fraction.floor() as usize; frac_buf.push(CHARS[digit] as char); fraction -= digit as f64; - if fraction >= 0.5 && fraction > delta { + if fraction > 0.5 || (fraction == 0.5 && digit & 1 != 0) { // Round up: carry into the already-emitted digits. if fraction + delta > 1.0 { // Propagate the carry through fraction digits, possibly @@ -1654,13 +1654,23 @@ fn double_to_radix_string(value: f64, radix: u32) -> String { // Integer part: repeated division. `integer` may have grown via carry. let mut int_buf = String::new(); + // V8's Double(integer / radix).Exponent() > 0 means the quotient's + // least significant binary digit is above the units place (>= 2^53). + // Such radix digits are unrepresented: emit zeros until the quotient + // fits, retaining the rounded quotient rather than flooring it. + while integer / radix as f64 >= crate::builtins::INT_EXACT_FASTPATH_LIMIT { + integer /= radix as f64; + int_buf.push('0'); + } if integer == 0.0 { int_buf.push('0'); } else { while integer >= 1.0 { let remainder = (integer % radix as f64) as usize; int_buf.push(CHARS[remainder] as char); - integer = (integer / radix as f64).floor(); + // Subtract before dividing: a rounded quotient can otherwise + // cross an integer boundary and invent a carry above 2^53. + integer = (integer - remainder as f64) / radix as f64; } } let int_part: String = int_buf.chars().rev().collect(); @@ -1890,6 +1900,40 @@ mod radix_tostring_tests { assert_eq!(double_to_radix_string(35.0, 36), "z"); } + #[test] + fn large_integer_radix_formatting_matches_node() { + // Node 26.5.1: preserve represented digits at the 2^53 boundary, + // then zero-fill digits beyond the double's precision (#9725). + for (value, radix, expected) in [ + (255.0, 36, "73"), + (1e15, 36, "9ugxnorjls"), + (9_007_199_254_740_991.0, 36, "2gosa7pa2gv"), + (9_007_199_254_740_992.0, 36, "2gosa7pa2gw"), + (9_007_199_254_740_994.0, 36, "2gosa7pa2gy"), + ( + 9_007_199_254_740_994.0, + 3, + "1121202011211211122211100012101111", + ), + (1e21, 36, "5v1j4f4ds7c000"), + (1e21, 7, "5135235413265003022600000"), + (1e30, 36, "2oy99wnkl1a000000000"), + (1e30, 7, "243230604464041356220000000000000000"), + (1e21, 16, "3635c9adc5dea00000"), + (1e30, 16, "c9f2c9cd04675000000000000"), + ] { + assert_eq!(double_to_radix_string(value, radix), expected); + assert_eq!( + double_to_radix_string(-value, radix), + format!("-{expected}") + ); + } + assert_eq!( + double_to_radix_string(f64::MAX, 36), + format!("1a1e4vngaiqo{}", "0".repeat(187)) + ); + } + #[test] fn fractional_radix_formatting_matches_v8() { // Terminating fractions. @@ -1905,6 +1949,9 @@ mod radix_tostring_tests { double_to_radix_string(0.1, 2), "0.0001100110011001100110011001100110011001100110011001101" ); + // Rounding is still needed when the residual is within delta. + assert_eq!(double_to_radix_string(0.1, 36), "0.3lllllllllm"); + assert_eq!(double_to_radix_string(10.5, 7), "13.333333333333333334"); } #[test] diff --git a/crates/perry-transform/src/inline/cross_module.rs b/crates/perry-transform/src/inline/cross_module.rs index 2027d24b3f..f3c5d865fd 100644 --- a/crates/perry-transform/src/inline/cross_module.rs +++ b/crates/perry-transform/src/inline/cross_module.rs @@ -155,12 +155,13 @@ pub fn gather_cross_module_functions(module: &Module) -> HashMap = module + let mut source_class_names: HashSet = module .classes .iter() .flat_map(|class| std::iter::once(class.name.clone()).chain(class.aliases.iter().cloned())) .filter(|name| !name.starts_with("__AnonShape_")) .collect(); + source_class_names.extend(imported_binding_names(module)); let mut out = HashMap::new(); for (exported_name, root_id) in &module.exported_functions { @@ -193,7 +194,7 @@ pub fn gather_cross_module_functions(module: &Module) -> HashMap MAX_CROSS_MODULE_FUNCTION_STMTS || !function_shell_is_cross_module_safe(function, &allowed_ids, &mut extern_names) - || body_references_class_in_set(&function.body, &source_class_names) + || function_references_class_in_set(function, &source_class_names) { safe = false; break; @@ -1016,7 +1017,7 @@ pub fn gather_cross_module_methods(module: &Module) -> HashMap<(String, String), if !is_cross_module_safe(&method.body) { continue; } - if body_references_class_in_set(&method.body, &nonexported) { + if function_references_class_in_set(method, &nonexported) { continue; } out.insert( @@ -1095,7 +1096,7 @@ pub fn gather_cross_module_methods_with_extern_imports( // so the source module's codegen — which DOES have the class // metadata — emits the correct inline-alloc with the right // class_id. - if body_references_class_in_set(&method.body, &nonexported) { + if function_references_class_in_set(method, &nonexported) { continue; } extern_names.sort(); @@ -1242,13 +1243,13 @@ pub fn is_cross_module_safe_with_externs(body: &[Stmt], extern_names: &mut Vec()` keep their inlinability. +/// Imported bindings are also source-local dependencies (#9023): exporting a +/// method does not export the classes that its module imports. +/// +/// The `__AnonShape_*` content-addressed shapes are excluded: the inliner +/// propagates their definitions via `extra_anon_classes`. pub fn collect_nonexported_class_names(module: &Module) -> HashSet { - let mut set = HashSet::new(); + let mut set: HashSet = imported_binding_names(module).collect(); for c in &module.classes { if c.is_exported { // Refs #486: even for an EXPORTED class, the inner self-binding @@ -1278,98 +1279,86 @@ pub fn collect_nonexported_class_names(module: &Module) -> HashSet { set } -/// Returns true iff `stmts` references any class whose name is in `set`. -/// Walks every Expr variant that carries a `class_name` string. Used by -/// the cross-module method gathering passes to reject candidates whose -/// body would dangle (or worse: silently fall to a class_id=0 placeholder) -/// after being copied into a destination module. -pub fn body_references_class_in_set(stmts: &[Stmt], set: &HashSet) -> bool { - fn check_expr(expr: &Expr, set: &HashSet) -> bool { - match expr { - Expr::New { class_name, .. } - | Expr::ClassRef(class_name) - | Expr::StaticFieldGet { class_name, .. } - | Expr::StaticFieldSet { class_name, .. } - | Expr::ClassStaticSymbolSet { class_name, .. } - | Expr::RegisterClassParentDynamic { class_name, .. } - | Expr::RegisterClassStaticSymbol { class_name, .. } - | Expr::StaticMethodCall { class_name, .. } - if set.contains(class_name) => +/// Imported class bindings belong to the source module just like local class +/// names. Copying `new ImportedBag()` does not copy its import or class metadata +/// (#9023). Include every import binding: the class-reference check below only +/// consults this set for class-bearing expressions, so ordinary imported calls +/// still use the existing extern-import localization path. +fn imported_binding_names(module: &Module) -> impl Iterator + '_ { + module.imports.iter().flat_map(|import| { + import.specifiers.iter().map(|specifier| match specifier { + ImportSpecifier::Named { local, .. } + | ImportSpecifier::Default { local } + | ImportSpecifier::Namespace { local } => local.clone(), + }) + }) +} + +fn function_references_class_in_set(function: &Function, set: &HashSet) -> bool { + body_references_class_in_set(&function.body, set) + || function.params.iter().any(|param| { + param + .default + .as_ref() + .is_some_and(|default| expr_references_class_in_set(default, set)) + }) +} + +fn expr_references_class_in_set(expr: &Expr, set: &HashSet) -> bool { + let contains = |name: &str| { + set.contains(name) + || name + .split_once('.') + .is_some_and(|(namespace, _)| set.contains(namespace)) + }; + match expr { + Expr::New { class_name, .. } + | Expr::ClassRef(class_name) + | Expr::StaticFieldGet { class_name, .. } + | Expr::StaticFieldSet { class_name, .. } + | Expr::ClassStaticSymbolSet { class_name, .. } + | Expr::RegisterClassParentDynamic { class_name, .. } + | Expr::RegisterClassStaticSymbol { class_name, .. } + | Expr::StaticMethodCall { class_name, .. } + if contains(class_name) => + { + return true; + } + Expr::ClassExprFresh { template, .. } if contains(template) => { + return true; + } + Expr::Closure { params, body, .. } => { + if body_references_class_in_set(body, set) + || params.iter().any(|param| { + param + .default + .as_ref() + .is_some_and(|default| expr_references_class_in_set(default, set)) + }) { return true; } - Expr::ClassExprFresh { template, .. } if set.contains(template) => { - return true; - } - _ => {} } - let mut hit = false; - walk_expr_children(expr, &mut |child| { - if check_expr(child, set) { - hit = true; - } - }); - hit + _ => {} } - fn check_stmt(s: &Stmt, set: &HashSet) -> bool { - match s { - Stmt::Let { init, .. } => init.as_ref().is_some_and(|e| check_expr(e, set)), - Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => check_expr(e, set), - Stmt::Return(None) | Stmt::Break | Stmt::Continue => false, - Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => false, - Stmt::If { - condition, - then_branch, - else_branch, - } => { - check_expr(condition, set) - || then_branch.iter().any(|s| check_stmt(s, set)) - || else_branch - .as_ref() - .is_some_and(|eb| eb.iter().any(|s| check_stmt(s, set))) - } - Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { - check_expr(condition, set) || body.iter().any(|s| check_stmt(s, set)) - } - Stmt::For { - init, - condition, - update, - body, - } => { - init.as_ref().is_some_and(|s| check_stmt(s, set)) - || condition.as_ref().is_some_and(|e| check_expr(e, set)) - || update.as_ref().is_some_and(|e| check_expr(e, set)) - || body.iter().any(|s| check_stmt(s, set)) - } - Stmt::Switch { - discriminant, - cases, - } => { - check_expr(discriminant, set) - || cases.iter().any(|c| { - c.test.as_ref().is_some_and(|e| check_expr(e, set)) - || c.body.iter().any(|s| check_stmt(s, set)) - }) - } - Stmt::Try { - body, - catch, - finally, - } => { - body.iter().any(|s| check_stmt(s, set)) - || catch - .as_ref() - .is_some_and(|c| c.body.iter().any(|s| check_stmt(s, set))) - || finally - .as_ref() - .is_some_and(|f| f.iter().any(|s| check_stmt(s, set))) - } - Stmt::Labeled { body, .. } => check_stmt(body.as_ref(), set), - Stmt::PreallocateBoxes(_) | Stmt::PreallocateTdzBoxes(_) | Stmt::ReleaseBoxes(_) => { - false - } + let mut hit = false; + walk_expr_children(expr, &mut |child| { + if expr_references_class_in_set(child, set) { + hit = true; } - } - stmts.iter().any(|s| check_stmt(s, set)) + }); + hit +} + +/// Returns true iff `stmts` references any class whose name is in `set`. +/// Walks every Expr variant that carries a `class_name` string. Used by +/// the cross-module method gathering passes to reject candidates whose +/// body would dangle (or worse: silently fall to a class_id=0 placeholder) +/// after being copied into a destination module. +pub fn body_references_class_in_set(stmts: &[Stmt], set: &HashSet) -> bool { + let mut referenced = false; + walk_stmts(stmts, &mut |expr| { + referenced |= expr_references_class_in_set(expr, set); + }); + referenced } diff --git a/crates/perry-transform/src/inline/exact_receivers.rs b/crates/perry-transform/src/inline/exact_receivers.rs index aa2c5906e5..53b235119b 100644 --- a/crates/perry-transform/src/inline/exact_receivers.rs +++ b/crates/perry-transform/src/inline/exact_receivers.rs @@ -102,9 +102,7 @@ pub(crate) fn collect_module_prototype_facts(module: &Module) -> ModulePrototype facts.touched_classes.insert(class_name.clone()); } Expr::SetFunctionPrototype { func, .. } => { - if let Expr::ClassRef(name) = func.as_ref() { - facts.touched_classes.insert(name.clone()); - } + note_holder(func, facts); } Expr::PropertyGet { object, property, .. diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 7f675217f7..c496965cda 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -1328,6 +1328,96 @@ mod tests { ); } + #[test] + fn cross_module_imported_class_dependencies_stay_in_the_source_module() { + for (specifier, class_name) in [ + ( + ImportSpecifier::Named { + imported: "Bag".into(), + local: "ImportedBag".into(), + }, + "ImportedBag", + ), + ( + ImportSpecifier::Default { + local: "ImportedBag".into(), + }, + "ImportedBag", + ), + ( + ImportSpecifier::Namespace { + local: "bags".into(), + }, + "bags.Bag", + ), + ] { + let mut source = Module::new("/src/helpers.ts"); + source.imports.push(perry_hir::Import { + source: "./bag".into(), + specifiers: vec![specifier], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: Some("/src/bag.ts".into()), + type_only: false, + runtime_erased: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + let mut factory = function(1, vec![anon_new(class_name)]); + factory.name = "make".into(); + factory.is_exported = true; + source.functions.push(factory.clone()); + source.exported_functions.push(("make".into(), 1)); + + // There is no class declaration in this module: the old local-only + // dependency census admitted the imported constructor. + assert!( + gather_cross_module_functions(&source).is_empty(), + "{class_name}" + ); + + let mut builder = anon_class(2, "Builder"); + builder.is_exported = true; + builder.methods.push(factory); + source.classes.push(builder); + assert!( + gather_cross_module_methods(&source).is_empty(), + "{class_name}" + ); + assert!( + gather_cross_module_methods_with_extern_imports(&source).is_empty(), + "{class_name}" + ); + + let Stmt::Expr(constructor) = anon_new(class_name) else { + unreachable!() + }; + source.functions[0].body = vec![Stmt::Return(Some(Expr::LocalGet(1)))]; + source.functions[0].params.push(Param { + id: 1, + name: "value".into(), + ty: Type::Any, + default: Some(constructor), + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }); + assert!( + gather_cross_module_functions(&source).is_empty(), + "default {class_name}" + ); + + // Importing a class must not disable unrelated helper inlining. + source.functions[0].params[0].default = Some(Expr::Integer(7)); + assert!( + gather_cross_module_functions(&source).contains_key("make"), + "independent {class_name}" + ); + } + } + #[test] fn cross_module_free_function_with_module_local_is_rejected() { let mut source = Module::new("/src/constants.ts"); diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 285918a5f9..ce3c8fa94f 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -141,6 +141,8 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_CODEGEN_UNITS", "PERRY_CODEGEN_UNIT_BYTES", "PERRY_CODEGEN_UNIT_SIZE", + // Enables per-site concatenation tables and changes the emitted calls. + "PERRY_CONCAT_SITE_CACHE", "PERRY_ENTRY_SYMBOL", "PERRY_FULL_OUTLINE_IC", "PERRY_FULL_OUTLINE_IC_MIN_FUNCS", @@ -410,9 +412,13 @@ mod tests { assert!( missing.is_empty(), "these codegen env vars key neither the build cache nor an \ - exclusion (#6394's rule): {missing:?}. Add each to \ - BUILD_CACHE_ENV_VARS, or to BUILD_CACHE_ENV_EXCLUSIONS with a \ - reason it cannot change emitted code." + exclusion (#6394's rule): {missing:?}.\n\ + Edit crates/perry/src/commands/compile/build_cache.rs:\n\ + - Add switches that change emitted code at `const BUILD_CACHE_ENV_VARS`.\n\ + - Otherwise add them at `const BUILD_CACHE_ENV_EXCLUSIONS`, with a \ + reason they cannot change emitted code.\n\ + Unregistered switches can reuse objects compiled with a different setting.\n\ + Verify with: cargo test -p perry codegen_env_vars_are_build_cache_inputs" ); // A stale exclusion is also a defect: it claims a var exists and is diff --git a/crates/perry/src/commands/compile/cjs_wrap/detect.rs b/crates/perry/src/commands/compile/cjs_wrap/detect.rs index 89c5914706..376bea3b28 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/detect.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/detect.rs @@ -36,7 +36,7 @@ pub(in crate::commands::compile) fn is_commonjs(source: &str) -> bool { let stripped = strip_comments_and_strings(source); // ESM-at-the-top wins: a top-level `import`/`export` makes this an // ES module regardless of CJS patterns appearing deeper in the file. - if has_top_level_esm(&stripped) { + if has_top_level_esm(&stripped) || has_import_meta(&stripped) { return false; } if stripped.contains("module.exports") @@ -69,6 +69,28 @@ pub(in crate::commands::compile) fn is_commonjs(source: &str) -> bool { stripped.contains("require(") && !stripped.contains("import ") } +/// `import.meta` also makes a file ESM, including Bun bundles whose only +/// imports are synchronous `import.meta.require` calls. Do not wrap those as +/// CommonJS and accidentally hoist their chunk dependencies. +fn has_import_meta(source: &str) -> bool { + fn is_ident(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' + } + source.match_indices("import").any(|(start, _)| { + let before = &source[..start]; + if before.ends_with(is_ident) || before.trim_end().ends_with('.') { + return false; + } + let after = source[start + "import".len()..].trim_start(); + after.strip_prefix('.').is_some_and(|after| { + after + .trim_start() + .strip_prefix("meta") + .is_some_and(|after| !after.starts_with(is_ident)) + }) + }) +} + /// Issue #5275: detect a bracket / computed-string-literal CJS export /// assignment — `module['exports'] = …`, `module["exports"] = …`, /// `exports['name'] = …`, `exports["name"] = …`, and the @@ -514,3 +536,26 @@ pub fn is_js_reserved_word(name: &str) -> bool { | "await" ) } + +#[cfg(test)] +mod import_meta_tests { + use super::is_commonjs; + + #[test] + fn import_meta_require_is_esm_without_import_declarations() { + for source in [ + "const chunk = import.meta.require('./chunk.js');", + "const chunk = import.meta['require']('./chunk.js');", + "function load() { return import /* gap */ . meta.require('./chunk.js'); }", + ] { + assert!(!is_commonjs(source), "{source}"); + } + for source in [ + "const text = 'import.meta'; module.exports = require('./chunk.js');", + "// import.meta\nmodule.exports = require('./chunk.js');", + "const text = object.import.meta; module.exports = require('./chunk.js');", + ] { + assert!(is_commonjs(source), "{source}"); + } + } +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index c50faaee4b..ddaaece7a3 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -661,7 +661,7 @@ fn collect_module_one( collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), ..Default::default() }); - let lower_result = perry_hir::lower_module_full( + let lower_result = perry_hir::lower_module_full_with_platform_globals( ast_module, &module_name, &source_file_path, @@ -671,6 +671,7 @@ fn collect_module_one( imported_class_accessors, is_entry_module, is_external_module, + if ctx.bun_platform { &["Bun"] } else { &[] }, ); progress.heartbeat(ProgressSnapshot { stage: "lower", @@ -930,13 +931,24 @@ fn collect_module_one( // doesn't misclassify multi-line filenames). let eval_mode = *is_eval; let mut visiting: std::collections::HashSet = std::collections::HashSet::new(); - match perry_hir::resolve_import_path_with_context( - filename.as_ref(), - &module_const_locals, - &dynamic_param_literals, - &dynamic_local_literals, - &mut visiting, - ) { + let resolution = if eval_mode { + perry_hir::resolve_import_path_with_context( + filename.as_ref(), + &module_const_locals, + &dynamic_param_literals, + &dynamic_local_literals, + &mut visiting, + ) + } else { + perry_hir::resolve_worker_path( + filename.as_ref(), + &hir_module, + &module_const_locals, + &dynamic_param_literals, + &dynamic_local_literals, + ) + }; + match resolution { perry_hir::Resolution::Set(mut set) => { if !eval_mode && set.len() > perry_hir::DYNAMIC_IMPORT_PATH_CAP { dyn_errors.push(format!( @@ -974,6 +986,24 @@ fn collect_module_one( return; } } + } else if set[0].starts_with("file:") { + // Helper-returned URLs carry a URL spelling, while the + // module resolver (including --bunfs-root) consumes a + // filesystem spelling. Decode through the URL parser + // before recording both the import edge and Worker path. + match url::Url::parse(&set[0]) + .ok() + .and_then(|url| url.to_file_path().ok()) + { + Some(path) => set[0] = path.to_string_lossy().into_owned(), + None => { + dyn_errors.push(format!( + "worker_threads Worker in module {}: invalid file URL {:?}", + module_name, set[0] + )); + return; + } + } } for p in &set { if !new_dyn_imports.contains(p) { diff --git a/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs index 7a68082bac..ba7c8c9682 100644 --- a/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs +++ b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs @@ -215,6 +215,7 @@ struct RequireCall { start: usize, end: usize, specifier: Option, + direct: bool, } struct CallScan<'a> { @@ -226,10 +227,11 @@ struct CallScan<'a> { impl Visit for CallScan<'_> { fn visit_call_expr(&mut self, call: &ast::CallExpr) { + let direct = matches!(&call.callee, ast::Callee::Expr(callee) + if is_import_meta_member(callee, "require")); let recognized = match &call.callee { ast::Callee::Expr(callee) => { - is_import_meta_member(callee, "require") - || identifier_id(callee).is_some_and(|id| self.aliases.contains(&id)) + direct || identifier_id(callee).is_some_and(|id| self.aliases.contains(&id)) } _ => false, }; @@ -241,6 +243,7 @@ impl Visit for CallScan<'_> { start: call.span.lo.0.saturating_sub(1) as usize, end: call.span.hi.0.saturating_sub(1) as usize, specifier, + direct, }); } call.visit_children_with(self); @@ -316,6 +319,13 @@ pub(super) fn rewrite_import_meta_require_addons( let process_alias = unique_identifier(source, "__perry_import_meta_process", 0); for (index, call) in calls.calls.into_iter().enumerate() { let Some(specifier) = call.specifier else { + // #9742: direct calls can load ordinary JS chunks. Let the HIR + // synchronous-require resolver handle their bounded candidate set + // and runtime fallback; an unknown path is not proof of an addon. + // Aliased addon loading keeps its existing declaration diagnostic. + if call.direct { + continue; + } anyhow::bail!( "cannot statically prove the Node-API addon path passed to `import.meta.require` in {}. Declare every project-owned addon with an exact `perry.nativeAddonPaths` entry and call the unmodified binding with a string literal or `new URL(\"./addon.node\", import.meta.url).pathname`.", module_path.display() @@ -437,6 +447,18 @@ const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pa assert!(error.contains("perry.nativeAddonPaths"), "{error}"); } + #[test] + fn direct_runtime_paths_reach_synchronous_module_dispatch() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let source = "import.meta.require(process.argv[2]); import.meta['require'](choosePath());"; + assert_eq!( + rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(), + source + ); + assert!(ctx.native_addons.is_empty()); + } + #[test] fn does_not_follow_a_modified_binding() { let (dir, relative_entry, mut ctx) = fixture(); diff --git a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs index e7e9fc99ba..2f9db242b7 100644 --- a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs +++ b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs @@ -51,10 +51,7 @@ pub(super) fn transform_static_literal_requires_with_bunfs( // `// fallback: require("./generated")` outside a `try` would // flip a genuinely optional specifier back to mandatory and // reintroduce the #6873 hard error. - if masked_source[full.start()..full.end()] - .bytes() - .all(|b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n') - { + if !is_bare_call(&masked_source, full.start(), full.end()) { continue; } let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); @@ -76,10 +73,7 @@ pub(super) fn transform_static_literal_requires_with_bunfs( let Some(full) = cap.name("call") else { continue; }; - if masked_source[full.start()..full.end()] - .bytes() - .all(|b| b.is_ascii_whitespace()) - { + if !is_bare_call(&masked_source, full.start(), full.end()) { continue; } let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); @@ -95,6 +89,12 @@ pub(super) fn transform_static_literal_requires_with_bunfs( } let call_re = literal_require_call_re(&alias); for cap in call_re.captures_iter(source) { + let Some(full) = cap.name("call") else { + continue; + }; + if !is_bare_call(&masked_source, full.start(), full.end()) { + continue; + } let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); let require_target = resolve_static_require(module_dir, specifier, bunfs_root); if should_leave_runtime_require(specifier, compile_packages) { @@ -133,15 +133,6 @@ pub(super) fn transform_static_literal_requires_with_bunfs( if optional_specs.get(specifier).copied().unwrap_or(false) && require_target.is_none() { continue; } - let Some(full) = cap.name("call") else { - continue; - }; - if masked_source[full.start()..full.end()] - .bytes() - .all(|b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n') - { - continue; - } // CJS, JSON, native addons, and custom extensions need the runtime // `require` path: it owns Node's cache/record/extension-hook // semantics. The side-effect import only makes the statically-known @@ -186,6 +177,16 @@ pub(super) fn transform_static_literal_requires_with_bunfs( prepend_imports_preserving_shebang(&transformed, &imports) } +fn is_bare_call(masked_source: &str, start: usize, end: usize) -> bool { + // The regex excludes an adjacent dot, but whitespace/comments can separate + // it from the method name. Keep `import.meta . require(...)` and ordinary + // object methods intact; only identifier calls belong to this transform. + !masked_source[..start].trim_end().ends_with('.') + && !masked_source[start..end] + .bytes() + .all(|b| b.is_ascii_whitespace()) +} + pub(super) fn resolve_static_require( module_dir: &Path, specifier: &str, @@ -607,6 +608,24 @@ fn is_identifier(value: &str) -> bool { mod tests { use super::*; + #[test] + fn member_calls_with_whitespace_are_not_bare_requires() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("dep.js"), "export const answer = 42;").unwrap(); + for source in [ + "const dep = import.meta . require('./dep.js');", + "const dep = import.meta. /* gap */ require('./dep.js');", + "const dep = object . require('./dep.js');", + "const dep = object . require.resolve('./dep.js');", + "// require('./dep.js')\nexport {};", + ] { + assert_eq!( + transform_static_literal_requires(source, &HashSet::new(), dir.path()), + source + ); + } + } + #[test] fn hoists_direct_relative_literal_require() { let source = r#" diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 36d7643a7b..1eb33e6edd 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -36,6 +36,16 @@ pub fn djb2_hash(bytes: &[u8]) -> u64 { hash } +/// `PERRY_OBJECT_CACHE_BUILD_ID=` pins the build id +/// component of the object-cache key. A compiler built from a runtime-only +/// branch can then reuse the objects a sibling build cached under the same +/// HIR and options (relink workflows: ~3 min link instead of a 40 min +/// codegen of a 13 MB bundle). Codegen changes still miss through the +/// `hir`/option fields; an unparsable value is ignored. +fn pinned_build_id(raw: Option) -> Option { + raw.and_then(|v| u64::from_str_radix(v.trim(), 16).ok()) +} + /// Hash of the running `perry` executable, computed once per process. /// /// `CARGO_PKG_VERSION` only invalidates the cache on a version bump; during @@ -53,6 +63,9 @@ pub fn djb2_hash(bytes: &[u8]) -> u64 { fn perry_build_id() -> u64 { static BUILD_ID: OnceLock = OnceLock::new(); *BUILD_ID.get_or_init(|| { + if let Some(pinned) = pinned_build_id(std::env::var("PERRY_OBJECT_CACHE_BUILD_ID").ok()) { + return pinned; + } std::env::current_exe() .ok() .and_then(|p| fs::read(&p).ok()) diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index ac035d7fe2..9bea93ecbf 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -1137,3 +1137,15 @@ fn toml_overrides_pkg_via_readers_and_resolver() { let resolved = resolve_cache_dir(root.path(), chosen.as_deref()); assert_eq!(resolved, root.path().join("toml-cache")); } + +#[test] +fn pinned_build_id_parses_hex_and_ignores_garbage() { + assert_eq!( + super::pinned_build_id(Some(" 5458fd55d49a4640\n".to_string())), + Some(0x5458_fd55_d49a_4640) + ); + assert_eq!(super::pinned_build_id(Some("0".to_string())), Some(0)); + assert_eq!(super::pinned_build_id(Some("not-hex".to_string())), None); + assert_eq!(super::pinned_build_id(Some(String::new())), None); + assert_eq!(super::pinned_build_id(None), None); +} diff --git a/crates/perry/tests/issue_9325_ws_server_clients.rs b/crates/perry/tests/issue_9325_ws_server_clients.rs index a815c48c7a..ece5503213 100644 --- a/crates/perry/tests/issue_9325_ws_server_clients.rs +++ b/crates/perry/tests/issue_9325_ws_server_clients.rs @@ -27,7 +27,6 @@ fn compile_and_run(dir: &Path, source: &str) -> String { .arg("-o") .arg(&output) .arg("--no-cache") - .env_remove("PERRY_NO_AUTO_OPTIMIZE") .env("PERRY_WORKSPACE_ROOT", workspace_root()) .output() .expect("run perry compile"); @@ -69,6 +68,7 @@ console.log(typeof first[Symbol.iterator]); let count = 0; for (const _client of first) count += 1; console.log(first === second, count, first.size); +wss.close(); "#, ); diff --git a/crates/perry/tests/issue_9599_bun_platform.rs b/crates/perry/tests/issue_9599_bun_platform.rs index 4023a218df..1213a47ba3 100644 --- a/crates/perry/tests/issue_9599_bun_platform.rs +++ b/crates/perry/tests/issue_9599_bun_platform.rs @@ -7,7 +7,7 @@ fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } -fn compile(dir: &Path, platform: Option<&str>) -> PathBuf { +fn compile(dir: &Path, platform: Option<&str>) -> (PathBuf, String) { let entry = dir.join("main.ts"); let output = dir.join("main_bin"); let mut command = Command::new(perry_bin()); @@ -27,7 +27,10 @@ fn compile(dir: &Path, platform: Option<&str>) -> PathBuf { String::from_utf8_lossy(&compile.stdout), String::from_utf8_lossy(&compile.stderr) ); - output + ( + output, + String::from_utf8_lossy(&compile.stderr).into_owned(), + ) } fn run(output: &Path, dir: &Path) -> String { @@ -58,7 +61,11 @@ console.log(typeof globalThis["Bun"]); ) .expect("write entry"); - let output = compile(dir.path(), None); + let (output, diagnostics) = compile(dir.path(), None); + assert!( + !diagnostics.contains("unknown identifier 'Bun'"), + "{diagnostics}" + ); assert_eq!( run(&output, dir.path()), "undefined\nundefined\nundefined\n" @@ -111,7 +118,11 @@ console.log(scoped()); ) .expect("write entry"); - let output = compile(dir.path(), Some("bun")); + let (output, diagnostics) = compile(dir.path(), Some("bun")); + assert!( + !diagnostics.contains("unknown identifier 'Bun'"), + "{diagnostics}" + ); let expected = "\ dependency object true object @@ -132,3 +143,49 @@ true true true "; assert_eq!(run(&output, dir.path()), expected); } + +/// #9745: platform-provided globals change diagnostics, while every read +/// still follows globalThis and lexical bindings retain precedence. +#[test] +fn bun_platform_only_suppresses_warnings_for_its_unshadowed_global() { + for platform in [None, Some("bun")] { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.ts"), + r#" +(globalThis as any).Bun = { marker: 17 }; +(globalThis as any).platformDiagnosticProbe = { marker: 23 }; +const key = "marker"; +const { marker } = Bun; +console.log(Bun === globalThis.Bun, typeof Bun, Bun[key], marker); +console.log(platformDiagnosticProbe[key]); +try { new Bun(); } catch (error) { console.log(error instanceof TypeError); } +function scoped(Bun: any) { + const { marker } = Bun; + console.log(typeof Bun, Bun[key], marker); +} +scoped({ marker: 99 }); +{ + const Bun = { marker: 31 }; + const { marker } = Bun; + console.log(typeof Bun, Bun[key], marker); +} +"#, + ) + .expect("write entry"); + let (output, diagnostics) = compile(dir.path(), platform); + assert_eq!( + diagnostics.contains("unknown identifier 'Bun'"), + platform.is_none(), + "only Bun-platform mode knows the supplied global: {diagnostics}" + ); + assert!( + diagnostics.contains("unknown identifier 'platformDiagnosticProbe'"), + "other unknown names must still warn: {diagnostics}" + ); + assert_eq!( + run(&output, dir.path()), + "true object 17 17\n23\ntrue\nobject 99 99\nobject 31 31\n" + ); + } +} diff --git a/crates/perry/tests/issue_9619_ws_server_upgrades.rs b/crates/perry/tests/issue_9619_ws_server_upgrades.rs new file mode 100644 index 0000000000..f99f9e6df0 --- /dev/null +++ b/crates/perry/tests/issue_9619_ws_server_upgrades.rs @@ -0,0 +1,180 @@ +//! Exercise real HTTP and WebSocket traffic through compiled native bindings. +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const SOURCE: &str = r#" +import { createServer } from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +const mode = "@MODE@"; +const total = @TOTAL@; +let connections = 0, callbacks = 0, messages = 0, opened = 0, errors = 0; +let urls = 0, members = 0, listening = 0; +const http = createServer((req, res) => res.end("http-ok")); +const wss = new WebSocketServer(@OPTIONS@); +const clients = wss.clients; +if (mode === "manual" || mode === "callback-only") { + let message = ""; + try { wss.address(); } catch (error) { message = error.message; } + if (message !== 'The server is operating in "noServer" mode') throw new Error("noServer address"); +} +const watchdog = setTimeout(() => { + console.log("timeout", connections, callbacks, messages, opened, errors, listening); + process.exit(1); +}, 10000); +function emitConnection(server: any, event: string, ws: any, req: any) { + return server[event]("connection", ws, req); +} +function sendHello(ws: any) { ws.send("hello"); } +wss.on("connection", (ws, req) => { + connections++; + if (mode === "ephemeral" || req.url === "/v1/ws?token=ok") urls++; + if (clients.has(ws)) members++; + sendHello(ws); +}); +if (mode === "manual" || mode === "callback-only") { + http.on("upgrade", (req, socket, head) => { + if (req.url.split("?")[0] !== "/v1/ws") return; + const result = wss.handleUpgrade(req, socket, head, (ws, request) => { + callbacks++; + if (request !== req) throw new Error("request identity"); + if (mode === "manual") { + if (!emitConnection(wss, "emit", ws, req)) throw new Error("emit return"); + } else { sendHello(ws); } + }); + if (result !== undefined) throw new Error("handleUpgrade return"); + }); +} +function connect(port: number) { + for (let i = 0; i < total; i++) { + const client = new WebSocket("ws://127.0.0.1:" + port + "/v1/ws?token=ok"); + client.on("open", () => { opened++; }); + client.on("error", () => { errors++; }); + client.on("message", (data) => { + if (data.toString() !== "hello") throw new Error("message payload"); + messages++; + client.close(); + if (messages === total) { + setTimeout(async () => { + console.log("counts", connections, callbacks, messages, opened, errors, urls, members, listening); + wss.close(); + if (mode === "attached") { + const response = await fetch("http://127.0.0.1:" + port + "/after-ws-close"); + console.log("detached-http", await response.text()); + } + if (mode !== "ephemeral") http.close(); + clearTimeout(watchdog); + }, 30); + } + }); + } +} +wss.on("listening", () => { + listening++; + const address = wss.address(); + console.log("address", address.address === "127.0.0.1", address.family === "IPv4", address.port > 0); + if (mode === "ephemeral") connect(address.port); +}); +if (mode !== "ephemeral") { + http.listen(0, "127.0.0.1", async () => { + const port = http.address().port; + console.log("http", await (await fetch("http://127.0.0.1:" + port + "/")).text()); + if (mode === "attached") console.log("shared-port", wss.address().port === port); + connect(port); + }); +} +"#; + +fn run(mode: &str, options: &str, total: usize) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let source = SOURCE + .replace("@MODE@", mode) + .replace("@OPTIONS@", options) + .replace("@TOTAL@", &total.to_string()); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main"); + std::fs::write(&entry, source).unwrap(); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let compile = Command::new(env!("CARGO_BIN_EXE_perry")) + .args([ + "compile", + entry.to_str().unwrap(), + "-o", + binary.to_str().unwrap(), + "--no-cache", + ]) + .env("PERRY_WORKSPACE_ROOT", root) + .output() + .expect("compile"); + assert!( + compile.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&compile.stderr) + ); + let mut child = Command::new(binary) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "{mode} hung: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(20)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{mode} failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn attached_server_shares_http_port_and_accepts_120_clients() { + let output = run("attached", "{ clientTracking: true, server: http }", 120); + assert!(output.contains("http http-ok\n"), "{output}"); + assert!(output.contains("shared-port true\n"), "{output}"); + assert!(output.contains("detached-http http-ok\n"), "{output}"); + assert!(output.contains("address true true true\n"), "{output}"); + assert!( + output.contains("counts 120 0 120 120 0 120 120 1\n"), + "{output}" + ); +} + +#[test] +fn manual_upgrade_calls_callback_and_emits_exactly_once_for_60_clients() { + let output = run("manual", "{ maxPayload: 1024, noServer: true }", 60); + assert!(output.contains("http http-ok\n"), "{output}"); + assert!( + output.contains("counts 60 60 60 60 0 60 60 0\n"), + "{output}" + ); +} + +#[test] +fn handle_upgrade_does_not_emit_connection_without_callback_emission() { + let output = run("callback-only", "{ noServer: true }", 3); + assert!(output.contains("counts 0 3 3 3 0 0 0 0\n"), "{output}"); +} + +#[test] +fn ephemeral_port_listens_and_address_locates_the_server() { + let output = run( + "ephemeral", + "{ clientTracking: true, host: '127.0.0.1', port: 0 }", + 6, + ); + assert!(output.contains("address true true true\n"), "{output}"); + assert!(output.contains("counts 6 0 6 6 0 6 6 1\n"), "{output}"); +} diff --git a/crates/perry/tests/issue_9742_import_meta_require.rs b/crates/perry/tests/issue_9742_import_meta_require.rs new file mode 100644 index 0000000000..bec51ad40d --- /dev/null +++ b/crates/perry/tests/issue_9742_import_meta_require.rs @@ -0,0 +1,138 @@ +use std::process::Command; + +fn compile_and_run(source: &str, bunfs: bool) -> String { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("main.js"); + let binary = dir.path().join("app"); + std::fs::write(&entry, source).unwrap(); + std::fs::write( + dir.path().join("dep.js"), + "globalThis.loads = (globalThis.loads ?? 0) + 1; export const answer = 42;", + ) + .unwrap(); + std::fs::write( + dir.path().join("other.js"), + "globalThis.otherLoads = (globalThis.otherLoads ?? 0) + 1; export const answer = 99;", + ) + .unwrap(); + let mut compile = Command::new(env!("CARGO_BIN_EXE_perry")); + compile.args(["compile", "--platform", "bun"]); + if bunfs { + compile.arg("--bunfs-root").arg(dir.path()); + } + let output = compile + .arg(&entry) + .arg("-o") + .arg(&binary) + .env("PERRY_NO_CACHE", "1") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + // The executable must contain the chunks; runtime filesystem loading would + // hide a missing AOT import edge. + std::fs::remove_file(dir.path().join("dep.js")).unwrap(); + std::fs::remove_file(dir.path().join("other.js")).unwrap(); + let output = Command::new(binary).output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn relative_chunks_load_synchronously_once_at_the_call() { + let output = compile_and_run( + r#" + console.log(globalThis.loads ?? 0); + const first = import.meta.require("./dep.js"); + const second = import.meta["require"]("./dep.js"); + console.log(first.answer, second.answer, globalThis.loads); + console.log(first === second, typeof first.then); + const require = value => "local:" + value; + const object = { require(value) { return "object:" + value; } }; + console.log(require("value"), object.require("value")); + console.log(typeof import.meta.url, import.meta.main); + console.log(typeof import.meta.require("node:os").platform()); + "#, + false, + ); + assert_eq!( + output, + "0\n42 42 1\ntrue undefined\nlocal:value object:value\nstring true\nstring\n" + ); +} + +#[test] +fn bun_virtual_chunks_are_discovered_for_both_spellings() { + assert_eq!( + compile_and_run( + r#" + const first = import.meta.require("/$bunfs/root/dep.js"); + const second = import.meta["require"]("/$bunfs/root/dep.js"); + console.log(first.answer, second.answer, globalThis.loads); + "#, + true + ), + "42 42 1\n" + ); +} + +#[test] +fn computed_literal_only_entry_and_finite_choices_are_lazy() { + assert_eq!( + compile_and_run( + r#" + const path = process.argv.length > 0 ? "./dep.js" : "./other.js"; + console.log(globalThis.loads ?? 0, globalThis.otherLoads ?? 0); + const first = import.meta["require"](path); + console.log(first.answer, globalThis.loads, globalThis.otherLoads ?? 0); + "#, + false + ), + "0 0\n42 1 0\n" + ); +} + +#[test] +fn missing_and_runtime_paths_report_synchronous_require_errors() { + assert_eq!( + compile_and_run( + r#" + try { import.meta.require("./missing.js"); } + catch (error) { console.log(error.code); } + const path = process.argv[99] ?? "./missing-runtime.js"; + try { import.meta["require"](path); } + catch (error) { console.log(error.code); } + "#, + false + ), + "MODULE_NOT_FOUND\nMODULE_NOT_FOUND\n" + ); +} + +#[test] +fn whitespace_and_comments_do_not_turn_member_calls_into_eager_imports() { + assert_eq!( + compile_and_run( + r#" + console.log(globalThis.loads ?? 0); + const object = { require(value) { return "object:" + value; } }; + console.log(object . require("./dep.js")); + const first = import.meta . require("./dep.js"); + const second = import.meta. /* gap */ require("./dep.js"); + console.log(first.answer, second.answer, globalThis.loads); + "#, + false + ), + "0\nobject:./dep.js\n42 42 1\n" + ); +} diff --git a/crates/perry/tests/issue_9743_bun_jsc_heap_stats.rs b/crates/perry/tests/issue_9743_bun_jsc_heap_stats.rs new file mode 100644 index 0000000000..5d4a3065d2 --- /dev/null +++ b/crates/perry/tests/issue_9743_bun_jsc_heap_stats.rs @@ -0,0 +1,96 @@ +//! Each import form must resolve and return a usable report in a fresh executable. +use std::process::Command; + +const CHECKS: &str = include_str!("../../../test-files/_helpers/bun_jsc_heap_stats_9743.ts"); + +fn compile_and_run(preamble: &str) -> String { + compile_source(&format!("{preamble}\n{CHECKS}"), true) +} + +fn compile_source(source: &str, bun: bool) -> String { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("app"); + std::fs::write(&entry, source).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_perry")); + command.arg("compile"); + if bun { + command.args(["--platform", "bun"]); + } + let compile = command + .arg(&entry) + .arg("-o") + .arg(&binary) + .env("PERRY_NO_CACHE", "1") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .unwrap(); + let diagnostics = format!( + "{}\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + assert!(compile.status.success(), "{diagnostics}"); + assert!( + !diagnostics.contains("Could not resolve import 'bun:jsc'"), + "{diagnostics}" + ); + let run = Command::new(binary).output().unwrap(); + assert!( + run.status.success(), + "status={}\nstdout={}\nstderr={}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).unwrap() +} + +fn expected() -> &'static str { + "heapStats shapes ok\nretained 256 0 255\nheapStats growth ok\n" +} + +#[test] +fn static_and_namespace_imports_share_the_dynamic_and_require_function() { + let stdout = compile_and_run( + r#" + import { heapStats } from 'bun:jsc'; + import * as namespace from 'bun:jsc'; + const dynamic = await import('bun:jsc'); + const required = require('bun:jsc'); + if (heapStats !== namespace.heapStats || heapStats !== dynamic.heapStats || heapStats !== required.heapStats) + throw new Error('heapStats function identity differs by import form'); + "#, + ); + assert_eq!(stdout, expected()); +} + +#[test] +fn dynamic_import_exposes_heap_stats() { + assert_eq!( + compile_and_run("const { heapStats } = await import('bun:jsc');"), + expected() + ); +} + +#[test] +fn require_exposes_heap_stats() { + assert_eq!( + compile_and_run("const { heapStats } = require('bun:jsc');"), + expected() + ); +} + +#[test] +fn runtime_only_import_installs_its_dispatch_without_the_bun_global() { + let stdout = compile_source( + r#" + const { heapStats } = await import('bun:jsc'); + console.log(typeof heapStats, heapStats.length); + const stats = heapStats(true); + console.log(typeof stats.heapSize, typeof stats.objectTypeCounts); + "#, + false, + ); + assert_eq!(stdout, "function 0\nnumber object\n"); +} diff --git a/crates/perry/tests/issue_9744_static_worker_helpers.rs b/crates/perry/tests/issue_9744_static_worker_helpers.rs new file mode 100644 index 0000000000..bc042e55dc --- /dev/null +++ b/crates/perry/tests/issue_9744_static_worker_helpers.rs @@ -0,0 +1,109 @@ +//! Worker helper resolution must discover and start the real worker entry. +use std::path::Path; +use std::process::Command; + +fn compile_and_run(dir: &Path, source: &str, bun: bool) -> String { + let entry = dir.join("main.ts"); + let binary = dir.join("app"); + std::fs::write(&entry, source).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_perry")); + command + .current_dir(dir) + .args(["compile"]) + .arg(&entry) + .arg("-o") + .arg(&binary) + .env("PERRY_NO_CACHE", "1") + .env("PERRY_NO_AUTO_OPTIMIZE", "1"); + if bun { + command.args(["--platform", "bun", "--bunfs-root"]).arg(dir); + } + let compile = command.output().unwrap(); + let diagnostics = format!( + "{}\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + assert!(compile.status.success(), "{diagnostics}"); + assert!( + !diagnostics.contains("this Worker will throw"), + "{diagnostics}" + ); + let run = Command::new(binary).output().unwrap(); + assert!( + run.status.success(), + "status={}\nstdout={}\nstderr={}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).unwrap() +} + +#[test] +fn bun_global_worker_starts_from_an_embedded_file_url_helper_chain() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("worker.js"), "postMessage('ready');").unwrap(); + let stdout = compile_and_run( + dir.path(), + r#" + const embeddedWorkerUrl = (path) => new URL(`file://${path}`); + const hooksWorkerUrl = () => embeddedWorkerUrl('/$bunfs/root/worker.js'); + const worker = new Worker(hooksWorkerUrl()); + worker.onmessage = ({ data }) => { + console.log('global', data); + worker.terminate().then(() => process.exit(0)); + }; + setTimeout(() => process.exit(2), 5000); + "#, + true, + ); + assert_eq!(stdout.trim(), "global ready"); +} + +#[test] +fn node_worker_starts_through_declarations_aliases_and_url_arguments() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("_helpers")).unwrap(); + std::fs::write( + dir.path().join("_helpers/static_worker_9744.ts"), + include_str!("../../../test-files/_helpers/static_worker_9744.ts"), + ) + .unwrap(); + let stdout = compile_and_run( + dir.path(), + include_str!("../../../test-files/test_gap_9744_static_worker_helpers.ts"), + false, + ); + assert_eq!(stdout.trim(), "node ready"); +} + +#[test] +fn helper_file_urls_decode_filesystem_paths() { + let dir = tempfile::tempdir().unwrap(); + let worker = dir.path().join("worker space.ts"); + std::fs::write( + &worker, + include_str!("../../../test-files/_helpers/static_worker_9744.ts"), + ) + .unwrap(); + let url = url::Url::from_file_path(worker).unwrap(); + assert!(url.as_str().contains("%20")); + let url_literal = serde_json::to_string(url.as_str()).unwrap(); + let source = format!( + r#" + import {{ Worker }} from 'node:worker_threads'; + const entry = () => new URL({url_literal}); + const worker = new Worker(entry()); + worker.on('message', (data) => {{ + console.log('file', data); + worker.terminate().then(() => process.exit(0)); + }}); + setTimeout(() => process.exit(2), 5000); + "# + ); + assert_eq!( + compile_and_run(dir.path(), &source, false).trim(), + "file ready" + ); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index c8c478c975..fe456f0ae9 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: 2089 entries across 136 modules +// Coverage: 2092 entries across 137 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -375,6 +375,8 @@ declare module "bun" { /** stdlib */ export function build(...args: any[]): any; /** stdlib */ + export function connect(...args: any[]): any; + /** stdlib */ export function deepEquals(...args: any[]): any; /** stdlib */ export function file(...args: any[]): any; @@ -387,6 +389,8 @@ declare module "bun" { /** stdlib */ export function hash(...args: any[]): any; /** stdlib */ + export function listen(...args: any[]): any; + /** stdlib */ export function pathToFileURL(...args: any[]): any; /** stdlib */ export function serve(options: any): any; @@ -437,6 +441,11 @@ declare module "bun:ffi" { export function viewSource(...args: any[]): any; } +declare module "bun:jsc" { + /** stdlib */ + export function heapStats(...args: any[]): any; +} + declare module "bun:sqlite" { /** stdlib */ export class Database { [key: string]: any; } diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 552694501d..6d89347cfc 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: 3046 entries across 138 modules. +Total: 3051 entries across 139 modules. ## Modules @@ -29,6 +29,7 @@ Total: 3046 entries across 138 modules. - [`buffer`](#buffer) - [`bun`](#bun) - [`bun:ffi`](#bunffi) +- [`bun:jsc`](#bunjsc) - [`bun:sqlite`](#bunsqlite) - [`cheerio`](#cheerio) - [`child_process`](#child_process) @@ -429,12 +430,14 @@ Total: 3046 entries across 138 modules. - `Terminal` — module - `Transpiler` — module - `build` — module +- `connect` — module - `deepEquals` — module - `file` — module - `fileURLToPath` — module - `gc` — module - `generateHeapSnapshot` — module - `hash` — module +- `listen` — module - `pathToFileURL` — module - `scan` — instance *(class: `Transpiler`)* - `scanImports` — instance *(class: `Transpiler`)* @@ -484,6 +487,12 @@ Total: 3046 entries across 138 modules. - `read` - `suffix` +## `bun:jsc` + +### Methods + +- `heapStats` — module + ## `bun:sqlite` ### Classes @@ -4131,10 +4140,12 @@ Total: 3046 entries across 138 modules. - `Server` — module - `WebSocket` — module - `addListener` — instance *(class: `Client`)* +- `address` — instance - `clients` — instance - `close` — instance - `close` — instance *(class: `Client`)* - `closeClient` — module +- `emit` — instance - `handleUpgrade` — instance - `on` — instance - `on` — instance *(class: `Client`)* diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index e79243618d..f407739255 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -157,14 +157,21 @@ For source extracted from a Bun standalone executable, mount its extracted perry compile --bunfs-root ./fixture/root ./fixture/root/entry.js -o app ``` -Static imports, re-exports, literal dynamic imports, and literal `require()` -calls below `/$bunfs/root/` resolve against that directory. Perry canonicalizes +Static imports, re-exports, literal dynamic imports, literal `require()`, and +`import.meta.require()` calls below `/$bunfs/root/` resolve against that directory. +Perry canonicalizes their real targets, so importing the same module through `./chunk.js` and `/$bunfs/root/chunk.js` still initializes one module. Literal mapped file paths are embedded under their original names and work through both `node:fs` and `Bun.file()` after the extracted directory is removed. No host-level `/$bunfs` directory or compatibility symlink is needed. +Bun bundles can load compiled chunks synchronously with +`import.meta.require("./chunk.js")` or `import.meta["require"]("./chunk.js")`. +Both return the module namespace immediately and initialize the target once, +at its first load. Computed paths use the existing bounded synchronous +`require(expr)` resolver; unknown targets throw `MODULE_NOT_FOUND` at the call. + Some build pipelines inject a generated module rather than writing it into the source checkout. Reproduce that file-map step with `--asset-module`. Perry sorts the directory walk, preserves each `{ type: "file" }` edge, and keeps the diff --git a/docs/src/stdlib/http.md b/docs/src/stdlib/http.md index ff6b89154a..dcc6b15e08 100644 --- a/docs/src/stdlib/http.md +++ b/docs/src/stdlib/http.md @@ -175,6 +175,22 @@ Perry's Fastify implementation is API-compatible with the npm package. Routes, r {{#include ../../examples/stdlib/http/snippets.ts:websocket-client}} ``` +`new WebSocketServer({ server: httpServer })` shares an existing HTTP server's +port: ordinary requests still reach the HTTP handler and WebSocket upgrades +fire `wss.on("connection", (ws, req) => ...)`. `wss.address()` reports the +host server's address. Closing the WebSocket server detaches it without +closing the shared HTTP listener. + +For manual routing, create `new WebSocketServer({ noServer: true })` and call +`wss.handleUpgrade(req, socket, head, (ws, req) => wss.emit("connection", ws, req))` +from the HTTP server's `upgrade` listener. The callback runs once; it controls +whether the connection event is emitted. The native HTTP transport performs +the handshake before dispatching `upgrade`, as described above. + +A standalone `new WebSocketServer({ port: 0 })` binds an ephemeral port and +emits `listening`. Read `wss.address().port` inside that listener to connect to +it. Its address object contains `address`, `family`, and `port`. + ## AWS S3 / S3-Compatible Object Storage [`@bradenmacdonald/s3-lite-client`](https://github.com/bradenmacdonald/s3-lite-client) is a zero-dependency, MIT-licensed S3 client (~1.9k LoC, derived from the official MinIO JS client without the lodash/async/xml2js baggage). It compiles natively under `perry.compilePackages` with no patches required — verified against a SigV4 presigned-URL byte-for-byte match with `bun` (issue #551). diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index f28b1d7c28..6c9260fa2f 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -1,8 +1,7 @@ # Other Modules -Additional npm packages and Node.js APIs supported by Perry. All listed here -are wired through Perry's well-known native bindings registry (#466) and -compile to native code with no JavaScript runtime involvement. +Additional npm packages and runtime APIs supported by Perry. These APIs compile +to native code. ## sharp (Image Processing) @@ -213,6 +212,65 @@ onmessage = (event) => { }; ``` + +## bun:jsc + +`heapStats()` and `heapStats(true)` return memory diagnostics for the calling +thread. Static imports, dynamic imports, and `require("bun:jsc")` expose the same +function. The optional argument is accepted for Bun compatibility and ignored. + +```typescript,no-test +import { heapStats } from "bun:jsc"; +const stats = heapStats(); +console.log(stats.heapSize, stats.objectCount, stats.objectTypeCounts); +``` + +The [Bun HeapStats API](https://bun.com/reference/bun/jsc/HeapStats) uses +JavaScriptCore measurements. Perry supplies its own heap and allocator counters: + +| Field | Perry meaning | +| --- | --- | +| `heapSize` | Allocated arena bytes plus tracked malloc GC blocks, including headers. | +| `heapCapacity` | Reserved arena bytes plus tracked malloc GC blocks; at least `heapSize`. | +| `extraMemorySize` | Zero; external backing storage is not separately measured. | +| `objectCount` | Allocated GC cells found by the heap walk. | +| `objectTypeCounts` | Cell counts keyed by Perry's GC type names. | +| `protectedObjectCount` | Pinned GC cells, approximating native protection. | +| `protectedObjectTypeCounts` | Pinned cell counts by Perry GC type. | +| `globalObjectCount` | One context for the calling thread. | +| `protectedGlobalObjectCount` | Zero; protected globals are not separately counted. | +| `mimalloc` | Object with `arenaUsed`, `arenaReserved`, `gcMallocBytes`, and `gcMallocObjectCount`. | + +All counters are finite, non-negative numbers; sizes are bytes. The snapshot +covers the calling thread's heap and does not force collection. It excludes free +slots and forwarding headers, but can include garbage awaiting collection or +unreclaimed arena residents. For comparisons, retain the objects of interest +and call `gc(true)` from `bun` before each measurement. The returned report is a +snapshot taken before allocating the report itself. + +Only `heapStats` is implemented from this module. + +Worker entries can also pass through small module-local helper chains: + +```typescript,no-test +const workerUrl = (name: string) => new URL(`./${name}.ts`, import.meta.url); +function entry() { return workerUrl("worker"); } +const worker = new Worker(entry()); +``` + +Helpers must have simple parameters, static string or URL arguments, and a body +containing only one return expression (including concise arrows). Supported +return expressions include string concatenation, static path operations, module +URLs, and bounded path registries. Reassigned bindings, effectful bodies, opaque +calls, and recursion remain unresolved. URL arguments can be passed through +helpers, but coercing them to strings is outside this static subset. + +Collection must identify exactly one entry per Worker. Helper resolution is +bounded to 64 candidates, 64 expression levels, 4096 resolution steps, and 64 KiB +per path; unsupported or over-budget helpers emit a diagnostic and throw if the +Worker is constructed. The original filename expression still runs at runtime. +Static `file:` URLs are decoded before file lookup, including Bun embedded paths +such as `file:///$bunfs/root/worker.js` mapped through `--bunfs-root`. ## commander (CLI Parsing) ```typescript,no-test diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 42a17ec138..169f1cfc50 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -19,7 +19,18 @@ " open_gap - a real unrooted GC pointer. `issue` says where it is tracked; this", " verdict FAILS while old-page relocation ships enabled.", " unverified - enumerated, verdict NOT established. This also FAILS: an unknown", - " movable-address contract cannot be a production exemption." + " movable-address contract cannot be a production exemption.", + "", + " non_moving_snapshot - real GC addresses deliberately untraced inside one non-moving", + " collector window. window names start/end/owner functions and", + " SHA-256 source pins for the holder and reviewed control flow.", + " Source changes or new holder/boundary references fail the gate;", + " re-audit the window before updating a pin. This is a review", + " contract, not a static proof of arbitrary callees being safe.", + "", + "#9740 extends the existing rule-T identity ratchet to raw TLS as well as Perry TLS.", + "The newly visible historical frontier is debt, not a safety verdict or scanner exemption.", + "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ { @@ -237,12 +248,75 @@ "verdict": "not_a_gc_pointer", "why": "Monotonic id counter for READ_LINES_REGISTRY keys. The registry's own heap values are visited by scan_filehandle_roots_mut (fs/filehandle.rs:65), reached from scan_fs_handle_roots_mut." }, + { + "file": "crates/perry-runtime/src/gc/census.rs", + "name": "ARMED", + "verdict": "not_a_gc_pointer", + "why": "Boolean census request latch, set by census_arm and consumed at full-sweep entry; contains no address or JS value." + }, + { + "file": "crates/perry-runtime/src/gc/census.rs", + "name": "LABEL", + "verdict": "not_a_gc_pointer", + "why": "Borrowed static Rust label (manual/signal) for the requested census, not a GC string or a NaN-boxed JS value." + }, + { + "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.", + "window": { + "start": { + "file": "crates/perry-runtime/src/gc/census.rs", + "function": "census_pass1_if_armed" + }, + "end": { + "file": "crates/perry-runtime/src/gc/census.rs", + "function": "census_take_if_armed_at_full_sweep_start" + }, + "owner": { + "file": "crates/perry-runtime/src/gc/cycle.rs", + "function": "run_to_completion" + }, + "sources": { + "crates/perry-runtime/src/gc/census.rs": "0d5c6fcec6500692f702c8e7422e255b224de968febdb441b235b14358684b00", + "crates/perry-runtime/src/gc/cycle.rs": "4acea623de941aac70d38a4c993a3cd23135152e51f24b11bacd3d68e2571208", + "crates/perry-runtime/src/gc/mod.rs": "6d138d48e496160e711fa4389f5fd9eb12787e0a869b87da3e5a8c27719ef3ee", + "crates/perry-runtime/src/gc/policy.rs": "319ed42f1a985c88f6362657a08518077283fe5216d6055fc82343b34dec50f9", + "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" + } + } + }, + { + "file": "crates/perry-runtime/src/gc/census.rs", + "name": "SEQ", + "verdict": "not_a_gc_pointer", + "why": "u32 JSON census sequence counter, incremented once per emitted report; it never carries an object address." + }, + { + "file": "crates/perry-runtime/src/gc/census.rs", + "name": "TEST_PATH_OVERRIDE", + "verdict": "test_only", + "why": "Declared under cfg(test); holds an explicitly leaked Rust path string used by the isolated census unit tests." + }, { "file": "crates/perry-runtime/src/gc/trace.rs", "name": "FORWARDED_STUB_MEMBERSHIP_RECOVERIES", "verdict": "not_a_gc_pointer", "why": "#9717: monotonic count of array-growth forwarding stubs a budgeted full cycle admitted through `classifier_valid_object_start`, reported as `forwarded_stub_recoveries=` on the PERRY_GC_DIAG `[gc-incremental]` line. A `Cell` holding a tally, never an address \u2014 the stubs it counts are reached through the worklist, not retained here. Nothing for the collector." }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "name": "IC_DIAG", + "verdict": "not_a_gc_pointer", + "why": "Inline-cache miss diagnostics (`PERRY_IC_DIAG`). `IcDiag` is two `Instant`s, three counters, and `sites: HashMap` whose KEY is a PIC cache-slot address \u2014 malloc'd arena storage from `field_get_set/ic_slot.rs`, never GC heap \u2014 and whose value is a `String` plus counters. Nothing here is a managed pointer." + }, + { + "file": "crates/perry-runtime/src/hot_diag.rs", + "name": "REGEX_DIAG", + "verdict": "not_a_gc_pointer", + "why": "RegExp construction/exec diagnostics (`PERRY_REGEX_DIAG`), off unless armed. Every field is a counter or an `Instant` except `per_pattern: HashMap`, whose VALUE is Rust-owned (an owned prefix `String`, a flags `String`, counters). The KEY is a pattern `StringHeader` address, so it is a heap address \u2014 but it is used ONLY as an opaque grouping id and is never dereferenced: `PatStat::prefix` and `byte_len` are filled from the `&[u8]` argument at first insert, never by reading the key. Nothing here is traced, rooted or rewritten. The one consequence of the address being reused after a pattern dies is that two patterns' diagnostic counters merge into one row \u2014 an inaccuracy in an off-by-default diagnostic, with no collector implication. Distinct from `PASS1_MARKED`, whose addresses ARE walked and which therefore carries `non_moving_snapshot` with a pinned window." + }, { "file": "crates/perry-runtime/src/map.rs", "name": "MAP_COMPACTION_LOG", @@ -1967,7 +2041,7 @@ "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." } ], - "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core perry_thread_local! declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", + "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", "frontier": [ { "file": "crates/perry-runtime/src/array/element_shape.rs", @@ -2562,6 +2636,1078 @@ { "file": "crates/perry-runtime/src/yoga.rs", "name": "GC_SCANNER_REGISTERED" + }, + { + "file": "crates/perry-runtime/src/abi_trampoline.rs", + "name": "SEEN_FROM_CALLEE" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "ACTIVE_SURVIVOR" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "ARENA" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "BLOCK_POOL" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "BLOCK_POOL_BYTES" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "FORCE_BLOCK_ALLOC_FAILURE" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "GC_TRIGGER_BORROW_DEPTH" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "GC_TRIGGER_CALLS" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "INLINE_STATE" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "LONGLIVED_ARENA" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "OLD_ARENA" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "OLD_GEN_IN_USE_BYTES" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "SURVIVOR_ARENA_0" + }, + { + "file": "crates/perry-runtime/src/arena/block.rs", + "name": "SURVIVOR_ARENA_1" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "OLD_GEN_PAGE_DIRTY_EPOCH" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "OLD_GEN_RECLAIM_POOLED_BYTES" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "OLD_GEN_RECLAIM_RETURNED_BYTES" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "OLD_GEN_RECLAIM_REUSABLE_BYTES" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "OLD_PAGE_META_SNAPSHOT_CALLS" + }, + { + "file": "crates/perry-runtime/src/arena/page_meta.rs", + "name": "PAGE_GENERATION_CACHE" + }, + { + "file": "crates/perry-runtime/src/array/element_shape.rs", + "name": "ARRAY_SUBCLASS_PREFIX_STORE_HITS" + }, + { + "file": "crates/perry-runtime/src/array/element_shape.rs", + "name": "EXACT_SHAPE_STORE_HITS" + }, + { + "file": "crates/perry-runtime/src/array/indexing.rs", + "name": "STRICT_DENSE_POINTER_OVERWRITE_HITS" + }, + { + "file": "crates/perry-runtime/src/array/indexing_support.rs", + "name": "KEYS_ARRAY_SLOT_FALLBACKS" + }, + { + "file": "crates/perry-runtime/src/async_context.rs", + "name": "HANDLE_GENERATIONS" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "name": "HOOK_CALLBACK_DEPTH" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "name": "PENDING_HOOK_STATES" + }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "name": "REGISTERED" + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "name": "TEST_BUFFER_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/buffer/header.rs", + "name": "TEST_UINT8ARRAY_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/builtins/console.rs", + "name": "CONSOLE_COUNTERS" + }, + { + "file": "crates/perry-runtime/src/builtins/console.rs", + "name": "CONSOLE_GROUP_INDENT" + }, + { + "file": "crates/perry-runtime/src/builtins/console.rs", + "name": "CONSOLE_TIMERS" + }, + { + "file": "crates/perry-runtime/src/builtins/formatting.rs", + "name": "INSPECT_CIRCULAR" + }, + { + "file": "crates/perry-runtime/src/child_process/reactor.rs", + "name": "CP_PUMPING" + }, + { + "file": "crates/perry-runtime/src/child_process/v8_serde.rs", + "name": "DESERIALIZERS" + }, + { + "file": "crates/perry-runtime/src/child_process/v8_serde.rs", + "name": "SERIALIZERS" + }, + { + "file": "crates/perry-runtime/src/closure/dispatch/errors.rs", + "name": "THROW_NOT_CALLABLE_COUNT" + }, + { + "file": "crates/perry-runtime/src/closure/registry.rs", + "name": "RESOLVE_STRATEGY_SLOW_CALLS" + }, + { + "file": "crates/perry-runtime/src/cluster.rs", + "name": "CLUSTER_GC_REGISTERED" + }, + { + "file": "crates/perry-runtime/src/dyn_eval/mod.rs", + "name": "CALL_DEPTH" + }, + { + "file": "crates/perry-runtime/src/dyn_eval/mod.rs", + "name": "FN_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/dyn_eval/mod.rs", + "name": "NEXT_FN_ID" + }, + { + "file": "crates/perry-runtime/src/dyn_eval/mod.rs", + "name": "SOURCE_FN_CACHE" + }, + { + "file": "crates/perry-runtime/src/dyn_eval/mod.rs", + "name": "SOURCE_FN_CACHE_BYTES" + }, + { + "file": "crates/perry-runtime/src/eh.rs", + "name": "EXC_OBJECT" + }, + { + "file": "crates/perry-runtime/src/eh_walker.rs", + "name": "PREDICTION" + }, + { + "file": "crates/perry-runtime/src/error.rs", + "name": "CURRENT_CALL_LOCATION" + }, + { + "file": "crates/perry-runtime/src/error.rs", + "name": "INTERNED_ERROR_CODES" + }, + { + "file": "crates/perry-runtime/src/error.rs", + "name": "RUNTIME_SOURCE_LOCATION" + }, + { + "file": "crates/perry-runtime/src/event_pump.rs", + "name": "SPIN_STREAK" + }, + { + "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch.rs", + "name": "GLOB_ITERATORS" + }, + { + "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch.rs", + "name": "NEXT_GLOB_ITERATOR_ID" + }, + { + "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch.rs", + "name": "NEXT_WATCH_ID" + }, + { + "file": "crates/perry-runtime/src/fs/dir_glob_watch/watch.rs", + "name": "WATCH_FILE_PATHS" + }, + { + "file": "crates/perry-runtime/src/fs/filehandle.rs", + "name": "NEXT_STREAM_ITER_ID" + }, + { + "file": "crates/perry-runtime/src/fs/filehandle.rs", + "name": "NEXT_WRITER_ID" + }, + { + "file": "crates/perry-runtime/src/fs/mod.rs", + "name": "DIR_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/fs/mod.rs", + "name": "FD_APPEND_MODE" + }, + { + "file": "crates/perry-runtime/src/fs/mod.rs", + "name": "FD_PATHS" + }, + { + "file": "crates/perry-runtime/src/fs/mod.rs", + "name": "FD_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/fs/mod.rs", + "name": "NEXT_DIR_ID" + }, + { + "file": "crates/perry-runtime/src/fs/stream.rs", + "name": "FS_STREAM_NEXT_ID" + }, + { + "file": "crates/perry-runtime/src/fs/stream.rs", + "name": "FS_UTF8_STREAM_NEXT_ID" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "CONS_PINNED" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "DIRTY_OLD_PAGES" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "EVER_DIRTY_OLD_PAGES" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "EXTERNAL_DIRTY_SLOT_PAGES" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "GC_BIRTH_EXTRA_FLAGS" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "INCREMENTAL_MARK_BARRIER_MINOR_ONLY" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "INCREMENTAL_MARK_BARRIER_VALID_PTRS" + }, + { + "file": "crates/perry-runtime/src/gc/barrier/mod.rs", + "name": "REMEMBERED_SET" + }, + { + "file": "crates/perry-runtime/src/gc/barrier_arming.rs", + "name": "RECONSTRUCT_CENSUS" + }, + { + "file": "crates/perry-runtime/src/gc/barrier_arming.rs", + "name": "REMEMBERED_SET_RECONSTRUCTED" + }, + { + "file": "crates/perry-runtime/src/gc/barrier_arming.rs", + "name": "TEST_ARMED_OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/gc/copying.rs", + "name": "UNTRACED_DECLINE_REASON" + }, + { + "file": "crates/perry-runtime/src/gc/cycle_malloc_trim.rs", + "name": "TEST_MALLOC_TRIM_CALLS" + }, + { + "file": "crates/perry-runtime/src/gc/cycle_malloc_trim.rs", + "name": "TEST_MALLOC_TRIM_EXECUTED" + }, + { + "file": "crates/perry-runtime/src/gc/cycle_malloc_trim.rs", + "name": "TEST_MIMALLOC_PURGES" + }, + { + "file": "crates/perry-runtime/src/gc/fromspace_scan.rs", + "name": "SNAPSHOT_PAGES" + }, + { + "file": "crates/perry-runtime/src/gc/layout.rs", + "name": "SHAPE_LAYOUTS" + }, + { + "file": "crates/perry-runtime/src/gc/layout.rs", + "name": "TRACE_SLOT_READS" + }, + { + "file": "crates/perry-runtime/src/gc/layout.rs", + "name": "TYPED_RAW_F64_DESCRIPTOR_QUERIES" + }, + { + "file": "crates/perry-runtime/src/gc/layout.rs", + "name": "TYPED_SLOT_DESCRIPTOR_PROBES" + }, + { + "file": "crates/perry-runtime/src/gc/layout_tables.rs", + "name": "LAYOUT_SLOT_MASKS" + }, + { + "file": "crates/perry-runtime/src/gc/layout_tables.rs", + "name": "PER_OBJECT_LAYOUTS_NONEMPTY" + }, + { + "file": "crates/perry-runtime/src/gc/layout_tables.rs", + "name": "TYPED_LAYOUTS" + }, + { + "file": "crates/perry-runtime/src/gc/malloc.rs", + "name": "ARENA_FREE_LIST" + }, + { + "file": "crates/perry-runtime/src/gc/malloc.rs", + "name": "ARENA_FREE_LIST_NONEMPTY" + }, + { + "file": "crates/perry-runtime/src/gc/mod.rs", + "name": "AUTO_GC_INIT_SUPPRESSED" + }, + { + "file": "crates/perry-runtime/src/gc/mod.rs", + "name": "GC_INIT_DONE" + }, + { + "file": "crates/perry-runtime/src/gc/mod.rs", + "name": "GC_SCAVENGE_TEST_OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/gc/mod.rs", + "name": "IN_EMERGENCY" + }, + { + "file": "crates/perry-runtime/src/gc/old_free.rs", + "name": "OLD_FREE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/old_free.rs", + "name": "OLD_FREE_MAP" + }, + { + "file": "crates/perry-runtime/src/gc/old_free.rs", + "name": "OLD_FREE_NONEMPTY" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_BUDGETED_CYCLE" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_BUDGETED_CYCLE_ACTIVE" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_DEFERRED_REQUEST" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_EXTERNAL_SIDE_ALLOC_PENDING" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_EXTERNAL_SIDE_LIVE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_FULL_CYCLE_PRE_IN_USE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_LAST_COLLECTION_POST_IN_USE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_LAST_FULL_ARENA_IN_USE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_LAST_OLD_RECLAIM_IN_USE_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_MAJOR_PACING_BACKOFF_SHIFT" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_MAJOR_PACING_RETAINING" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_OLD_RECLAIM_PENDING" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_SAFEPOINT_DEFER_ARENA_BASE" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_SAFEPOINT_PENDING" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_TRIGGER_ARMED" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "SURVIVOR_HANDOFF_AWAITING_MINOR" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "SURVIVOR_HANDOFF_SUPPRESSIONS" + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "TEST_PACING_ARENA_IN_USE" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "FIRST_CYCLE_PROMOTION_ATTEMPTS" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "FIRST_CYCLE_PROMOTION_ROLLBACKS" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "IN_PLACE_PROMOTED_OBJECTS" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "IN_PLACE_PROMOTION_CYCLES" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "UNTRACED_PROMOTED_OBJECTS" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "UNTRACED_PROMOTION_CYCLES" + }, + { + "file": "crates/perry-runtime/src/gc/promote_in_place.rs", + "name": "YOUNG_CAPACITY_CREDIT" + }, + { + "file": "crates/perry-runtime/src/gc/roots/runtime_handles.rs", + "name": "RUNTIME_HANDLE_STACK_HOT_GUARD" + }, + { + "file": "crates/perry-runtime/src/gc/roots/shadow_stack.rs", + "name": "SHADOW_BUFFER_GUARD" + }, + { + "file": "crates/perry-runtime/src/gc/scan_fallback.rs", + "name": "SAFEPOINT_DRAINS" + }, + { + "file": "crates/perry-runtime/src/gc/scan_fallback.rs", + "name": "SCAN_FALLBACKS" + }, + { + "file": "crates/perry-runtime/src/gc/shape_install.rs", + "name": "SHAPE_INSTALL_MEMO" + }, + { + "file": "crates/perry-runtime/src/gc/telemetry.rs", + "name": "GC_STATS" + }, + { + "file": "crates/perry-runtime/src/gc/telemetry.rs", + "name": "TEST_LAST_GC_TRACE_JSON" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "CAP_GROW_STREAK" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "CAP_SHRINK_STREAK" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "MEAN_SURVIVING_OBJECT_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "NURSERY_CAP_SCALE" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "OBJECT_CENSUS_SEEDED" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "PREV_COPIED_BYTES" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "PROMOTE_LOCK" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "RAISE_STREAK" + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "UNLOCK_STREAK" + }, + { + "file": "crates/perry-runtime/src/gc/trace.rs", + "name": "CLASSIFIER_VERIFY_SUPPRESSED" + }, + { + "file": "crates/perry-runtime/src/intl/number_format.rs", + "name": "ROUND_CTX" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "OBJECT_PROTO_TOJSON_STATE" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "STRINGIFY_BUF" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "STRINGIFY_DEPTH" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "STRINGIFY_STACK" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "SUPPRESS_NEXT_TO_JSON" + }, + { + "file": "crates/perry-runtime/src/json/mod.rs", + "name": "TO_JSON_KEY" + }, + { + "file": "crates/perry-runtime/src/json_tape.rs", + "name": "JSON_TAPE_SAFEPOINT_HOOK" + }, + { + "file": "crates/perry-runtime/src/json_tape.rs", + "name": "REPARSE_MATERIALIZATIONS" + }, + { + "file": "crates/perry-runtime/src/json_tape.rs", + "name": "TAPE_SCRATCH" + }, + { + "file": "crates/perry-runtime/src/map.rs", + "name": "TEST_MAP_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/media_playback.rs", + "name": "GC_SCANNER_REGISTERED" + }, + { + "file": "crates/perry-runtime/src/native_arena.rs", + "name": "OWNER_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/native_arena.rs", + "name": "POD_VIEW_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/native_arena.rs", + "name": "VIEW_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/node_http2_constants.rs", + "name": "SENSITIVE_HEADERS_SYMBOL" + }, + { + "file": "crates/perry-runtime/src/node_repl.rs", + "name": "RECOVERABLE_ERRORS" + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors.rs", + "name": "DEFAULT_HWM_BYTE" + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors.rs", + "name": "DEFAULT_HWM_OBJECT" + }, + { + "file": "crates/perry-runtime/src/node_stream_constructors.rs", + "name": "ITER_HELPER_ARITIES_REGISTERED" + }, + { + "file": "crates/perry-runtime/src/node_submodules/blob.rs", + "name": "FILE_BLOBS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/blob.rs", + "name": "NEXT_FILE_BLOB_ID" + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "DIAG_PENDING_UNCAUGHT" + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "DIAG_SUPPRESS_UNCAUGHT_DRAIN" + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "ERROR_DIAGNOSTICS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "NEXT_DIAG_ID" + }, + { + "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", + "name": "PENDING_ERROR_DIAGNOSTICS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_ASSERT_COUNT" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_DIAGNOSTICS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_PLAN" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_SNAPSHOT_INDEX" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_TEST_NAME" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "CURRENT_TEST_OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/node_submodules/test.rs", + "name": "NEXT_MOCK_ID" + }, + { + "file": "crates/perry-runtime/src/node_submodules/trace_events.rs", + "name": "NEXT_TRACE_ID" + }, + { + "file": "crates/perry-runtime/src/node_submodules/trace_events.rs", + "name": "TRACE_ENABLED_COUNTS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/trace_events.rs", + "name": "TRACE_ENABLED_OBJECTS" + }, + { + "file": "crates/perry-runtime/src/node_submodules/trace_events.rs", + "name": "TRACE_OUTPUT" + }, + { + "file": "crates/perry-runtime/src/node_submodules/trace_events.rs", + "name": "TRACE_WARNING_EMITTED" + }, + { + "file": "crates/perry-runtime/src/object/arguments.rs", + "name": "TEST_ARGUMENTS_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/object/class_registry/parent_static.rs", + "name": "TEST_CLASS_PROTOTYPE_SCANS" + }, + { + "file": "crates/perry-runtime/src/object/delete_rest.rs", + "name": "TOMBSTONE_TEST_OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/object/native_module.rs", + "name": "TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT" + }, + { + "file": "crates/perry-runtime/src/object/native_module/callable_exports.rs", + "name": "TEST_COLLECT_NATIVE_EXPORT_AFTER_ALLOC" + }, + { + "file": "crates/perry-runtime/src/object/shapes.rs", + "name": "KEYS_EDGE_SUPPRESSED" + }, + { + "file": "crates/perry-runtime/src/object/shapes.rs", + "name": "TEST_CACHED_TRANSITION_STAMPS" + }, + { + "file": "crates/perry-runtime/src/object/shapes.rs", + "name": "TEST_CACHED_TRANSITION_WATCH" + }, + { + "file": "crates/perry-runtime/src/object/spill.rs", + "name": "LEARNED_INLINE_FIELDS" + }, + { + "file": "crates/perry-runtime/src/object/spill.rs", + "name": "SPILL_SAFEPOINT_HOOK" + }, + { + "file": "crates/perry-runtime/src/object/typed_array_proto_thunks.rs", + "name": "TEST_BUFFER_GATE_PROBES" + }, + { + "file": "crates/perry-runtime/src/per_test_global.rs", + "name": "SLOTS" + }, + { + "file": "crates/perry-runtime/src/perf_hooks.rs", + "name": "FLUSH_SCHEDULED" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "MODULE_LOADER_HOOKS" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "MODULE_LOADER_HOOK_NEXT_ID" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_FINALIZATION_BEFORE_EXIT_LISTENER_INSTALLED" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_FINALIZATION_BEFORE_EXIT_RAN" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_FINALIZATION_EXIT_RAN" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_FINALIZATION_OBJECT" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_FINALIZATION_REGISTRY" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_PERMISSION_DROPS" + }, + { + "file": "crates/perry-runtime/src/process.rs", + "name": "PROCESS_TITLE" + }, + { + "file": "crates/perry-runtime/src/process/env_misc.rs", + "name": "ENV_CACHE_MUTATION" + }, + { + "file": "crates/perry-runtime/src/process/env_misc.rs", + "name": "ENV_KEY_CASING" + }, + { + "file": "crates/perry-runtime/src/process/env_misc.rs", + "name": "PROCESS_EXIT_CODE" + }, + { + "file": "crates/perry-runtime/src/proxy.rs", + "name": "PROXY_FULL_TRACE_LIVE" + }, + { + "file": "crates/perry-runtime/src/proxy.rs", + "name": "PROXY_GC_RECLAIMED_TOTAL" + }, + { + "file": "crates/perry-runtime/src/proxy.rs", + "name": "PROXY_ID_BAND_LEN_OVERRIDE" + }, + { + "file": "crates/perry-runtime/src/pty/reactor.rs", + "name": "PTY_PUMPING" + }, + { + "file": "crates/perry-runtime/src/registry_latch_probes.rs", + "name": "TABLE" + }, + { + "file": "crates/perry-runtime/src/set.rs", + "name": "TEST_SET_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/static_plugins.rs", + "name": "STATIC_PLUGINS" + }, + { + "file": "crates/perry-runtime/src/string/concat.rs", + "name": "CONCAT_CHAIN_NO_COLLECT_HITS" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "name": "TEST_DISABLE_SYMBOL_MAGIC_SCREEN" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "name": "TEST_SYMBOL_FILTER_ADMITTED_PROBES" + }, + { + "file": "crates/perry-runtime/src/symbol.rs", + "name": "TEST_SYMBOL_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/timer.rs", + "name": "TIMER_CALLBACK_DISPATCH_DEPTH" + }, + { + "file": "crates/perry-runtime/src/tls_hot.rs", + "name": "AFTER_PROBE" + }, + { + "file": "crates/perry-runtime/src/tls_hot.rs", + "name": "HOT" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "name": "TEST_TA_REGISTRY_PROBES" + }, + { + "file": "crates/perry-runtime/src/typedarray/mod.rs", + "name": "TEST_TA_WINDOW_ADMITTED_PROBES" + }, + { + "file": "crates/perry-runtime/src/util_debuglog.rs", + "name": "REGISTERED" + }, + { + "file": "crates/perry-runtime/src/util_promisify.rs", + "name": "REGISTERED" + }, + { + "file": "crates/perry-runtime/src/v8.rs", + "name": "IN_PROMISE_HOOK_CALLBACK" + }, + { + "file": "crates/perry-runtime/src/v8.rs", + "name": "REGISTERED" + }, + { + "file": "crates/perry-runtime/src/value/dyn_index.rs", + "name": "TEST_DYN_INDEX_DISPATCH_COUNTS" + }, + { + "file": "crates/perry-runtime/src/wasi.rs", + "name": "WASI_EXIT_CODE" + }, + { + "file": "crates/perry-runtime/src/weakref/test_support.rs", + "name": "WEAK_READ_BARRIER_SHADES" + }, + { + "file": "crates/perry-runtime/src/web_storage.rs", + "name": "LOCAL_STORE" + }, + { + "file": "crates/perry-runtime/src/web_storage.rs", + "name": "SESSION_STORE" + }, + { + "file": "crates/perry-stdlib/src/commander.rs", + "name": "GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/common/async_bridge.rs", + "name": "GC_SCANNER_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/cron.rs", + "name": "CRON_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/crypto/hash_handles.rs", + "name": "CRYPTO_STREAM_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/crypto/random.rs", + "name": "CB_ERR_NULLISH" + }, + { + "file": "crates/perry-stdlib/src/crypto/random.rs", + "name": "CB_FIRED" + }, + { + "file": "crates/perry-stdlib/src/crypto/random.rs", + "name": "CB_VALUE_PTR" + }, + { + "file": "crates/perry-stdlib/src/domain.rs", + "name": "ACTIVE_DOMAINS" + }, + { + "file": "crates/perry-stdlib/src/domain.rs", + "name": "ACTIVE_DOMAINS_TOUCHED" + }, + { + "file": "crates/perry-stdlib/src/domain.rs", + "name": "DOMAIN_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/events.rs", + "name": "EVENTS_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/exponential_backoff.rs", + "name": "GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/fetch/gc.rs", + "name": "GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "name": "PENDING_FETCH_BODY_CONTENT_TYPE" + }, + { + "file": "crates/perry-stdlib/src/fetch/mod.rs", + "name": "PENDING_FETCH_BODY_STREAM_ID" + }, + { + "file": "crates/perry-stdlib/src/net/mod.rs", + "name": "NET_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/net/mod.rs", + "name": "SCRATCH" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "name": "CLOSE_FIRED" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "name": "NEXT_READLINE_HANDLE" + }, + { + "file": "crates/perry-stdlib/src/readline/mod.rs", + "name": "READLINE_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/sqlite.rs", + "name": "NODE_SQLITE_GC_SCANNER" + }, + { + "file": "crates/perry-stdlib/src/streams/gc.rs", + "name": "GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/tls.rs", + "name": "TLS_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "CURRENT_RESOURCE_LIMITS" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "CURRENT_THREAD_NAME" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "CURRENT_WORKER_CLOSE_REQUESTED" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "CURRENT_WORKER_DATA" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "ENVIRONMENT_DATA_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "NEXT_BROADCAST_ID" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "PARENT_PORT_EVENT_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "PENDING_MESSAGES" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "STDIN_EOF" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "STDIN_READER_STARTED" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "UNCLONEABLE_OBJECTS" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "UNTRANSFERABLE_OBJECTS" + }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "WORKER_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/ws.rs", + "name": "SCRATCH" + }, + { + "file": "crates/perry-stdlib/src/ws.rs", + "name": "WS_GC_REGISTERED" + }, + { + "file": "crates/perry-stdlib/src/zlib.rs", + "name": "ZLIB_GC_REGISTERED" } ] } diff --git a/scripts/gc_runtime_root_holders.py b/scripts/gc_runtime_root_holders.py index cf4236e58c..2fb970aeaf 100755 --- a/scripts/gc_runtime_root_holders.py +++ b/scripts/gc_runtime_root_holders.py @@ -57,8 +57,9 @@ by an identity-pinned ratchet instead of per-holder verdicts. See "The frontier tier" below. - Perry's custom TLS macro is also a deliberate fourth rule: every - `perry_thread_local!` / `crate::perry_thread_local!` declaration in the core + TLS macros are also a deliberate fourth rule: every raw `thread_local!` + (including `std::thread_local!`) or `perry_thread_local!` (including + `crate::perry_thread_local!`) declaration in the core crates that rules A/B do not already recognize is enumerated as rule T. The macro accepts arbitrary crate-local types, so treating an unfamiliar type as safe would recreate the blind spot this census exists to close. Rule-T @@ -110,7 +111,7 @@ go stale, and the deletion is the receipt. The same ratchet also carries otherwise-unclassified core declarations inside -`perry_thread_local!`. Unlike a plain `static` declaration, each of these is a +raw or Perry TLS macros. Unlike a plain `static` declaration, each of these is a known state-holding TLS slot even when its type is a crate-local struct that rules A/B cannot resolve (`PathModuleRegistry`, `ExceptionState`, and `YogaNode` are real examples). A newly declared slot therefore fails until it @@ -125,6 +126,7 @@ * an inventory entry that no longer matches a declaration -> exit 1 * an `open_gap` or `unverified` verdict -> exit 1; old-page relocation ships enabled, so a known or unevaluated movable-address holder cannot be exempted +* a `non_moving_snapshot` whose source pins or closed reference set changed -> exit 1 * a frontier/rule-T holder not in the pinned `frontier` list -> exit 1 (ratchet up) * a `frontier` entry matching no holder -> exit 1 (ratchet down / stale) * fewer than MIN_HOLDERS declarations matched -> exit 2, because a regex that @@ -152,7 +154,7 @@ `scan_exotic_expando_roots_mut`. A new field added there is invisible here. `STATE_FIELD_FLOOR` below asserts the struct has not grown past the field count this was checked at, so growth is at least *loud*. -* **An integer-typed holder whose own file never calls an allocator, in a CORE +* **A non-TLS integer-typed holder whose own file never calls an allocator, in a CORE crate.** Rule B needs a function that both names the holder and allocates; a cell written purely from a value handed in across a module boundary has neither, and is invisible. The ffi-side shape rules (V/S/E/F) close exactly @@ -187,6 +189,8 @@ import tempfile from pathlib import Path, PurePath, PureWindowsPath +from gc_snapshot_contracts import snapshot_contract_problems, snapshot_contract_self_test + REPO_ROOT = Path(__file__).resolve().parent.parent INVENTORY_PATH = REPO_ROOT / "scripts" / "gc_runtime_root_holders.json" @@ -358,11 +362,13 @@ def repo_relative(path: PurePath, root: PurePath) -> str: r"(?P[A-Z][A-Z0-9_]*)\s*:\s*(?P.*)$" ) -# Perry's hot-TLS macro accepts the same declaration syntax as -# `thread_local!`, but a declaration may name an opaque crate-local type that -# core rules A/B cannot see through. `declarations_in_perry_tls` makes the -# macro boundary explicit instead of relying on DECL's context-free match. -PERRY_TLS_BLOCK = re.compile(r"(?m)^[ \t]*(?:crate::)?perry_thread_local!\s*\{") +# Raw and Perry TLS accept opaque crate-local types that core rules A/B +# cannot see through. Skipping the hot-TLS convention must not skip custody. +# `declarations_in_tls` makes the macro boundary explicit rather than relying +# on DECL's context-free match. +TLS_BLOCK = re.compile( + r"(?m)^[ \t]*(?:(?:crate::)?perry_thread_local|(?:std::)?thread_local)!\s*\{" +) MAX_SCANNER_DEPTH = 3 @@ -533,17 +539,16 @@ def declarations(rel: str, text: str) -> list[tuple[str, int, str]]: return out -def declarations_in_perry_tls(text: str) -> set[tuple[str, int]]: - """Return (name, line) for declarations inside Perry TLS macro blocks. +def declarations_in_tls(text: str) -> set[tuple[str, int]]: + """Return (name, line) for declarations inside raw or Perry TLS blocks. Brace matching runs on comment/string-stripped source, so braces in docs - and initializers cannot terminate a block early. Both the exported and - `crate::` spellings are accepted; the declaration syntax inside is the - same as `thread_local!`. + and initializers cannot terminate a block early. Both qualified and + unqualified spellings of the raw and Perry macros are accepted. """ code = strip_comments(text) found: set[tuple[str, int]] = set() - for match in PERRY_TLS_BLOCK.finditer(code): + for match in TLS_BLOCK.finditer(code): open_at = code.find("{", match.start(), match.end()) if open_at < 0: continue @@ -984,9 +989,7 @@ def reachable_text(call_pattern: re.Pattern) -> dict[Path, str]: for path, text in texts.items(): rel = repo_relative(path, root) tier = tier_of[path] - perry_tls_declarations = ( - declarations_in_perry_tls(text) if tier == "core" else set() - ) + tls_declarations = declarations_in_tls(text) if tier == "core" else set() bodies_here = function_bodies(text) covered_text = ( reachable_text_by_file if tier == "frontier" else legacy_reachable_text_by_file @@ -1008,7 +1011,7 @@ def reachable_text(call_pattern: re.Pattern) -> dict[Path, str]: for name, lineno, type_text in declarations(rel, text): if tier == "core": rule = holder_is_candidate(name, type_text, allocating_context) - if rule is None and (name, lineno) in perry_tls_declarations: + if rule is None and (name, lineno) in tls_declarations: rule = "T" else: rule = ffi_holder_is_candidate(name, type_text, type_index, closure_context) @@ -1037,6 +1040,7 @@ def reachable_text(call_pattern: re.Pattern) -> dict[Path, str]: "covered_elsewhere", # a registered scanner in ANOTHER file visits it "not_a_gc_pointer", # id, counter, epoch, code address, .rodata, Rust-owned "test_only", # #[cfg(test)] storage + "non_moving_snapshot", # deliberately untraced within a pinned collector window "open_gap", # a real unrooted GC pointer, with an issue "unverified", # enumerated, verdict not established — a dated TODO } @@ -1106,7 +1110,7 @@ def apply_frontier( return unpinned, stale -def inventory_problems(inventory: list[dict]) -> list[str]: +def inventory_problems(inventory: list[dict], root: Path | None = None) -> list[str]: """Structural checks on the inventory itself. Without these, `apply_inventory` accepts any object carrying a matching @@ -1138,6 +1142,8 @@ def inventory_problems(inventory: list[dict]) -> list[str]: f"{label}: covered_elsewhere must name the `scanner` that covers it, or " f"the claim cannot be checked or maintained" ) + if verdict == "non_moving_snapshot": + problems.extend(snapshot_contract_problems(entry, root)) if verdict == "open_gap" and not (entry.get("issue") or "").strip(): problems.append(f"{label}: open_gap must cite an `issue`") if verdict in {"open_gap", "unverified"}: @@ -1221,7 +1227,7 @@ def report(root: Path, quiet: bool = False) -> int: inventory = load_inventory(INVENTORY_PATH) unclassified, stale = apply_inventory(holders, inventory) - malformed = inventory_problems(inventory) + malformed = inventory_problems(inventory, root) frontier = load_frontier(INVENTORY_PATH) frontier_new, frontier_stale = apply_frontier( holders, frontier, registered_scanners, inventory @@ -1233,7 +1239,7 @@ def report(root: Path, quiet: bool = False) -> int: print( "\ngc_runtime_root_holders: NEW identity-ratcheted holders not pinned in\n" "the inventory's `frontier` list. This covers perry-ui* callback tables\n" - "and otherwise-unclassified core `perry_thread_local!` declarations.\n" + "and otherwise-unclassified core raw/Perry thread-local declarations.\n" "Register a scanner that reaches the holder, record a researched verdict\n" "where the gated rules apply, or pin existing debt deliberately — with the\n" "understanding that a pinned entry is not a GC-safety verdict.\n", @@ -1311,7 +1317,7 @@ def report(root: Path, quiet: bool = False) -> int: # docstring under "What this gate CANNOT see"). print( " UNVERIFIED by this gate: RuntimeState struct fields " - "(growth-floor only); core-crate integer tables in files that " + "(growth-floor only); non-TLS core-crate integer tables in files that " f"never call an allocator (rule B's limit); and the " f"{len(frontier)} pinned frontier holders, which are ENUMERATED " "and RATCHETED but scanned by nothing — a value parked there may " @@ -1598,6 +1604,29 @@ def expect_absent(rel: str, name: str, why: str) -> None: False, "unqualified perry_thread_local! declaration with an opaque type", ) + # Raw TLS must have the same custody policy as Perry TLS, even when no + # allocator or recognized heap type appears in the declaring file (#9740). + for macro in ("thread_local", "std::thread_local"): + raw = _scan_tree({"crates/perry-runtime/src/raw_tls.rs": f""" +{macro}! {{ + static RAW_OPAQUE: RefCell = RefCell::new(OpaqueState::new()); + static RAW_ADDRESSES: RefCell>> = RefCell::new(None); +}} +"""}) + raw_holders = [h for h in raw if h["file"].endswith("/raw_tls.rs")] + if {h["name"] for h in raw_holders} != {"RAW_OPAQUE", "RAW_ADDRESSES"}: + failures.append(f"{macro}! escaped opaque/address holder enumeration") + unpinned, _stale = apply_frontier(raw_holders, []) + if len(unpinned) != 2: + failures.append(f"{macro}! new unclassified holders did not fail the ratchet") + pins = [{"file": h["file"], "name": h["name"]} for h in raw_holders] + if apply_frontier(raw_holders, pins) != ([], []): + failures.append(f"{macro}! existing debt did not match its identity pins") + if apply_frontier([], pins)[1] != pins: + failures.append(f"{macro}! removed holders left live frontier pins") + covered_raw = [{**h, "covered": True} for h in raw_holders] + if apply_frontier(covered_raw, pins) != ([], pins): + failures.append(f"{macro}! scanner coverage did not retire debt pins") if by_key.get( ("crates/perry-runtime/src/thing.rs", "COVERED_OPAQUE_TLS"), {} ).get("rule") != "T": @@ -1893,7 +1922,8 @@ def expect_absent(rel: str, name: str, why: str) -> None: ", ".join(f"{e['file']}:{e['name']}" for e in frontier_stale[:5]), ) ) - failures.extend(inventory_problems(inventory)) + failures.extend(inventory_problems(inventory, REPO_ROOT)) + failures.extend(snapshot_contract_self_test()) # …and the structural checker must itself be able to fail. long_why = "x" * 30 for bad, expect in ( diff --git a/scripts/gc_snapshot_contracts.py b/scripts/gc_snapshot_contracts.py new file mode 100644 index 0000000000..b75d79cf9c --- /dev/null +++ b/scripts/gc_snapshot_contracts.py @@ -0,0 +1,162 @@ +"""Source-pinned contracts for deliberately untraced, non-moving GC snapshots. + +These pins require a renewed review when the holder, its boundaries, or its +collector driver changes. They do not infer GC safety from function names or +prove arbitrary callees safe. The inventory must explain the reviewed window. +""" + +from __future__ import annotations + +import hashlib +import re +import tempfile +from pathlib import Path, PurePosixPath + +from check_gc_scanner_latches import mask_non_code + + +def source_digest(path: Path) -> str: + # A Windows checkout's CRLF conversion must not invalidate a reviewed pin. + return hashlib.sha256(path.read_text(encoding="utf-8").encode("utf-8")).hexdigest() + + +def snapshot_contract_problems(entry: dict, root: Path | None) -> list[str]: + label = f"{entry.get('file', '?')}:{entry.get('name', '?')}" + problems: list[str] = [] + + def fail(message: str) -> None: + problems.append(f"{label}: non_moving_snapshot {message}") + + name = entry.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z_]\w*", name): + fail("requires a holder name") + return problems + window = entry.get("window") + if not isinstance(window, dict): + fail("requires a window with start, end, owner and source pins") + return problems + sources = window.get("sources") + if not isinstance(sources, dict) or not sources: + fail("requires nonempty window.sources SHA-256 pins") + return problems + if root is None: + fail("requires a source root to verify its window") + return problems + + pinned: dict[str, str] = {} + for rel, digest in sources.items(): + if not isinstance(rel, str): + fail("source paths must be repository-relative Rust files") + continue + path = PurePosixPath(rel) + if (path.is_absolute() or ".." in path.parts or "\\" in rel + or not rel.startswith("crates/") or path.suffix != ".rs"): + fail(f"invalid source path {rel!r}") + continue + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + fail(f"invalid SHA-256 pin for {rel}") + continue + target = root / rel + if not target.is_file() or not target.resolve().is_relative_to(root.resolve()): + fail(f"source missing or outside repository: {rel}") + continue + pinned[rel] = mask_non_code(target.read_text(encoding="utf-8")) + if source_digest(target) != digest: + fail(f"source changed: {rel}; re-audit the window before updating its pin") + + holder_file = entry.get("file") + if not isinstance(holder_file, str) or holder_file not in pinned: + fail("must pin the holder's declaration and accesses") + symbols = {name} + boundaries = [] + for role in ("start", "end", "owner"): + boundary = window.get(role) + if not isinstance(boundary, dict): + fail(f"requires a {role} boundary with file and function") + continue + rel, name = boundary.get("file"), boundary.get("function") + if not isinstance(rel, str) or rel not in pinned: + fail(f"{role} boundary must be in a pinned source file") + continue + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z_]\w*", name): + fail(f"{role} function must be a Rust identifier") + continue + if not re.search(r"\bfn\s+" + re.escape(name) + r"\s*(?:<|\()", pinned[rel]): + fail(f"{role} function {name} is missing from {rel}") + if role != "owner": + symbols.add(name) + boundaries.append((rel, name)) + if len(boundaries) == 2 and boundaries[0] == boundaries[1]: + fail("start and end must identify distinct boundaries") + + # A new access/caller outside the reviewed files must not escape merely + # because none of their hashes changed. Names in comments/strings do not + # count. Include test code too: an extra caller still deserves review. + symbols.discard("") + if symbols: + references = re.compile(r"\b(?:" + "|".join(map(re.escape, sorted(symbols))) + r")\b") + for source in sorted((root / "crates").glob("*/src/**/*.rs")): + rel = source.relative_to(root).as_posix() + if rel in pinned: + continue + text = source.read_text(encoding="utf-8") + if any(symbol in text for symbol in symbols) and references.search(mask_non_code(text)): + fail(f"unreviewed holder access or boundary reference in {rel}") + return problems + + +def snapshot_contract_self_test() -> list[str]: + failures = [] + with tempfile.TemporaryDirectory(prefix="perry-snapshot-contract-") as temporary: + root = Path(temporary) + rel = "crates/perry-runtime/src/snapshot.rs" + source = root / rel + source.parent.mkdir(parents=True) + original = """static SNAPSHOT: Cell = Cell::new(0); +fn begin() { SNAPSHOT.set(1); } +fn end() { SNAPSHOT.set(0); } +fn collect() { begin(); end(); } +""" + source.write_text(original) + entry = { + "file": rel, "name": "SNAPSHOT", "verdict": "non_moving_snapshot", + "window": { + **{role: {"file": rel, "function": name} + for role, name in (("start", "begin"), ("end", "end"), ("owner", "collect"))}, + "sources": {rel: source_digest(source)}, + }, + } + if snapshot_contract_problems(entry, root): + failures.append("valid snapshot contract rejected") + source.write_bytes(original.replace("\n", "\r\n").encode("utf-8")) + if snapshot_contract_problems(entry, root): + failures.append("CRLF checkout changed the snapshot source pin") + source.write_text(original) + for role in ("start", "end", "owner", "sources"): + bad = {**entry, "window": {k: v for k, v in entry["window"].items() if k != role}} + if not snapshot_contract_problems(bad, root): + failures.append(f"snapshot contract accepted missing {role}") + for bad in ({**entry, "window": None}, {**entry, "window": {}}, + {**entry, "window": {**entry["window"], "sources": {"../escape.rs": "0" * 64}}}, + {**entry, "window": {**entry["window"], "sources": {rel: "bad"}}}, + {**entry, "window": {**entry["window"], "end": {"file": rel, "function": "missing"}}}): + if not snapshot_contract_problems(bad, root): + failures.append("malformed snapshot contract accepted") + for replacement in ( + original.replace("end();", "resume_mutator(); end();"), + original.replace("Cell::new(0)", "Cell::new(1)"), + original.replace("SNAPSHOT.set(0);", ""), + ): + source.write_text(replacement) + if not any("source changed" in p for p in snapshot_contract_problems(entry, root)): + failures.append("changed snapshot lifetime/holder escaped source pin") + source.write_text(original) + caller = source.with_name("new_caller.rs") + for access in ("begin();", "end();", "SNAPSHOT.set(2);"): + caller.write_text(f"fn caller() {{ {access} }}") + if not any("unreviewed" in p for p in snapshot_contract_problems(entry, root)): + failures.append(f"new snapshot reference {access} escaped the closed reference set") + caller.write_text('// begin();\nconst TEXT: &str = "SNAPSHOT end()";') + if snapshot_contract_problems(entry, root): + failures.append("comments/literals were treated as snapshot references") + return failures diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index e69dd0015c..cc8c57e8b0 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -12,8 +12,8 @@ inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 -inline-offset | perry-runtime | 358 +inline-offset | perry-runtime | 353 inline-offset | perry-stdlib | 40 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 -reader-helper | perry-runtime | 12 +reader-helper | perry-runtime | 13 diff --git a/test-files/_helpers/bun_jsc_heap_stats_9743.ts b/test-files/_helpers/bun_jsc_heap_stats_9743.ts new file mode 100644 index 0000000000..fe865bcce0 --- /dev/null +++ b/test-files/_helpers/bun_jsc_heap_stats_9743.ts @@ -0,0 +1,50 @@ +// Compiled with --platform bun by issue_9743_bun_jsc_heap_stats.rs. +// Its preamble supplies heapStats through one of the supported import forms. +import { gc } from 'bun'; + +function check(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const keys = [ + 'extraMemorySize', 'globalObjectCount', 'heapCapacity', 'heapSize', 'mimalloc', + 'objectCount', 'objectTypeCounts', 'protectedGlobalObjectCount', + 'protectedObjectCount', 'protectedObjectTypeCounts', +].sort().join(','); + +function checkStats(stats: any) { + check(Object.keys(stats).sort().join(',') === keys, 'heapStats keys'); + for (const key of ['heapSize', 'heapCapacity', 'extraMemorySize', 'objectCount', + 'protectedObjectCount', 'globalObjectCount', 'protectedGlobalObjectCount']) { + check(typeof stats[key] === 'number' && Number.isFinite(stats[key]) && stats[key] >= 0, + 'invalid scalar ' + key); + } + for (const key of ['objectTypeCounts', 'protectedObjectTypeCounts', 'mimalloc']) { + const counts = stats[key]; + check(typeof counts === 'object' && counts !== null, 'invalid counters ' + key); + for (const type of Object.keys(counts)) { + check(typeof counts[type] === 'number' && Number.isFinite(counts[type]) && counts[type] >= 0, + 'invalid counter ' + key + '.' + type); + } + } + check(Object.keys(stats.objectTypeCounts).length > 0, 'missing heap census'); + check(Object.keys(stats.mimalloc).length > 0, 'missing allocator counters'); + check(stats.heapCapacity >= stats.heapSize, 'capacity smaller than usage'); + check(stats.protectedObjectCount <= stats.objectCount, 'protected count exceeds population'); +} + +checkStats(heapStats()); +checkStats(heapStats(true)); +gc(true); +const before = heapStats(); +const retained: any[] = []; +for (let i = 0; i < 256; i++) retained.push({ value: i, text: 'cell-' + i }); +gc(true); +const after = heapStats(true); +checkStats(before); +checkStats(after); +check(after.objectCount > before.objectCount, 'retained objects must increase the census'); +check(after.heapSize > before.heapSize, 'retained objects must increase heap usage'); +console.log('heapStats shapes ok'); +console.log('retained', retained.length, retained[0].value, retained[255].value); +console.log('heapStats growth ok'); diff --git a/test-files/_helpers/imported_class_expr_9366.ts b/test-files/_helpers/imported_class_expr_9366.ts new file mode 100644 index 0000000000..84a05902b3 --- /dev/null +++ b/test-files/_helpers/imported_class_expr_9366.ts @@ -0,0 +1,14 @@ +export class Decl { m() { return 1; } } +export let Expr = class { m() { return 2; } }; +export const Anon = class Named { m() { return 3; } }; +export function localProbe() { + return [typeof Decl.prototype, typeof Expr.prototype, typeof Anon.prototype].join(","); +} +export function localExprPrototype() { return Expr.prototype; } +export function replaceExpr() { Expr = class Replacement { m() { return 4; } }; } +export const box = { marker: 8 }; +export const text = "hello"; +export let accessorReads = 0; +export const accessor = { get value() { accessorReads++; return this.marker; }, marker: 9 }; +export let functionCalls = 0; +export function untouchedFunction() { functionCalls++; return box; } diff --git a/test-files/_helpers/imported_class_expr_barrel_9366.ts b/test-files/_helpers/imported_class_expr_barrel_9366.ts new file mode 100644 index 0000000000..2ef8256d90 --- /dev/null +++ b/test-files/_helpers/imported_class_expr_barrel_9366.ts @@ -0,0 +1 @@ +export { Expr as RenamedExpr, Anon as RenamedAnon } from "./imported_class_expr_9366.ts"; diff --git a/test-files/_helpers/static_worker_9744.ts b/test-files/_helpers/static_worker_9744.ts new file mode 100644 index 0000000000..999fbaf483 --- /dev/null +++ b/test-files/_helpers/static_worker_9744.ts @@ -0,0 +1,2 @@ +import { parentPort } from 'node:worker_threads'; +parentPort.postMessage('ready'); diff --git a/test-files/fixtures/issue_9023/bag.ts b/test-files/fixtures/issue_9023/bag.ts new file mode 100644 index 0000000000..737a82acac --- /dev/null +++ b/test-files/fixtures/issue_9023/bag.ts @@ -0,0 +1,12 @@ +export class Bag { + private values = new Map>(); + add(key: K, value: V) { + let group = this.values.get(key); + if (!group) { group = new Set(); this.values.set(key, group); } + group.add(value); + } + *entries(): IterableIterator<[K, V]> { + for (const [key, group] of this.values) for (const value of group) yield [key, value]; + } + [Symbol.iterator]() { return this.entries(); } +} diff --git a/test-files/fixtures/issue_9023/defaults.ts b/test-files/fixtures/issue_9023/defaults.ts new file mode 100644 index 0000000000..78b752bad7 --- /dev/null +++ b/test-files/fixtures/issue_9023/defaults.ts @@ -0,0 +1,8 @@ +import ImportedToken from "./token.ts"; + +export function readDefault(value = new ImportedToken()) { return value.read(); } +export function readThunk(make = () => new ImportedToken()) { return make().read(); } +export class Builder { + make() { return new ImportedToken(); } + read(value = new ImportedToken()) { return value.read(); } +} diff --git a/test-files/fixtures/issue_9023/helpers.ts b/test-files/fixtures/issue_9023/helpers.ts new file mode 100644 index 0000000000..0eb3739cf2 --- /dev/null +++ b/test-files/fixtures/issue_9023/helpers.ts @@ -0,0 +1,9 @@ +import { Bag as ImportedBag } from "./bag.ts"; +export function makeBag() { return new ImportedBag(); } +export function getBag(store: Map>, key: number) { + return store.get(key) ?? new ImportedBag(); +} +export function track(store: Map>, key: number, a: number, b: number) { + if (!store.has(key)) store.set(key, new ImportedBag()); + store.get(key)!.add(a, b); +} diff --git a/test-files/fixtures/issue_9023/token.ts b/test-files/fixtures/issue_9023/token.ts new file mode 100644 index 0000000000..4750eca7b2 --- /dev/null +++ b/test-files/fixtures/issue_9023/token.ts @@ -0,0 +1,4 @@ +export default class Token { + label = "source"; + read() { return this.label; } +} diff --git a/test-files/test_gap_9365_prototype_property_stores.cts b/test-files/test_gap_9365_prototype_property_stores.cts new file mode 100644 index 0000000000..99876f51c5 --- /dev/null +++ b/test-files/test_gap_9365_prototype_property_stores.cts @@ -0,0 +1,123 @@ +// #9365: a property named "prototype" is an ordinary property on non-functions. +function assign(target: any, value: any): any { + return (target.prototype = value); +} +function assignStrict(target: any, value: any): any { + "use strict"; + return (target.prototype = value); +} +function rejected(target: any): boolean { + try { + assignStrict(target, 99); + return false; + } catch (error) { + return error instanceof TypeError; + } +} + +const payload = { marker: 7 }; +const parameter: any = {}; +console.log("parameter", assign(parameter, payload) === payload); +console.log("own", Object.hasOwn(parameter, "prototype"), parameter.prototype === payload); +const descriptor = Object.getOwnPropertyDescriptor(parameter, "prototype"); +console.log("descriptor", descriptor.writable, descriptor.enumerable, descriptor.configurable); +console.log("keys", Object.keys(parameter).join(",")); + +for (let count = 0; count < 4; count++) { + const dynamic: any = {}; + for (let i = 0; i < count; i++) dynamic["x" + i] = i; + dynamic.prototype = payload; + console.log("dynamic", count, Object.hasOwn(dynamic, "prototype"), dynamic.prototype === payload); +} + +const primitiveValues: any[] = [17, "text", true, null, undefined]; +for (const value of primitiveValues) { + const target: any = {}; + console.log("value", assign(target, value) === value, target.prototype === value); +} +const arrayValue = [1, 2]; +const arrayTarget: any = []; +console.log("array", assign(arrayTarget, arrayValue) === arrayValue, arrayTarget.prototype === arrayValue); +const computed: any = {}; +computed["prototype"] = payload; +console.log("computed", computed.prototype === payload); + +let setterCalls = 0; +let setterThis: any; +let setterValue: any; +const accessor: any = {}; +Object.defineProperty(accessor, "prototype", { + set(value) { setterCalls++; setterThis = this; setterValue = value; }, + configurable: true, +}); +console.log("setter-result", assign(accessor, payload) === payload); +console.log("setter", setterCalls, setterThis === accessor, setterValue === payload); +const inherited: any = Object.create(accessor); +assign(inherited, arrayValue); +console.log("inherited", setterCalls, setterThis === inherited, setterValue === arrayValue, + Object.hasOwn(inherited, "prototype")); + +let proxyCalls = 0; +const proxyTarget: any = {}; +let proxy: any; +proxy = new Proxy(proxyTarget, { + set(target, key, value, receiver) { + proxyCalls++; + console.log("trap", key, receiver === proxy); + return Reflect.set(target, key, value, receiver); + }, +}); +console.log("proxy-result", assign(proxy, payload) === payload, proxyTarget.prototype === payload, proxyCalls); +const rejectingProxy = new Proxy({}, { set() { return false; } }); +console.log("proxy-reject", assign(rejectingProxy, payload) === payload, rejected(rejectingProxy)); + +const readonly: any = {}; +Object.defineProperty(readonly, "prototype", { value: 12, writable: false }); +console.log("readonly", assign(readonly, 13), readonly.prototype, rejected(readonly)); +const frozen = Object.freeze({}); +console.log("frozen", assign(frozen, payload) === payload, Object.hasOwn(frozen, "prototype"), rejected(frozen)); +console.log("primitive", assign(42, payload) === payload, rejected(42)); +console.log("nullish", rejected(null), rejected(undefined)); + +let receiverCalls = 0; +let rhsCalls = 0; +let order = ""; +const ordered: any = {}; +function receiver(): any { receiverCalls++; order += "r"; return ordered; } +function rhs(): any { rhsCalls++; order += "v"; return payload; } +console.log("order-result", (receiver().prototype = rhs()) === payload); +console.log("order", receiverCalls, rhsCalls, order, ordered.prototype === payload); +const holder = { get target(): any { receiverCalls++; return ordered; } }; +holder.target.prototype = arrayValue; +console.log("getter-once", receiverCalls, ordered.prototype === arrayValue); +(receiverCalls > 0 ? receiver() : holder.target).prototype = payload; +console.log("conditional-once", receiverCalls, ordered.prototype === payload); + +function Base() {} +const basePrototype = { method() { return 23; } }; +assign(Base, basePrototype); +class Derived extends Base {} +console.log("function", Base.prototype === basePrototype, new Derived().method()); +const functionDescriptor = Object.getOwnPropertyDescriptor(Base, "prototype"); +console.log("function-descriptor", functionDescriptor.writable, functionDescriptor.enumerable, functionDescriptor.configurable); +Object.defineProperty(Base, "prototype", { writable: false }); +console.log("function-readonly", assign(Base, payload) === payload, Base.prototype === basePrototype, rejected(Base)); +class StillDerived extends Base {} +console.log("function-retained", new StillDerived().method()); +const arrow: any = () => 1; +assign(arrow, payload); +const arrowDescriptor = Object.getOwnPropertyDescriptor(arrow, "prototype"); +console.log("arrow", arrow.prototype === payload, arrowDescriptor.writable, + arrowDescriptor.enumerable, arrowDescriptor.configurable); + +class Carrier { + prototype() { return 10; } + read() { return this.prototype(); } +} +const carrier = new Carrier(); +console.log("class-method-before", carrier.read()); +assign(Carrier.prototype, function() { return 42; }); +console.log("class-method-after", carrier.read()); +const carrierPrototype = Carrier.prototype; +console.log("class-readonly", assign(Carrier, payload) === payload, + Carrier.prototype === carrierPrototype, rejected(Carrier), Reflect.set(Carrier, "prototype", payload)); diff --git a/test-files/test_gap_9366_imported_class_expression_prototypes.ts b/test-files/test_gap_9366_imported_class_expression_prototypes.ts new file mode 100644 index 0000000000..250698e74a --- /dev/null +++ b/test-files/test_gap_9366_imported_class_expression_prototypes.ts @@ -0,0 +1,31 @@ +import { Decl, Expr, Anon, localProbe } from "./_helpers/imported_class_expr_9366.ts"; +console.log("in-defining-module=" + localProbe()); +console.log("importer-Decl=" + typeof Decl.prototype); +console.log("importer-Expr=" + typeof Expr.prototype); +console.log("importer-Anon=" + typeof Anon.prototype); +import * as ns from "./_helpers/imported_class_expr_9366.ts"; +import { RenamedExpr, RenamedAnon } from "./_helpers/imported_class_expr_barrel_9366.ts"; +import { localExprPrototype, replaceExpr, box, text, accessor, accessorReads, + untouchedFunction, functionCalls } from "./_helpers/imported_class_expr_9366.ts"; + +console.log("instance", Object.getPrototypeOf(new Expr()) === Expr.prototype); +console.log("named-instance", Object.getPrototypeOf(new Anon()) === Anon.prototype); +console.log("own", Object.hasOwn(Expr, "prototype")); +console.log("local-identity", Expr.prototype === localExprPrototype()); +console.log("namespace", ns.Expr.prototype === Expr.prototype, typeof ns.Expr.prototype); +console.log("barrel", RenamedExpr.prototype === Expr.prototype, typeof RenamedExpr.prototype); +console.log("named-barrel", RenamedAnon.prototype === Anon.prototype, typeof RenamedAnon.prototype); +console.log("computed", Expr["prototype"] === Expr.prototype); +console.log("named-class-name", Anon.name); +console.log("object", box.marker); +console.log("string", text.length); +console.log("accessor", accessor.value, accessorReads); +console.log("function", typeof untouchedFunction.marker, functionCalls); + +const first = Expr.prototype; +replaceExpr(); +console.log("rebound", typeof Expr.prototype, Expr.prototype !== first, + Expr.prototype === localExprPrototype()); +console.log("barrel-rebound", RenamedExpr.prototype === Expr.prototype); +console.log("namespace-rebound", ns.Expr.prototype === Expr.prototype); +console.log("method", Expr.prototype.m()); diff --git a/test-files/test_gap_9502_factory_decl_heritage_forms.ts b/test-files/test_gap_9502_factory_decl_heritage_forms.ts new file mode 100644 index 0000000000..2a2d142b9d --- /dev/null +++ b/test-files/test_gap_9502_factory_decl_heritage_forms.ts @@ -0,0 +1,55 @@ +// #9502: dynamic heritage includes bundled member access, aliases and shadows. +class First { kind() { return "first"; } } +class Second { kind() { return "second"; } } +function fromMember(mod: any): any { + class Member extends mod.Base {} + return Member; +} +function fromAlias(P: any): any { + const Parent = P; + class Aliased extends Parent {} + return Aliased; +} +function fromShadow(First: any): any { + class Shadowed extends First {} + return Shadowed; +} +function check(label: string, factory: any): void { + const One = factory(First); + const Two = factory(Second); + console.log(label + " " + (One !== Two) + " " + (Object.getPrototypeOf(One) === First) + + " " + (Object.getPrototypeOf(Two) === Second)); + console.log(new One().kind() + " " + new Two().kind()); +} +check("member", (P: any) => fromMember({ Base: P })); +check("alias", fromAlias); +check("shadow", fromShadow); + +// A direct new-expression inside the factory must use the fresh local binding. +function localNew(P: any): any { + class Local extends P {} + return new Local(); +} +const I1 = localNew(First); +const I2 = localNew(Second); +console.log("local new " + I1.kind() + " " + I2.kind()); + +// Static state must be initialized on each class object, even without captures +// in instance members. A static block still runs once per evaluation. +let blocks = 0; +function withStatics(P: any, tag: string): any { + class Stateful extends P { + static tag = tag; + static count = 0; + static { blocks++; } + } + return Stateful; +} +const S1 = withStatics(First, "one"); +const S2 = withStatics(Second, "two"); +S1.count++; +console.log("statics " + (S1 !== S2) + " " + S1.tag + " " + S2.tag + + " " + S1.count + " " + S2.count + " " + blocks); +console.log("static parents " + (Object.getPrototypeOf(S1) === First) + + " " + (Object.getPrototypeOf(S2) === Second)); +console.log("static methods " + new S1().kind() + " " + new S2().kind()); diff --git a/test-files/test_gap_9502_factory_decl_heritage_identity.ts b/test-files/test_gap_9502_factory_decl_heritage_identity.ts new file mode 100644 index 0000000000..1aea77016d --- /dev/null +++ b/test-files/test_gap_9502_factory_decl_heritage_identity.ts @@ -0,0 +1,30 @@ +// #9502: heritage alone must give a function-body declaration a fresh class. +function mk(P: any): any { + class D extends (P ?? Object) {} + return D; +} +const A = mk(null); +const B = mk(A); +const C = mk(B); +console.log("ident " + (A === B) + " " + (B === C)); +console.log("gp " + (Object.getPrototypeOf(B) === A)); +console.log("chain " + (Object.getPrototypeOf(C) === B)); +console.log("prototypes " + (A.prototype !== B.prototype) + " " + (B.prototype !== C.prototype)); +console.log("prototype chain " + (Object.getPrototypeOf(B.prototype) === A.prototype) + + " " + (Object.getPrototypeOf(C.prototype) === B.prototype)); +// Construct older evaluations after the template has seen newer parents. +const a = new A(); +const b = new B(); +const c = new C(); +console.log("a " + (a instanceof A) + " " + (a instanceof B) + " " + (a instanceof C)); +console.log("b " + (b instanceof A) + " " + (b instanceof B) + " " + (b instanceof C)); +console.log("c " + (c instanceof A) + " " + (c instanceof B) + " " + (c instanceof C)); + +// Distinct roots must remain attached to their own escaped evaluations. +class Left { side() { return "left"; } } +class Right { side() { return "right"; } } +const L = mk(Left); +const R = mk(Right); +console.log("roots " + (Object.getPrototypeOf(L) === Left) + " " + (Object.getPrototypeOf(R) === Right)); +console.log("methods " + new L().side() + " " + new R().side()); +console.log("saved " + (Object.getPrototypeOf(C) === B) + " " + (new C() instanceof A)); diff --git a/test-files/test_gap_9503_dynamic_heritage_builtin_this.ts b/test-files/test_gap_9503_dynamic_heritage_builtin_this.ts new file mode 100644 index 0000000000..13ac285234 --- /dev/null +++ b/test-files/test_gap_9503_dynamic_heritage_builtin_this.ts @@ -0,0 +1,57 @@ +// #9503: a runtime-valued heritage that resolves to Object must bind the +// object returned by the builtin super-constructor as the derived `this`, and +// `new` must publish that same object after the explicit constructor returns. +function mkc(P?: any): any { + class D extends (P ?? Object) { + marker = "field"; + + constructor(def: any) { + super(def); + (this as any).def = def; + } + + ping(): string { + return "pong"; + } + } + return D; +} + +const A = mkc(); +const a = new A({ t: 1 }); +console.log("default:", a.def.t, a.marker, a.ping(), a instanceof A); + +// Keep the value path dynamic even when Object is supplied explicitly. +const B = mkc(Object); +const b = new B({ t: 2 }); +console.log("explicit:", b.def.t, b.marker, b.ping(), b instanceof B); + +const a2 = new A({ t: 3 }); +console.log("distinct:", a !== a2, a2.def.t); + +// A capture forces a fresh class object rather than a shared ClassRef. Its +// evaluation-specific prototype must survive the same builtin-super path. +function mkFresh(P: any, label: string): any { + const captured = label; + return class extends P { + #label = captured; + + constructor(def: any) { + super(def); + (this as any).def = def; + } + + read(): string { + return `${this.#label}:${(this as any).def.t}`; + } + }; +} + +const Fresh = mkFresh(Object, "fresh"); +const fresh = new Fresh({ t: 4 }); +console.log( + "fresh:", + fresh.read(), + fresh instanceof Fresh, + Object.getPrototypeOf(fresh) === Fresh.prototype, +); diff --git a/test-files/test_gap_9725_large_number_radix.ts b/test-files/test_gap_9725_large_number_radix.ts new file mode 100644 index 0000000000..137049ff0f --- /dev/null +++ b/test-files/test_gap_9725_large_number_radix.ts @@ -0,0 +1,24 @@ +// #9725: large integral doubles must zero-fill unrepresented radix digits. +// Include values on both sides of 2^53, large exponents, both signs, and +// every radix so the power-of-two and decimal paths remain covered. +function dynamic(value: any, key: string, radix: number): string { + return value[key](radix); +} + +const values: number[] = [ + 0, -0, 0.1, 10.5, 255, 1e15, + 9007199254740991, 9007199254740992, 9007199254740994, + 18014398509481984, 1e21, 1e30, 1e100, Number.MAX_VALUE, +]; +for (const value of values) { + for (let radix = 2; radix <= 36; radix++) { + console.log(value.toString(radix)); + console.log((-value).toString(radix)); + console.log(dynamic(value, "toString", radix)); + } +} + +console.log((1e21).toString(36)); +console.log((1e21).toString(7)); +console.log((1e30).toString(36)); +console.log(dynamic(new Number(1e21), "toString", 36)); diff --git a/test-files/test_gap_9744_static_worker_helpers.ts b/test-files/test_gap_9744_static_worker_helpers.ts new file mode 100644 index 0000000000..323efc75ac --- /dev/null +++ b/test-files/test_gap_9744_static_worker_helpers.ts @@ -0,0 +1,12 @@ +import { Worker } from 'node:worker_threads'; + +function identity(path: string | URL) { return path; } +const same = identity; +const workerUrl = (name: string) => new URL(`./_helpers/${name}.ts`, import.meta.url); +const entry = () => same(identity(workerUrl('static_worker_9744'))); +const worker = new Worker(entry()); +worker.on('message', (data: string) => { + console.log('node', data); + worker.terminate().then(() => process.exit(0)); +}); +setTimeout(() => process.exit(2), 5000); diff --git a/test-files/test_parity_9023_transitive_class_collections.ts b/test-files/test_parity_9023_transitive_class_collections.ts new file mode 100644 index 0000000000..180fda6b6e --- /dev/null +++ b/test-files/test_parity_9023_transitive_class_collections.ts @@ -0,0 +1,13 @@ +// Import only helpers: their inlined bodies must not lose the transitive class. +import { makeBag, getBag, track } from "./fixtures/issue_9023/helpers.ts"; +const empty = getBag(new Map(), 1); +let count = 0; +for (const pair of empty) count++; +console.log("empty", count); +const bag = makeBag(); +bag.add(1, 2); +bag.add(1, 3); +for (const [key, value] of bag) console.log("pair", key, value); +const store = new Map(); +track(store, 9, 4, 5); +for (const [key, value] of getBag(store, 9)) console.log("tracked", key, value); diff --git a/test-files/test_parity_9023_transitive_class_defaults.ts b/test-files/test_parity_9023_transitive_class_defaults.ts new file mode 100644 index 0000000000..189322ba1c --- /dev/null +++ b/test-files/test_parity_9023_transitive_class_defaults.ts @@ -0,0 +1,13 @@ +import { readDefault, readThunk, Builder } from "./fixtures/issue_9023/defaults.ts"; + +// The helper's class name must not bind to an unrelated class in the consumer. +class ImportedToken { + read() { return "consumer"; } +} +console.log("consumer", new ImportedToken().read()); +console.log("default", readDefault()); +console.log("explicit", readDefault(new ImportedToken())); +console.log("thunk", readThunk()); +const builder = new Builder(); +console.log("method", builder.make().read()); +console.log("method default", builder.read());