From a9584c988cf93cf357d41e360a2fe7cd078272d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:15:01 +0000 Subject: [PATCH 1/2] refactor(stdlib): remove lru-cache native binding Fixes #10685 -- the removal is the fix. Native `instanceof` threw "Right-hand side of 'instanceof' is not callable" and constructor.name was undefined (the handle isn't a real class object); core get/set/eviction logic was otherwise correct, but forEach/dispose silently no-op'd where npm's real implementation visits/invokes. Removes both copies (crates/perry-ext-lru-cache/ and the feature-gated crates/perry-stdlib/src/lru_cache.rs), the dedicated #10293 native-subclass machinery (crates/perry-runtime/src/lru_subclass.rs plus its call sites in perry-codegen), the LRUCache-only arms in every shared HIR/codegen recognition point (LRUCache/Command/Big/Decimal/BigNumber share several match blocks; only LRUCache's line is touched here), and every registry row (well_known_bindings.toml, NATIVE_MODULES, the API manifest, stdlib_features.rs, native_result_ledger, workspace-architecture.json, ci_ext_link_scope.py, Android stubs). Based on PR #10699's branch (fix/10439-native-binding-import-provenance): without that fix, lru-cache at its default import name is unreachable regardless of perry.compilePackages, so this removal is not independently mergeable. --- Cargo.lock | 24 - Cargo.toml | 2 - crates/perry-api-manifest/src/entries.rs | 1 - .../perry-api-manifest/src/entries/part_1.rs | 16 - crates/perry-codegen/src/expr/mod.rs | 2 +- .../perry-codegen/src/expr/this_super_call.rs | 33 +- .../perry-codegen/src/expr/write_barrier.rs | 16 - .../perry-codegen/src/lower_call/builtin.rs | 17 - .../src/lower_call/native_table/node_misc.rs | 73 --- .../src/lower_call/new_helpers.rs | 19 - .../src/runtime_decls/stdlib_ffi.rs | 3 +- .../src/runtime_decls/stdlib_ffi/utilities.rs | 13 +- crates/perry-ext-lru-cache/Cargo.toml | 28 - crates/perry-ext-lru-cache/src/lib.rs | 548 ------------------ crates/perry-ext-lru-cache/src/tests.rs | 504 ---------------- .../perry-ext-lru-cache/tests/gc_survival.rs | 171 ------ .../destructuring/var_decl/native_fetch.rs | 1 - .../src/destructuring/var_decl/native_new.rs | 2 - .../src/destructuring/var_decl_sources.rs | 46 +- crates/perry-hir/src/js_transform/imports.rs | 2 +- .../src/lower/expr_member/native_dispatch.rs | 27 - crates/perry-hir/src/lower/module_decl.rs | 2 - crates/perry-hir/src/lower/tests.rs | 41 +- crates/perry-hir/src/lower_decl/class_decl.rs | 5 - crates/perry-hir/src/lower_patterns.rs | 1 - crates/perry-runtime/src/lib.rs | 1 - crates/perry-runtime/src/lru_subclass.rs | 337 ----------- crates/perry-stdlib/Cargo.toml | 13 +- crates/perry-stdlib/src/lib.rs | 6 - crates/perry-stdlib/src/lru_cache.rs | 132 ----- crates/perry-ui-android/src/stdlib_stubs.rs | 32 - .../compile/collect_modules/feature_detect.rs | 8 +- .../perry/src/commands/compile/host_config.rs | 4 +- .../perry/src/commands/compile/well_known.rs | 39 +- crates/perry/src/commands/stdlib_features.rs | 3 - .../tests/issue_10293_lru_cache_subclass.rs | 154 ----- ..._10439_native_binding_import_provenance.rs | 32 +- crates/perry/well_known_bindings.toml | 45 -- docs/api/perry.d.ts | 7 +- docs/examples/stdlib/other/snippets.ts | 16 +- docs/src/api/reference.md | 16 +- docs/src/native-libraries/governance.md | 1 - docs/src/stdlib/other.md | 16 - docs/src/stdlib/overview.md | 1 - scripts/ci_ext_link_scope.py | 3 +- scripts/native_result_ledger.py | 4 +- scripts/native_result_ledger.tsv | 2 - .../next-app-route/provider/stdlib/Cargo.toml | 1 - workspace-architecture.json | 9 +- 49 files changed, 39 insertions(+), 2440 deletions(-) delete mode 100644 crates/perry-ext-lru-cache/Cargo.toml delete mode 100644 crates/perry-ext-lru-cache/src/lib.rs delete mode 100644 crates/perry-ext-lru-cache/src/tests.rs delete mode 100644 crates/perry-ext-lru-cache/tests/gc_survival.rs delete mode 100644 crates/perry-runtime/src/lru_subclass.rs delete mode 100644 crates/perry-stdlib/src/lru_cache.rs delete mode 100644 crates/perry/tests/issue_10293_lru_cache_subclass.rs diff --git a/Cargo.lock b/Cargo.lock index 1fbb5e01ea..ce445a929a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3165,11 +3165,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashlink" @@ -4455,15 +4450,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "lru" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" -dependencies = [ - "hashbrown 0.17.1", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -6017,15 +6003,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "perry-ext-lru-cache" -version = "0.5.1605" -dependencies = [ - "lru", - "perry-ffi", - "perry-runtime", -] - [[package]] name = "perry-ext-moment" version = "0.5.1605" @@ -6386,7 +6363,6 @@ dependencies = [ "lazy_static", "lettre", "libc", - "lru", "md-5 0.11.0", "ml-kem", "mongodb", diff --git a/Cargo.toml b/Cargo.toml index f5a536bf9d..1cb941e912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ members = [ "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", "crates/perry-perex", - "crates/perry-ext-lru-cache", "crates/perry-ext-better-sqlite3", "crates/perry-ext-zlib", "crates/perry-ext-exponential-backoff", @@ -477,7 +476,6 @@ perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } perry-perex = { path = "crates/perry-perex" } -perry-ext-lru-cache = { path = "crates/perry-ext-lru-cache" } perry-ext-better-sqlite3 = { path = "crates/perry-ext-better-sqlite3" } perry-ext-zlib = { path = "crates/perry-ext-zlib" } perry-ext-exponential-backoff = { path = "crates/perry-ext-exponential-backoff" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 7ea8ac5320..d9445384f9 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -92,7 +92,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "dns/promises", // (duplicate — kept for parity) "url", // URL / URLSearchParams // ── More third-party npm packages ── - "lru-cache", // LRU cache "commander", // CLI argument parser "decimal.js", // arbitrary-precision decimals "bignumber.js", // arbitrary-precision big numbers diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 2b801b3cf7..442076a1c9 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1000,22 +1000,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("domain", "remove", true, None), method("domain", "enter", true, None), method("domain", "exit", true, None), - method_sig( - "lru-cache", - "default", - false, - None, - &[p_any("p0")], - TypeSpec::Any, - ), - method("lru-cache", "get", true, None), - method("lru-cache", "set", true, None), - method("lru-cache", "has", true, None), - method("lru-cache", "delete", true, None), - method("lru-cache", "clear", true, None), - method("lru-cache", "size", true, None), - // `peek(key)` — read without refreshing recency (#7136). - method("lru-cache", "peek", true, None), method("commander", "name", true, None), method("commander", "description", true, None), method("commander", "version", true, None), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 02637d22a7..b1f8ef5174 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -148,7 +148,7 @@ pub(crate) use write_barrier::{ emit_write_barrier_slot_generation_tested, emit_write_barrier_slot_on_block, emit_write_barrier_slot_value_and_generation_tested, lower_array_super_init, lower_event_emitter_async_resource_subclass_init, lower_event_emitter_subclass_init, - lower_lru_cache_subclass_init, lower_node_stream_super_init, lower_stream_super_init, + lower_node_stream_super_init, lower_stream_super_init, }; // Issue #1098 phase 3: the `FnCtx` definition stays in this trunk, but its diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 18b51c5578..f0d5af3d44 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -14,8 +14,8 @@ use crate::types::{DOUBLE, I1, I32, I64, PTR}; use super::{ lower_array_super_init, lower_event_emitter_async_resource_subclass_init, - lower_event_emitter_subclass_init, lower_expr, lower_lru_cache_subclass_init, - lower_node_stream_super_init, lower_stream_super_init, nanbox_pointer_inline, FnCtx, + lower_event_emitter_subclass_init, lower_expr, lower_node_stream_super_init, + lower_stream_super_init, nanbox_pointer_inline, FnCtx, }; /// Enter one derived constructor's `super()` binding scope. @@ -936,35 +936,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } - // #10293: `class X extends LRUCache` (lru-cache). - // `LRUCache` is lowered as a compile-time pattern on the - // identifier and has no runtime value, so `extends` would - // otherwise throw "Class extends value is not a - // constructor". Same treatment as the node:stream bases: - // create the native cache and install its surface on - // `this`. path-scurry (via glob) is the case that needs it. - if parent_name.as_str() == "LRUCache" { - let operands: Vec<_> = super_args.iter().collect(); - return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| { - let options = lowered.first().cloned().unwrap_or_else(|| { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }); - let this_box = match ctx.this_stack.last().cloned() { - Some(slot) => ctx.block().load(DOUBLE, &slot), - None => { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - } - }; - lower_lru_cache_subclass_init(ctx, &this_box, &options); - bind_derived_this_after_super(ctx); - crate::lower_call::apply_field_initializers_recursive( - ctx, - ¤t_class_name, - crate::lower_call::FieldInitMode::SelfOnly, - )?; - Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) - }); - } if parent_name.as_str() == "EventEmitterAsyncResource" { let operands: Vec<_> = super_args.iter().collect(); return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| { diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index 2789891671..a1b6c65c82 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -1279,22 +1279,6 @@ pub(crate) fn lower_event_emitter_subclass_init(ctx: &mut FnCtx<'_>, this_box: & ); } -/// #10293: `super(options)` for a source-compiled `class X extends LRUCache`. -/// Installs the implemented cache surface directly onto `this` — `LRUCache` is -/// a compile-time lowering with no runtime value, so there is no base -/// constructor to call. -pub(crate) fn lower_lru_cache_subclass_init( - ctx: &mut FnCtx<'_>, - this_box: &str, - options_box: &str, -) { - ctx.block().call( - DOUBLE, - "js_lru_cache_subclass_init", - &[(DOUBLE, this_box), (DOUBLE, options_box)], - ); -} - pub(crate) fn lower_event_emitter_async_resource_subclass_init( ctx: &mut FnCtx<'_>, this_box: &str, diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index d976f3e675..a5e4366d67 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -633,23 +633,6 @@ pub(super) fn lower_builtin_new<'a>( let result = ctx.block().call(DOUBLE, runtime_fn, &[(DOUBLE, &opts_box)]); Ok(Some(result)) } - // lru-cache LRUCache — `new LRUCache({ max, ttl, updateAgeOnGet })`. - // The runtime parses the whole NaN-boxed options object itself - // (`js_lru_cache_new(options: f64)`), so we just lower the options - // argument and hand it through — no static field extraction, which - // means dynamic/variable options objects work too. A missing options - // argument passes `undefined`, which the runtime rejects with the - // same `TypeError` npm's constructor destructuring raises. - "LRUCache" => { - // npm's constructor ignores everything past the options object, - // but the arguments are still evaluated — the tail is lowered - // for its side effects so `new LRUCache(opts, f())` still calls - // `f`. #6986: `opts_val` was held across that lowering. - let opts_val = adopt_leading_arg_discard_rest(ctx, args, group)?; - let blk = ctx.block(); - let handle = blk.call(I64, "js_lru_cache_new", &[(DOUBLE, &opts_val)]); - Ok(Some(nanbox_pointer_inline(blk, &handle))) - } // (`WebSocketServer` is handled by an earlier branch lower in this // file — pre-existing from 2026-04-14. No new branch needed here.) // pg Client — `new Client(config)` matching npm pg's API: synchronous diff --git a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs index e51fd585f9..d096d921ed 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs @@ -281,79 +281,6 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ args: &[NA_F64, NA_F64, NA_F64, NA_F64], ret: NR_F64, }, - // ========== LRU Cache ========== - NativeModSig { - module: "lru-cache", - has_receiver: false, - method: "default", - class_filter: None, - runtime: "js_lru_cache_new", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "get", - class_filter: None, - runtime: "js_lru_cache_get", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "set", - class_filter: None, - runtime: "js_lru_cache_set", - args: &[NA_F64, NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "has", - class_filter: None, - runtime: "js_lru_cache_has", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "delete", - class_filter: None, - runtime: "js_lru_cache_delete", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "clear", - class_filter: None, - runtime: "js_lru_cache_clear", - args: &[], - ret: NR_VOID, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "size", - class_filter: None, - runtime: "js_lru_cache_size", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "lru-cache", - has_receiver: true, - method: "peek", - class_filter: None, - runtime: "js_lru_cache_peek", - args: &[NA_F64], - ret: NR_F64, - }, // ========== commander (CLI parsing) ========== // `new Command()` is dispatched separately by `lower_builtin_new` so it // produces a real CommanderHandle instead of an empty placeholder. The diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index f8c56b1e5c..3ed72efe24 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -86,7 +86,6 @@ pub(crate) enum NativeInstanceBase { Event, CustomEvent, DomException, - LruCache, } /// The native base a parent NAME denotes, if any. @@ -112,14 +111,6 @@ pub(crate) fn native_instance_base(name: &str) -> Option { "Event" => Some(NativeInstanceBase::Event), "CustomEvent" => Some(NativeInstanceBase::CustomEvent), "DOMException" => Some(NativeInstanceBase::DomException), - // #10293: `lru-cache`'s `LRUCache` is one of these too. It is a - // compile-time binding with no runtime class value, so `super(opts)` - // installs its surface onto `this` exactly like `EventEmitter` — and a - // subclass with NO constructor writes no `super()`, so without this arm - // `class C extends LRUCache {}` constructed bare and `c.set(...)` threw - // "set is not a function". The explicit-`super()` arm in - // `expr/this_super_call.rs` covers only the written-constructor case. - "LRUCache" => Some(NativeInstanceBase::LruCache), _ => None, } } @@ -196,16 +187,6 @@ pub(crate) fn emit_native_instance_base_init( // (already lowered for their side effects) are not forwarded. crate::expr::lower_event_emitter_subclass_init(ctx, this_box); } - NativeInstanceBase::LruCache => { - // Unlike the emitter, the options bag IS load-bearing (`{ max }` is - // required by the binding), so forward the first argument — what a - // written `super(opts)` would have passed. - let options = lowered_args - .first() - .cloned() - .unwrap_or_else(|| undef.clone()); - crate::expr::lower_lru_cache_subclass_init(ctx, this_box, &options); - } NativeInstanceBase::Array => { let n = lowered_args.len(); let (args_ptr, args_len) = if n == 0 { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index 05bf3a210f..133453c103 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -42,8 +42,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { declare_third_party(module); // URL / URLSearchParams + WebSocket. declare_web(module); - // @perryts/pdf, commander, dotenv, date libs, decimal.js, ethers, lodash, - // lru-cache. + // @perryts/pdf, commander, dotenv, date libs, decimal.js, ethers, lodash. declare_utilities(module); // node:stream, EventEmitter, domain, StringDecoder, querystring, fastify, // nodemailer, rate-limit, validator. diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs index 417c9b491e..d3806e61bc 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs @@ -1,6 +1,6 @@ //! Utility-package stdlib FFI declarations (extracted from stdlib_ffi.rs): //! @perryts/pdf, commander, dotenv, date libs (dayjs/datefns/moment), -//! decimal.js, ethers, lodash, lru-cache. +//! decimal.js, ethers, lodash. use crate::module::LlModule; use crate::types::{DOUBLE, I64, VOID}; @@ -221,15 +221,4 @@ pub(crate) fn declare_utilities(module: &mut LlModule) { module.declare_function("js_lodash_uniq", I64, &[I64]); module.declare_function("js_lodash_upper_case", I64, &[I64]); module.declare_function("js_lodash_upper_first", I64, &[I64]); - - // ========== LRU Cache ========== - module.declare_function("js_lru_cache_clear", VOID, &[I64]); - module.declare_function("js_lru_cache_delete", DOUBLE, &[I64, DOUBLE]); - module.declare_function("js_lru_cache_get", DOUBLE, &[I64, DOUBLE]); - module.declare_function("js_lru_cache_has", DOUBLE, &[I64, DOUBLE]); - module.declare_function("js_lru_cache_new", I64, &[DOUBLE]); - module.declare_function("js_lru_cache_peek", DOUBLE, &[I64, DOUBLE]); - module.declare_function("js_lru_cache_set", I64, &[I64, DOUBLE, DOUBLE]); - module.declare_function("js_lru_cache_size", DOUBLE, &[I64]); - module.declare_function("js_lru_cache_subclass_init", DOUBLE, &[DOUBLE, DOUBLE]); } diff --git a/crates/perry-ext-lru-cache/Cargo.toml b/crates/perry-ext-lru-cache/Cargo.toml deleted file mode 100644 index 1e234158a4..0000000000 --- a/crates/perry-ext-lru-cache/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "perry-ext-lru-cache" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `lru-cache` package — uses only `perry-ffi`. First handle-based wrapper port and acceptance test for perry-ffi's v0.5.x handle registry surface." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -lru = "0.18" - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } -# Direct handle for two things the tests need from the runtime: -# `tests/gc_survival.rs` forces a minor collection -# (`perry_runtime::gc::gc_collect_minor`) and drives the write-barrier / -# shadow-frame guard, mirroring perry-ext-events' scanner test; the unit -# tests use `exception::js_call_catching` to assert on the constructor's -# npm-matching option errors without the throw exiting the process. -# `default` + `stdlib` keep this copy feature-identical to the shipped -# runtime (see perry-ext-events/Cargo.toml for the #6303 rationale). -perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-lru-cache/src/lib.rs b/crates/perry-ext-lru-cache/src/lib.rs deleted file mode 100644 index 523dc999d8..0000000000 --- a/crates/perry-ext-lru-cache/src/lib.rs +++ /dev/null @@ -1,548 +0,0 @@ -//! Native bindings for the npm `lru-cache` package. -//! -//! Handle-based port under #466 Phase 5 — exercises the -//! `Handle` / `register_handle` / `with_handle_mut` surface plus the -//! perry-ffi GC-root-scanner surface (`gc_register_mutable_root_scanner_named`). -//! -//! # Values and keys are real JS values, not raw `f64` numbers -//! -//! Perry NaN-boxes every JS value into an `f64`, so the FFI ABI stays -//! homogeneous (every method takes/returns `f64`). But the *contents* -//! are arbitrary JS values, and this wrapper treats them as such: -//! -//! - **String keys hash/compare by CONTENT.** The previous version used -//! `key.to_bits() as i64` as the map key, so two different string -//! allocations holding the same text (or an SSO short string vs a heap -//! string) were treated as different keys and a `cache.get("k")` after -//! `cache.set("k", …)` missed. We materialize the key via -//! `js_get_string_pointer_unified` and key the map on the UTF-8 bytes. -//! -//! - **Stored heap values are GC roots for as long as they are cached.** -//! A cached object/string is otherwise unreachable from the JS shadow -//! stack, so the collector would free it out from under the cache — a -//! use-after-free that surfaces later as "value is not a function" / -//! corrupted reads. We register a mutable root scanner that visits -//! every cached value slot on each GC cycle, so live values are marked -//! AND rewritten to their forwarded address after copying evacuation. -//! -//! # Options -//! -//! `new LRUCache({ max, ttl, updateAgeOnGet })` is parsed from the -//! NaN-boxed options object (mirrors npm's option surface for the parts -//! typical callers use): -//! -//! - `max` — capacity; entries past it evict LRU-first. `0`/absent means -//! unbounded, which npm only permits together with a `ttl`. -//! - `ttl` — per-entry time-to-live in ms. `get`/`has`/`peek` on an -//! expired entry behave as if it were absent; `get` also evicts it. -//! - `updateAgeOnGet` — on a live `get`, reset the entry's TTL clock so -//! its age restarts from the access (npm semantics). -//! -//! The clock is the runtime's `performance.now()` (`js_performance_now`) -//! — the same monotonic source npm lru-cache uses (`perf_now`), and it -//! honors Perry's mock-timer facility. -//! -//! ## Option validation is npm's, measured — not invented -//! -//! npm `lru-cache` rejects bad `max`/`ttl` loudly, and the exact errors -//! are the contract a caller writes `try`/`catch` against. Every case -//! below was measured against `lru-cache@11.5.2` on the pinned oracle -//! (Node 26.5.1) and is reproduced here, message for message: -//! -//! | `new LRUCache(…)` | throws | -//! |---|---| -//! | `()` | `TypeError: Cannot read properties of undefined (reading 'max')` | -//! | `(null)` | `TypeError: Cannot read properties of null (reading 'max')` | -//! | `(5)`, `("x")`, `({})`, `({ max: 0 })` | `TypeError: At least one of max, maxSize, or ttl is required` | -//! | `({ max: -1 \| 1.5 \| Infinity \| NaN \| "3" \| true \| null })` | `TypeError: max option must be a nonnegative integer` | -//! | `({ max: 2**32 })` … up to `MAX_SAFE_INTEGER` | `RangeError: Invalid array length` | -//! | `({ max: 2**53 })`, `({ max: 1e300 })` | `Error: invalid max value: ` | -//! | `({ max: 3, ttl: -5 \| 1.5 \| Infinity \| "5" })` | `TypeError: ttl must be a positive integer if specified` | -//! -//! The two upper bounds are not arbitrary: npm builds its index arrays -//! with `Array.from({ length: max })` (so `max` past the JS array-length -//! limit is a `RangeError`) after an `getUintArray(max)` lookup that -//! returns `null` past `Number.MAX_SAFE_INTEGER` (a plain `Error`). -//! Reproducing them is what keeps `new LRUCache({ max: 1e12 })` from -//! reaching an allocator with a 10^12-entry reservation. -//! -//! ## Not (yet) implemented vs npm lru-cache -//! -//! `maxSize`/`sizeCalculation`, `dispose`/`disposeAfter`, `fetch`, -//! `allowStale`, per-call `set`/`get` option objects, and the -//! iterator/`forEach`/`entries` surface are out of scope — the ABI only -//! carries `(key, value)`. Because `maxSize` is unimplemented it also does -//! not satisfy npm's "at least one of max, maxSize, or ttl" requirement: -//! a `maxSize`-only cache constructs on npm but throws here, which is the -//! loud failure rather than a silently unbounded cache. npm's -//! `UnboundedCacheWarning` (`ttl`-only caches) is likewise not emitted. -//! **Object-identity keys** (using an object as a key) are supported by -//! pointer identity but are NOT tracked across a GC relocation; primitive -//! keys (string/number/bool) are the faithful, GC-safe path and cover all -//! real usage. - -use lru::LruCache; -use perry_ffi::{ - gc_register_mutable_root_scanner_named, iter_handles_of_mut, read_bytes, register_handle, - throw_with_code, with_handle_mut, ErrorKind, GcRootVisitor, Handle, JsString, JsValue, - StringHeader, -}; -use std::num::NonZeroUsize; -use std::sync::Once; - -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; -const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; -const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; - -/// Largest `max` npm can build its index arrays for -/// (`Array.from({ length: max })`, i.e. the JS array-length limit). -const MAX_ARRAY_LENGTH: f64 = 4_294_967_295.0; -/// Above this npm's `getUintArray(max)` returns `null` and the constructor -/// raises a plain `Error` instead of a `RangeError`. -const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; - -extern "C" { - // Monotonic ms clock — same source npm lru-cache uses (`perf_now`); - // honors Perry's mock timers. - fn js_performance_now() -> f64; - // Materialize any string repr (heap `STRING_TAG` or inline SSO - // `SHORT_STRING_TAG`) into a real `*StringHeader` so we can read bytes. - fn js_get_string_pointer_unified(value: f64) -> i64; - // Read an option field off the NaN-boxed options argument. The - // *boxed* variant validates its receiver instead of dereferencing it - // on faith, so a non-object `options` reads as all-undefined fields - // rather than a forged pointer deref (see `option_value`). - fn js_object_get_field_by_name_boxed(receiver: f64, key: *const StringHeader) -> f64; - fn js_is_truthy(value: f64) -> i32; - // JS `String(n)` — npm interpolates the offending `max` into its - // "invalid max value" message, and JS renders `1e300` as `"1e+300"` - // where Rust's `{}` would print 301 digits. - fn js_number_to_string(value: f64) -> *mut StringHeader; -} - -#[inline] -fn undefined() -> f64 { - f64::from_bits(TAG_UNDEFINED) -} - -/// A NaN-boxed JS boolean. npm `has`/`delete` return real booleans, and a -/// NaN-tagged bool round-trips through the `f64`-wide ABI unchanged (same as -/// the object pointers `get` already returns). -#[inline] -fn js_bool(b: bool) -> f64 { - f64::from_bits(if b { TAG_TRUE } else { TAG_FALSE }) -} - -/// An owned, GC-independent map key. -/// -/// Primitives are stored by value/content so a relocation of the caller's -/// JS value never invalidates a stored key. Object keys fall back to -/// pointer identity (see the crate-level "Not implemented" note). -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -enum CacheKey { - /// Canonicalized `f64` bits (+0/-0 unified, all NaNs unified — matching - /// JS `Map` SameValueZero key semantics). - Num(u64), - /// UTF-8 (or raw byte) content of a string key. - Str(Box<[u8]>), - Bool(bool), - Null, - Undefined, - /// Heap pointer identity (lower 48 bits) for object/array/function keys. - Obj(u64), - /// A string key whose bytes could not be materialized. Kept distinct - /// from `Str(b"")` so a failed read never aliases the empty-string key - /// — and keyed on the value's own bits so two *different* unresolvable - /// strings do not alias each other either. Such a key can only ever be - /// hit again by the identical value, which is the safe direction: a - /// miss, never someone else's entry. - UnresolvedStr(u64), -} - -#[inline] -fn canonical_num_bits(n: f64) -> u64 { - if n == 0.0 { - 0.0f64.to_bits() // unify +0.0 / -0.0 - } else if n.is_nan() { - f64::NAN.to_bits() // unify all NaN payloads - } else { - n.to_bits() - } -} - -/// Derive an owned [`CacheKey`] from a NaN-boxed key value. -fn cache_key(key: f64) -> CacheKey { - let jv = JsValue::from_bits(key.to_bits()); - if jv.is_any_string() { - // Materialize either string repr into a heap header, then copy bytes. - let ptr = unsafe { js_get_string_pointer_unified(key) } as *mut StringHeader; - if !ptr.is_null() { - let handle = unsafe { JsString::from_raw(ptr) }; - if let Some(bytes) = read_bytes(handle) { - return CacheKey::Str(bytes.to_vec().into_boxed_slice()); - } - } - CacheKey::UnresolvedStr(key.to_bits()) - } else if jv.is_int32() { - CacheKey::Num(canonical_num_bits(jv.to_int32() as f64)) - } else if jv.is_undefined() { - CacheKey::Undefined - } else if jv.is_null() { - CacheKey::Null - } else if jv.is_bool() { - CacheKey::Bool(jv.to_bool()) - } else if jv.is_pointer() { - CacheKey::Obj(key.to_bits() & POINTER_MASK) - } else { - // Real numbers (and anything else numeric) key by canonical bits. - CacheKey::Num(canonical_num_bits(f64::from_bits(key.to_bits()))) - } -} - -/// A cached value plus its optional TTL expiry (ms on the `performance.now` -/// clock). `value_bits` are NaN-boxed JS value bits, GC-rooted by -/// [`scan_lru_roots`] while the entry is live. -struct Entry { - value_bits: u64, - expires_at: Option, -} - -impl Entry { - #[inline] - fn is_expired(&self, now: f64) -> bool { - matches!(self.expires_at, Some(t) if now >= t) - } -} - -/// Wrapper struct so the registry's downcast resolves uniquely. -pub struct LruCacheHandle { - cache: LruCache, - /// Default per-entry TTL in ms, or `None` when `ttl` was not set. - ttl_ms: Option, - /// npm `updateAgeOnGet` — refresh an entry's TTL clock on `get`. - update_age_on_get: bool, -} - -impl LruCacheHandle { - /// `max_size == 0` is npm's unbounded (`ttl`-only) cache. - /// - /// `LruCache::new(cap)` eagerly reserves a `HashMap` of `cap` buckets. - /// npm accepts any `max` up to the JS array-length limit, so an eager - /// reservation turns a legal `new LRUCache({ max: 1e9 })` into a - /// multi-gigabyte allocation before the first insert — the same shape - /// of failure npm itself hits (it OOMs Node there). `unbounded()` plus - /// `resize()` yields an identical eviction bound over a lazily grown - /// map, so Perry survives a range where npm dies. That is the only - /// deliberate divergence in this constructor and it is one-directional: - /// no program can observe it except by not running out of memory. - fn new(max_size: usize, ttl_ms: Option, update_age_on_get: bool) -> Self { - let mut cache = LruCache::unbounded(); - if let Some(cap) = NonZeroUsize::new(max_size) { - cache.resize(cap); - } - LruCacheHandle { - cache, - ttl_ms, - update_age_on_get, - } - } - - #[inline] - fn expiry_from_now(&self, now: f64) -> Option { - self.ttl_ms.and_then(|ttl| (ttl > 0.0).then_some(now + ttl)) - } -} - -static GC_REGISTERED: Once = Once::new(); - -fn ensure_gc_scanner() { - GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-lru-cache", scan_lru_roots); - }); -} - -/// GC root scanner: visit every cached value slot across every live cache -/// handle so the collector marks the referent and, under copying -/// evacuation, rewrites the stored bits to the forwarded address. -fn scan_lru_roots(visitor: &mut GcRootVisitor<'_>) { - iter_handles_of_mut::(|h| { - for (_key, entry) in h.cache.iter_mut() { - visitor.visit_nanbox_u64_slot(&mut entry.value_bits); - } - }); -} - -#[inline] -fn now_ms() -> f64 { - unsafe { js_performance_now() } -} - -/// Read `options.` off the NaN-boxed options argument. -/// -/// Routed through the runtime's *boxed*-receiver getter instead of -/// unboxing to a `*const ObjectHeader` here. `options` is whatever the -/// caller passed — an object, but equally a string, an array, a function, -/// a native handle id, or a double whose bit pattern lands inside the -/// heap-pointer window. The unboxed getter dereferences its argument on -/// faith, which is fine only when codegen has *proven* the receiver is an -/// object; nothing proves that here. The boxed entry point owns the -/// classification (handle-band routing plus the canonical address check), -/// so this wrapper does not re-implement a pointer-range test the runtime -/// already exports — the previous hand-rolled `is_pointer() && >= 0x1000` -/// pair was both a duplicate of that rule and subtly different from it. -/// -/// npm reads these options by destructuring, which yields `undefined` for -/// every field of a non-object rather than throwing, and that is exactly -/// what the boxed getter returns for one. -fn option_value(options: f64, name: &str) -> JsValue { - let key = perry_ffi::alloc_string(name); - // SAFETY: `key` owns the freshly allocated header, and the boxed - // getter validates `options` itself. - let raw = unsafe { js_object_get_field_by_name_boxed(options, key.as_raw()) }; - JsValue::from_bits(raw.to_bits()) -} - -/// npm's `isPosInt`: `!!n && n === Math.floor(n) && n > 0 && isFinite(n)`. -/// -/// The `===` is a *strict* compare against `Math.floor(n)`, so a non-number -/// can never be a positive integer — `"3"`, `true` and `null` all fail it, -/// which is why they raise the same `TypeError` as `-1` does. -fn is_pos_int(v: JsValue) -> bool { - if !v.is_number() { - return false; - } - let n = v.to_number(); - n.is_finite() && n > 0.0 && n == n.trunc() -} - -/// JS `String(n)`, for interpolating a number into an npm error message. -fn js_number_string(n: f64) -> String { - // SAFETY: the runtime returns either null or a live `StringHeader`. - let ptr = unsafe { js_number_to_string(n) }; - if ptr.is_null() { - return n.to_string(); - } - let handle = unsafe { JsString::from_raw(ptr) }; - read_bytes(handle).map_or_else( - || n.to_string(), - |b| String::from_utf8_lossy(b).into_owned(), - ) -} - -/// npm: `const { max = 0 } = options; if (max !== 0 && !isPosInt(max)) throw …` -/// followed by the `getUintArray` / `Array.from({ length: max })` bounds. -/// Returns the validated `max` (`0` = unbounded); diverges on a bad value. -fn parse_max(options: f64) -> f64 { - let raw = option_value(options, "max"); - if raw.is_undefined() { - return 0.0; // npm's `max = 0` destructuring default - } - // npm's `max !== 0` is strict, so only the *numbers* +0/-0 skip the - // validation below. `null`/`false`/`""` are all `!== 0` and throw. - if raw.is_number() && raw.to_number() == 0.0 { - return 0.0; - } - if !is_pos_int(raw) { - throw_with_code( - "max option must be a nonnegative integer", - "", - ErrorKind::TypeError, - ); - } - let max = raw.to_number(); - if max > MAX_SAFE_INTEGER { - // npm: `if (!UintArray) throw new Error('invalid max value: ' + max)`. - let msg = format!("invalid max value: {}", js_number_string(max)); - throw_with_code(&msg, "", ErrorKind::Error); - } - if max > MAX_ARRAY_LENGTH { - // npm: `Array.from({ length: max })` — V8's array-length check. - throw_with_code("Invalid array length", "", ErrorKind::RangeError); - } - max -} - -/// npm: `this.ttl = ttl || 0; if (this.ttl && !isPosInt(this.ttl)) throw …`. -/// Returns the validated ttl in ms (`0` = none); diverges on a bad value. -fn parse_ttl(options: f64) -> f64 { - let raw = option_value(options, "ttl"); - // `ttl || 0` — undefined, null, `0`, `NaN` and `""` all collapse to 0 - // *without* tripping the validation (npm only checks a truthy ttl). - // SAFETY: `js_is_truthy` reads a NaN-boxed value by value. - if unsafe { js_is_truthy(f64::from_bits(raw.bits())) } == 0 { - return 0.0; - } - if !is_pos_int(raw) { - throw_with_code( - "ttl must be a positive integer if specified", - "", - ErrorKind::TypeError, - ); - } - raw.to_number() -} - -/// `new LRUCache(options)` — register a fresh cache and return its handle. -/// -/// `options` is the NaN-boxed options argument. Validation mirrors npm -/// `lru-cache` exactly (see the crate-level table); a rejected option -/// throws the JS error npm throws rather than being silently clamped. -#[no_mangle] -pub extern "C" fn js_lru_cache_new(options: f64) -> Handle { - ensure_gc_scanner(); - - let opts = JsValue::from_bits(options.to_bits()); - // npm destructures `options` in the constructor *signature*, so a - // missing or null argument is a property read on undefined/null. The - // message a caller sees on Node is V8's, so that is the message here. - if opts.is_undefined() { - throw_with_code( - "Cannot read properties of undefined (reading 'max')", - "", - ErrorKind::TypeError, - ); - } - if opts.is_null() { - throw_with_code( - "Cannot read properties of null (reading 'max')", - "", - ErrorKind::TypeError, - ); - } - // Everything else — a primitive, a string, an array, a function, a - // native handle id — destructures cleanly into all-undefined fields on - // npm, and `option_value` reproduces that without this code having to - // classify the pointer itself. - let max = parse_max(options); - let ttl = parse_ttl(options); - if max == 0.0 && ttl == 0.0 { - // npm: "do not allow completely unbounded caches". `maxSize` would - // also satisfy this on npm, but it is unimplemented here (see the - // crate-level scope note), so it cannot. - throw_with_code( - "At least one of max, maxSize, or ttl is required", - "", - ErrorKind::TypeError, - ); - } - // SAFETY: `js_is_truthy` reads a NaN-boxed value by value. - let update_age_on_get = unsafe { - js_is_truthy(f64::from_bits( - option_value(options, "updateAgeOnGet").bits(), - )) != 0 - }; - - register_handle(LruCacheHandle::new( - max as usize, - (ttl > 0.0).then_some(ttl), - update_age_on_get, - )) -} - -/// `cache.get(key)` — returns `undefined` when the key is absent or its -/// entry has expired (an expired entry is evicted). Bumps LRU recency; when -/// `updateAgeOnGet` is set, also resets the entry's TTL clock. -#[no_mangle] -pub extern "C" fn js_lru_cache_get(handle: Handle, key: f64) -> f64 { - let k = cache_key(key); - let now = now_ms(); - with_handle_mut::(handle, |h| { - let refresh = h.update_age_on_get; - let new_expiry = h.expiry_from_now(now); - let outcome = match h.cache.get_mut(&k) { - Some(entry) => { - if entry.is_expired(now) { - None // expired → evict below - } else { - if refresh { - entry.expires_at = new_expiry; - } - Some(entry.value_bits) - } - } - None => return undefined(), - }; - match outcome { - Some(bits) => f64::from_bits(bits), - None => { - h.cache.pop(&k); - undefined() - } - } - }) - .unwrap_or_else(undefined) -} - -/// `cache.set(key, value)` — returns the handle for chaining. -#[no_mangle] -pub extern "C" fn js_lru_cache_set(handle: Handle, key: f64, value: f64) -> Handle { - let k = cache_key(key); - let now = now_ms(); - with_handle_mut::(handle, |h| { - let expires_at = h.expiry_from_now(now); - h.cache.put( - k, - Entry { - value_bits: value.to_bits(), - expires_at, - }, - ); - }); - handle -} - -/// `cache.has(key)` → `true` / `false`. Does not bump recency and does not -/// refresh age; an expired entry reads as absent (but is not evicted here, -/// matching npm's lazy purge). -#[no_mangle] -pub extern "C" fn js_lru_cache_has(handle: Handle, key: f64) -> f64 { - let k = cache_key(key); - let now = now_ms(); - js_bool( - with_handle_mut::( - handle, - |h| matches!(h.cache.peek(&k), Some(entry) if !entry.is_expired(now)), - ) - .unwrap_or(false), - ) -} - -/// `cache.delete(key)` → `true` if removed, `false` if absent. -#[no_mangle] -pub extern "C" fn js_lru_cache_delete(handle: Handle, key: f64) -> f64 { - let k = cache_key(key); - js_bool( - with_handle_mut::(handle, |h| h.cache.pop(&k).is_some()) - .unwrap_or(false), - ) -} - -/// `cache.clear()` — drops every entry. -#[no_mangle] -pub extern "C" fn js_lru_cache_clear(handle: Handle) { - with_handle_mut::(handle, |h| h.cache.clear()); -} - -/// `cache.size` — current entry count. -#[no_mangle] -pub extern "C" fn js_lru_cache_size(handle: Handle) -> f64 { - with_handle_mut::(handle, |h| h.cache.len() as f64).unwrap_or(0.0) -} - -/// `cache.peek(key)` — like `get` but doesn't bump recency and doesn't -/// refresh age. Returns `undefined` for an absent or expired entry (and -/// leaves an expired entry in place, matching npm's lazy purge). -#[no_mangle] -pub extern "C" fn js_lru_cache_peek(handle: Handle, key: f64) -> f64 { - let k = cache_key(key); - let now = now_ms(); - with_handle_mut::(handle, |h| match h.cache.peek(&k) { - Some(entry) if !entry.is_expired(now) => f64::from_bits(entry.value_bits), - _ => undefined(), - }) - .unwrap_or_else(undefined) -} - -#[cfg(test)] -mod tests; diff --git a/crates/perry-ext-lru-cache/src/tests.rs b/crates/perry-ext-lru-cache/src/tests.rs deleted file mode 100644 index 0e781ef17b..0000000000 --- a/crates/perry-ext-lru-cache/src/tests.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! Unit tests for the lru-cache wrapper. -//! -//! Every test links `perry-runtime` (the `runtime-link` dev-dep feature) -//! because the wrapper reaches the runtime for its clock -//! (`js_performance_now`) and string materialization -//! (`js_get_string_pointer_unified`), and reaches `perry-runtime` -//! directly for `js_call_catching` so a rejected constructor option can be -//! asserted on instead of exiting the process. The GC-survival test lives -//! in `tests/gc_survival.rs` — see that file for why it needs its own -//! process. - -use super::*; -use perry_ffi::{alloc_string, nanbox_string_bits, JsValue}; -/// NaN-boxed `f64` for a freshly allocated JS string with `text`. -fn string_value(text: &str) -> f64 { - let s = alloc_string(text); - assert!(!s.is_null(), "alloc_string returned null"); - f64::from_bits(nanbox_string_bits(s.as_raw())) -} - -/// Build a real JS options object carrying `fields`. -/// -/// Null-prototype on purpose. `options` is read for *own* properties only -/// — that is what npm's destructuring does and what the wrapper does — so -/// the prototype is immaterial to what is under test, and the compiled -/// A/B against the npm package covers the ordinary object literal a real -/// caller writes. -/// -/// The reason not to build these the ordinary way is that -/// `js_object_alloc(0, n)` followed by `js_object_set_field_by_name` -/// destabilizes the collector for the rest of the process: with options -/// objects built that way this suite SIGSEGVs deterministically under -/// `--test-threads=1` (in `gc::copying::scan_slot`, walking a bogus slot -/// address) and intermittently otherwise — measured at 1 failure in 12 -/// runs, against 0 in 40 with the null-proto construction, and 0 in 12 -/// for the pre-existing suite that allocated no JS objects at all. The -/// failure lands in whichever test happens to run next, e.g. a string -/// compare dereferencing string *content* as a pointer. That is a runtime -/// bug rather than anything this binding does; it is written up on the PR -/// so it can be fixed where it lives instead of being rediscovered from a -/// mystery flake here. -fn options(fields: &[(&str, f64)]) -> f64 { - let boxed: Vec<(&str, JsValue)> = fields - .iter() - .map(|(n, v)| (*n, JsValue::from_bits(v.to_bits()))) - .collect(); - let obj = perry_ffi::alloc_null_proto_object(&boxed); - assert!( - obj.is_pointer(), - "alloc_null_proto_object returned a non-object" - ); - f64::from_bits(obj.bits()) -} - -/// `new LRUCache({ max })` through the real constructor. -fn new_cache(max: f64) -> Handle { - js_lru_cache_new(options(&[("max", max)])) -} - -/// Call `js_lru_cache_new` inside a JS `try` so a rejected option can be -/// asserted on. Without the `try`, a throw at depth 0 prints the uncaught -/// error and exits the process, taking the test binary with it. -fn new_catching(opts: f64) -> Result { - match perry_runtime::exception::js_call_catching(|| js_lru_cache_new(opts) as f64) { - Ok(handle) => Ok(handle as Handle), - Err(bits) => { - let field = |name: &str| { - let key = alloc_string(name); - let v = unsafe { js_object_get_field_by_name_boxed(bits, key.as_raw()) }; - read_string_value(v).unwrap_or_default() - }; - Err((field("name"), field("message"))) - } - } -} - -/// Assert `new LRUCache(opts)` throws npm's `name` + `message`. -#[track_caller] -fn assert_throws(opts: f64, name: &str, message: &str) { - match new_catching(opts) { - Ok(h) => { - perry_ffi::drop_handle(h); - panic!("expected {name}: {message}, but the constructor returned a cache"); - } - Err((got_name, got_message)) => { - assert_eq!( - (got_name.as_str(), got_message.as_str()), - (name, message), - "constructor error must match npm lru-cache byte for byte" - ); - } - } -} - -/// npm `has`/`delete` return NaN-boxed JS booleans — decode one. -fn is_true(v: f64) -> bool { - v.to_bits() == TAG_TRUE -} - -/// Read the string content behind a NaN-boxed value produced by the cache. -fn read_string_value(value: f64) -> Option { - let ptr = unsafe { js_get_string_pointer_unified(value) } as *mut StringHeader; - if ptr.is_null() { - return None; - } - let handle = unsafe { JsString::from_raw(ptr) }; - read_bytes(handle).map(|b| String::from_utf8_lossy(b).into_owned()) -} - -// ── pure key-derivation logic (no runtime clock) ───────────────────── - -#[test] -fn canonical_num_unifies_zero_and_nan() { - assert_eq!(canonical_num_bits(0.0), canonical_num_bits(-0.0)); - assert_eq!( - canonical_num_bits(f64::NAN), - canonical_num_bits(f64::from_bits(0x7FF8_0000_0000_0001)) - ); - assert_ne!(canonical_num_bits(1.0), canonical_num_bits(2.0)); -} - -#[test] -fn cache_key_primitive_variants() { - assert_eq!(cache_key(3.5), CacheKey::Num(canonical_num_bits(3.5))); - assert_eq!( - cache_key(f64::from_bits(JsValue::from_int32(7).bits())), - CacheKey::Num(canonical_num_bits(7.0)) - ); - assert_eq!( - cache_key(f64::from_bits(JsValue::TRUE.bits())), - CacheKey::Bool(true) - ); - assert_eq!( - cache_key(f64::from_bits(JsValue::NULL.bits())), - CacheKey::Null - ); - assert_eq!( - cache_key(f64::from_bits(JsValue::UNDEFINED.bits())), - CacheKey::Undefined - ); -} - -// ── numeric-key behaviour (parity with the old surface) ────────────── - -#[test] -fn basic_set_get_round_trip() { - let h = new_cache(10.0); - assert_ne!(h, perry_ffi::INVALID_HANDLE); - js_lru_cache_set(h, 1.0, 100.0); - assert_eq!(js_lru_cache_get(h, 1.0), 100.0); - assert!(is_true(js_lru_cache_has(h, 1.0))); - assert_eq!(js_lru_cache_size(h), 1.0); - perry_ffi::drop_handle(h); -} - -#[test] -fn lru_eviction_at_max_size() { - // max:3 via a directly-built handle (options-object parsing is covered - // end-to-end by the compiled smoke program, not reachable from a unit - // test without allocating a real JS object). - let h = perry_ffi::register_handle(LruCacheHandle::new(3, None, false)); - for i in 0..3 { - js_lru_cache_set(h, i as f64, (i * 10) as f64); - } - assert_eq!(js_lru_cache_size(h), 3.0); - // Adding a 4th evicts the LRU (key=0). - js_lru_cache_set(h, 99.0, 990.0); - assert_eq!(js_lru_cache_size(h), 3.0); - assert!(!is_true(js_lru_cache_has(h, 0.0))); - assert!(is_true(js_lru_cache_has(h, 99.0))); - perry_ffi::drop_handle(h); -} - -#[test] -fn delete_and_clear() { - let h = new_cache(10.0); - js_lru_cache_set(h, 1.0, 100.0); - js_lru_cache_set(h, 2.0, 200.0); - assert!(is_true(js_lru_cache_delete(h, 1.0))); - assert!(!is_true(js_lru_cache_delete(h, 1.0))); // already gone - assert_eq!(js_lru_cache_size(h), 1.0); - js_lru_cache_clear(h); - assert_eq!(js_lru_cache_size(h), 0.0); - perry_ffi::drop_handle(h); -} - -#[test] -fn peek_does_not_bump_recency() { - let h = perry_ffi::register_handle(LruCacheHandle::new(2, None, false)); - js_lru_cache_set(h, 1.0, 100.0); - js_lru_cache_set(h, 2.0, 200.0); - // peek(1) reads but does not bump recency, so adding key 3 evicts key 1. - let _ = js_lru_cache_peek(h, 1.0); - js_lru_cache_set(h, 3.0, 300.0); - assert!(!is_true(js_lru_cache_has(h, 1.0))); - assert!(is_true(js_lru_cache_has(h, 2.0))); - assert!(is_true(js_lru_cache_has(h, 3.0))); - perry_ffi::drop_handle(h); -} - -#[test] -fn missing_key_returns_undefined() { - let h = new_cache(10.0); - let v = js_lru_cache_get(h, 42.0); - assert_eq!(v.to_bits(), TAG_UNDEFINED, "missing key must be undefined"); - perry_ffi::drop_handle(h); -} - -#[test] -fn invalid_handle_is_no_op() { - assert_eq!(js_lru_cache_get(99_999, 0.0).to_bits(), TAG_UNDEFINED); - assert!(!is_true(js_lru_cache_has(99_999, 0.0))); - assert_eq!(js_lru_cache_size(99_999), 0.0); - js_lru_cache_clear(99_999); // no panic -} - -// ── string keys hash/compare by content (the core fix) ─────────────── - -#[test] -fn string_key_round_trip_by_content() { - let h = new_cache(10.0); - // Store under one string allocation… - js_lru_cache_set(h, string_value("cache-key"), 4242.0); - // …read back through a *different* allocation of the same text. The old - // pointer-bits keying missed here; content keying hits. - assert_eq!(js_lru_cache_get(h, string_value("cache-key")), 4242.0); - assert!(is_true(js_lru_cache_has(h, string_value("cache-key")))); - assert!(!is_true(js_lru_cache_has(h, string_value("other")))); - assert_eq!(js_lru_cache_size(h), 1.0); - perry_ffi::drop_handle(h); -} - -#[test] -fn string_key_object_value_round_trip() { - let h = new_cache(10.0); - js_lru_cache_set( - h, - string_value("payload"), - string_value("hello-world-value"), - ); - let got = js_lru_cache_get(h, string_value("payload")); - assert_eq!(read_string_value(got).as_deref(), Some("hello-world-value")); - perry_ffi::drop_handle(h); -} - -// ── TTL + updateAgeOnGet ───────────────────────────────────────────── - -#[test] -fn ttl_expiry_evicts_on_get() { - let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(20.0), false)); - js_lru_cache_set(h, 1.0, 111.0); - assert_eq!(js_lru_cache_get(h, 1.0), 111.0); - std::thread::sleep(std::time::Duration::from_millis(60)); - // Expired: get returns undefined AND evicts. - assert_eq!(js_lru_cache_get(h, 1.0).to_bits(), TAG_UNDEFINED); - assert_eq!(js_lru_cache_size(h), 0.0, "expired entry evicted by get"); - perry_ffi::drop_handle(h); -} - -#[test] -fn has_and_peek_report_expired_as_absent() { - let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(20.0), false)); - js_lru_cache_set(h, 1.0, 111.0); - std::thread::sleep(std::time::Duration::from_millis(60)); - assert!(!is_true(js_lru_cache_has(h, 1.0))); - assert_eq!(js_lru_cache_peek(h, 1.0).to_bits(), TAG_UNDEFINED); - perry_ffi::drop_handle(h); -} - -// The TTL/sleep ratio below is deliberately wide. A 3:1 TTL-to-sleep -// margin means a loaded CI runner has to overshoot a 150 ms sleep by -// 150 ms before the refresh test can misread a live entry as expired. -const REFRESH_TTL_MS: f64 = 450.0; -const REFRESH_STEP: std::time::Duration = std::time::Duration::from_millis(150); - -#[test] -fn update_age_on_get_refreshes_ttl() { - let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(REFRESH_TTL_MS), true)); - js_lru_cache_set(h, 1.0, 111.0); - // A third of the way through the TTL, a get refreshes the clock. - std::thread::sleep(REFRESH_STEP); - assert_eq!(js_lru_cache_get(h, 1.0), 111.0); - // Two more steps put us past the original expiry; the entry is still - // live *because* the get above restarted its clock. - std::thread::sleep(REFRESH_STEP); - std::thread::sleep(REFRESH_STEP); - assert_eq!(js_lru_cache_get(h, 1.0), 111.0, "get refreshed the TTL"); - perry_ffi::drop_handle(h); -} - -#[test] -fn no_update_age_on_get_lets_ttl_expire() { - let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(REFRESH_TTL_MS), false)); - js_lru_cache_set(h, 1.0, 111.0); - std::thread::sleep(REFRESH_STEP); - assert_eq!(js_lru_cache_get(h, 1.0), 111.0); // still live, no refresh - std::thread::sleep(REFRESH_STEP); - std::thread::sleep(REFRESH_STEP); - // Past the TTL measured from `set`, and never refreshed → expired. - assert_eq!(js_lru_cache_get(h, 1.0).to_bits(), TAG_UNDEFINED); - perry_ffi::drop_handle(h); -} - -// ── options-object parsing ─────────────────────────────────────────── -// -// Every expectation below was measured against `lru-cache@11.5.2` under -// the repo's pinned Node oracle (26.5.1). They are npm's errors, not -// Perry's invention — see the table in the crate docs. - -#[test] -fn options_object_is_honored() { - let h = js_lru_cache_new(options(&[ - ("max", 3.0), - ("ttl", 5_000.0), - ("updateAgeOnGet", f64::from_bits(TAG_TRUE)), - ])); - let parsed = - with_handle_mut::(h, |c| (c.ttl_ms, c.update_age_on_get)).unwrap(); - assert_eq!(parsed, (Some(5_000.0), true)); - for i in 0..4 { - js_lru_cache_set(h, i as f64, i as f64); - } - assert_eq!(js_lru_cache_size(h), 3.0, "max:3 bounds the cache"); - perry_ffi::drop_handle(h); -} - -#[test] -fn huge_max_is_a_range_error_not_an_allocation() { - // The regression this pins: `max: 1e12` used to saturate through - // `n as usize` into the backing map's reserve and abort the process. - // npm raises `Array.from({ length: 1e12 })`'s RangeError instead. - assert_throws( - options(&[("max", 1e12)]), - "RangeError", - "Invalid array length", - ); - assert_throws( - options(&[("max", 4_294_967_296.0)]), - "RangeError", - "Invalid array length", - ); - // Past MAX_SAFE_INTEGER npm reports its own `getUintArray` failure, - // and renders the number the way JS does. - assert_throws( - options(&[("max", 9_007_199_254_740_992.0)]), - "Error", - "invalid max value: 9007199254740992", - ); - assert_throws( - options(&[("max", 1e300)]), - "Error", - "invalid max value: 1e+300", - ); -} - -#[test] -fn max_below_the_range_error_still_constructs_lazily() { - // Just under the array-length limit: npm OOMs Node here, Perry does - // not, because the backing map grows lazily instead of reserving - // `max` buckets up front. Constructing must be instant and cheap. - let h = new_cache(MAX_ARRAY_LENGTH); - js_lru_cache_set(h, 1.0, 111.0); - assert_eq!(js_lru_cache_get(h, 1.0), 111.0); - assert_eq!(js_lru_cache_size(h), 1.0); - perry_ffi::drop_handle(h); -} - -#[test] -fn non_integer_max_is_a_type_error() { - const MSG: &str = "max option must be a nonnegative integer"; - for bad in [-1.0, 1.5, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { - assert_throws(options(&[("max", bad)]), "TypeError", MSG); - } - // Non-numbers fail npm's strict `n === Math.floor(n)` too. - assert_throws(options(&[("max", string_value("3"))]), "TypeError", MSG); - assert_throws( - options(&[("max", f64::from_bits(JsValue::TRUE.bits()))]), - "TypeError", - MSG, - ); - assert_throws( - options(&[("max", f64::from_bits(JsValue::NULL.bits()))]), - "TypeError", - MSG, - ); -} - -#[test] -fn unbounded_cache_is_rejected() { - const MSG: &str = "At least one of max, maxSize, or ttl is required"; - // `max: 0` and `max: -0` pass npm's `max !== 0` guard, then fall into - // its "do not allow completely unbounded caches" check. - assert_throws(options(&[("max", 0.0)]), "TypeError", MSG); - assert_throws(options(&[("max", -0.0)]), "TypeError", MSG); - assert_throws(options(&[]), "TypeError", MSG); - // A primitive `options` destructures into all-undefined fields. - assert_throws(5.0, "TypeError", MSG); - assert_throws(string_value("x"), "TypeError", MSG); -} - -#[test] -fn missing_options_matches_npm_destructuring() { - assert_throws( - f64::from_bits(TAG_UNDEFINED), - "TypeError", - "Cannot read properties of undefined (reading 'max')", - ); - assert_throws( - f64::from_bits(JsValue::NULL.bits()), - "TypeError", - "Cannot read properties of null (reading 'max')", - ); -} - -#[test] -fn ttl_alone_makes_an_unbounded_cache() { - let h = js_lru_cache_new(options(&[("ttl", 5_000.0)])); - for i in 0..1_000 { - js_lru_cache_set(h, i as f64, i as f64); - } - assert_eq!(js_lru_cache_size(h), 1_000.0, "no max ⇒ no eviction"); - perry_ffi::drop_handle(h); -} - -#[test] -fn non_integer_ttl_is_a_type_error() { - const MSG: &str = "ttl must be a positive integer if specified"; - for bad in [-5.0, 1.5, f64::INFINITY] { - assert_throws(options(&[("max", 3.0), ("ttl", bad)]), "TypeError", MSG); - } - assert_throws( - options(&[("max", 3.0), ("ttl", string_value("5"))]), - "TypeError", - MSG, - ); - // npm's `ttl || 0` swallows every falsy ttl before the check runs. - for falsy in [0.0, f64::NAN] { - let h = js_lru_cache_new(options(&[("max", 3.0), ("ttl", falsy)])); - let ttl = with_handle_mut::(h, |c| c.ttl_ms).unwrap(); - assert_eq!(ttl, None, "falsy ttl is dropped, not rejected"); - perry_ffi::drop_handle(h); - } -} - -// ── GC rooting ─────────────────────────────────────────────────────── -// -// The cached-value-survives-a-copying-minor test lives in its own test -// binary (`tests/gc_survival.rs`). It needs a pristine heap to assert -// that the collector actually *relocated* the value, and it cannot get -// one here: any earlier test's stack leftovers conservatively pin the -// string (minor then reports `copied_objects=0`), and driving -// `gc_collect_minor()` in a binary that has also allocated JS objects -// SIGSEGVs the copying collector. See that file's module docs. - -// ── heap-typed non-object options ──────────────────────────────────── - -extern "C" { - fn js_array_alloc(capacity: u32) -> *mut std::ffi::c_void; - fn js_array_set_f64_extend(arr: *mut std::ffi::c_void, index: u32, value: f64); - fn js_nanbox_pointer(ptr: i64) -> f64; -} - -/// A heap value that is *not* a plain object must read as all-undefined -/// fields, never as a dereference of a forged object pointer. -/// -/// npm reaches its options by destructuring, so a string, an array, a -/// function, a `Map` — anything without a `max` own property — lands on -/// the same "At least one of max, maxSize, or ttl is required" TypeError. -/// Measured against `lru-cache@11.5.2` under Node 26.5.1 for strings, -/// arrays, `Map`, `Set`, functions, `Date`, `RegExp` and boxed numbers; -/// `Object.assign([], { max: 3 })` constructs fine there, because the own -/// property is what matters, not the exotic-ness of the receiver. -/// -/// The point of the test is the *receiver classification*, not the error: -/// `options` arrives as an untrusted NaN-boxed value, and the wrapper must -/// not unbox it to a `*const ObjectHeader` on faith. Arrays and handle-band -/// ids are the two shapes that pass a naive `is_pointer()` check. -#[test] -fn heap_typed_non_object_options_read_as_undefined_fields() { - const MSG: &str = "At least one of max, maxSize, or ttl is required"; - - assert_throws(string_value("longer-than-inline-string"), "TypeError", MSG); - - let empty = unsafe { js_array_alloc(4) }; - assert_throws(unsafe { js_nanbox_pointer(empty as i64) }, "TypeError", MSG); - - // A populated array has real element bytes behind the cast, so a - // forged-object read would find *something* rather than a zeroed slot. - let populated = unsafe { js_array_alloc(8) }; - for i in 0..8u32 { - unsafe { js_array_set_f64_extend(populated, i, 42.0 + f64::from(i)) }; - } - assert_throws( - unsafe { js_nanbox_pointer(populated as i64) }, - "TypeError", - MSG, - ); - - // Native handle ids: pointer-tagged, above the old hand-rolled 0x1000 - // floor, but small integers rather than heap addresses. - for raw in [0x1001_i64, 0x2000, 0x8000, 0xF_FFFF] { - assert_throws(unsafe { js_nanbox_pointer(raw) }, "TypeError", MSG); - } -} diff --git a/crates/perry-ext-lru-cache/tests/gc_survival.rs b/crates/perry-ext-lru-cache/tests/gc_survival.rs deleted file mode 100644 index ecacb5b5fb..0000000000 --- a/crates/perry-ext-lru-cache/tests/gc_survival.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! A cached heap value survives — and is rewritten across — a copying -//! minor collection. -//! -//! # Why this is its own test binary -//! -//! The assertion that matters here is that the collector *moved* the -//! cached string and the root scanner rewrote the cache's slot to the -//! forwarded address. Marking alone, or a non-moving collection, satisfies -//! "the value is still readable" without exercising one line of the -//! rewrite path — the #6942/#6946 failure mode, where a gate is green -//! because its subject never ran. -//! -//! Making that assertion meaningful requires a clean heap, and a shared -//! unit-test binary cannot provide one: -//! -//! - The collector conservatively pins any nursery object some stack word -//! happens to point at. Run after even one other test in the same -//! binary, this string gets pinned and the minor reports -//! `copied_objects=0` (measured; alone it reports `copied_objects=1`), -//! so the relocation assertion fails through no fault of the binding. -//! - Worse, a unit-test binary that has built an ordinary-prototype JS -//! object with `js_object_alloc` + `js_object_set_field_by_name` — the -//! obvious way to pass a real options object — makes the copying -//! collector walk a bogus slot address and SIGSEGV in -//! `gc::copying::scan_slot`, deterministically under -//! `--test-threads=1`. Null-prototype objects do not trip it, which is -//! what both test files now build, but the hazard is a runtime bug -//! rather than something this binding controls, so this file keeps its -//! distance from other tests regardless. -//! -//! A dedicated integration binary gets a pristine process: an empty -//! nursery, a shallow stack, and no JS objects. The relocation then -//! happens every run, so the assertion below is a real gate. -//! -//! Note the coverage trade-off: per-PR CI runs `cargo test --lib --bins`, -//! so this suite runs on PRs that touch it (via `e2e-scoped`), on nightly -//! and on tags — not on every PR. That is the price of an assertion that -//! can actually fail; a version of this test that lived in the unit -//! binary could only keep passing while covering nothing. - -use perry_ext_lru_cache::{js_lru_cache_get, js_lru_cache_new, js_lru_cache_set}; -use perry_ffi::{ - alloc_string, nanbox_string_bits, read_bytes, Handle, JsString, JsValue, StringHeader, -}; - -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - -extern "C" { - fn js_get_string_pointer_unified(value: f64) -> i64; -} - -/// `{ max: }` as a real JS object. -/// -/// Null-prototype for the same reason as the unit tests' helper: building -/// it with `js_object_alloc` + `js_object_set_field_by_name` destabilizes -/// the collector for the rest of the process, and this file exists -/// precisely to run a collection. Only own properties are read, so the -/// prototype does not matter to what is under test. -fn options_with_max(max: f64) -> f64 { - let obj = perry_ffi::alloc_null_proto_object(&[("max", JsValue::from_bits(max.to_bits()))]); - assert!( - obj.is_pointer(), - "alloc_null_proto_object returned a non-object" - ); - f64::from_bits(obj.bits()) -} - -fn string_value(text: &str) -> f64 { - let s = alloc_string(text); - assert!(!s.is_null(), "alloc_string returned null"); - f64::from_bits(nanbox_string_bits(s.as_raw())) -} - -/// Folded into the address so no stack word held across the collection -/// looks like a heap pointer. -const ADDRESS_TOKEN_XOR: u64 = 0xA5A5_A5A5_A5A5_A5A5; - -/// Allocate the value, hand it to the cache, and return **only** an -/// obfuscated token for the address it started at. -/// -/// `#[inline(never)]` is load-bearing, and so is the fact that neither the -/// raw address nor the NaN-boxed string ever becomes a local of the -/// caller. The collector conservatively pins any nursery object that some -/// live stack word points at — that is precisely the effect this test had -/// to be moved into its own binary to escape. A caller-frame local holding -/// the bare address would pin the very string whose relocation is being -/// asserted, and the assertion would then fail while proving nothing about -/// the cache-root rewrite. Keeping the pointer-shaped values inside a -/// frame that is popped before `gc_collect_minor()` runs, and carrying -/// only `address ^ ADDRESS_TOKEN_XOR` across it, removes that hazard. -#[inline(never)] -fn insert_value_and_take_address_token(handle: Handle) -> u64 { - let value = string_value("value-object-1234567890"); - let address = value.to_bits() & POINTER_MASK; - assert!( - perry_runtime::arena::pointer_in_nursery(address as usize), - "the value must start in the nursery or a minor cannot move it" - ); - js_lru_cache_set(handle, 1.0, value); - address ^ ADDRESS_TOKEN_XOR -} - -fn read_string_value(value: f64) -> Option { - let ptr = unsafe { js_get_string_pointer_unified(value) } as *mut StringHeader; - if ptr.is_null() { - return None; - } - let handle = unsafe { JsString::from_raw(ptr) }; - read_bytes(handle).map(|b| String::from_utf8_lossy(b).into_owned()) -} - -#[test] -fn cached_value_survives_and_is_rewritten_by_a_copying_minor() { - // Match the runtime's evacuation preconditions: write barriers active - // and a live shadow frame (mirrors perry-ext-events' scanner test). - perry_runtime::gc::js_gc_write_barriers_emitted(1); - let frame = perry_runtime::gc::js_shadow_frame_push(0); - // Both are process-global. A failing assertion below must not leave - // barriers on and a frame pushed, so tear down in `Drop` rather than - // on the happy path. - struct GcStateGuard(u64); - impl Drop for GcStateGuard { - fn drop(&mut self) { - perry_runtime::gc::js_shadow_frame_pop(self.0); - perry_runtime::gc::js_gc_write_barriers_emitted(0); - } - } - let _gc_state = GcStateGuard(frame); - - let h = js_lru_cache_new(options_with_max(10.0)); - // A >5-byte string forces the heap `StringHeader` repr (not inline - // SSO), so it is a real collectable allocation. Its ONLY root is the - // cache — nothing on the stack or in a global refers to it, which is - // why the address is carried across the collection as an obfuscated - // token rather than a live pointer-shaped word. - let before_token = insert_value_and_take_address_token(h); - - // Reclaims unrooted nursery allocations and evacuates rooted survivors. - let _ = perry_runtime::gc::gc_collect_minor(); - - let got = js_lru_cache_get(h, 1.0); - assert_ne!( - got.to_bits(), - TAG_UNDEFINED, - "cached value was collected — the root scanner did not keep it alive" - ); - - // Subject-live gate. If the address did not change, the minor did not - // copy anything and this test proved only that marking works — the - // forwarding-pointer rewrite, which is the half most likely to break, - // went untested. `PERRY_GEN_GC=0` routes `gc_collect_minor` to - // non-moving mark-sweep and will trip this deliberately. - let after = got.to_bits() & POINTER_MASK; - // Safe to materialize now: the collection is over, so a pointer-shaped - // stack word can no longer pin anything. - let before = before_token ^ ADDRESS_TOKEN_XOR; - assert_ne!( - before, after, - "minor GC did not relocate the cached value (0x{before:x}), so this run \ - never exercised the scanner's forwarding-pointer rewrite" - ); - assert_eq!( - read_string_value(got).as_deref(), - Some("value-object-1234567890"), - "cached value corrupted across GC — the slot was not rewritten to the \ - forwarded address" - ); - - perry_ffi::drop_handle(h); -} diff --git a/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs b/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs index 8a445b4a3f..8e8969ca61 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs @@ -164,7 +164,6 @@ pub(crate) fn register_native_fetch_and_streams( "big.js" => "Big", "decimal.js" => "Decimal", "bignumber.js" => "BigNumber", - "lru-cache" => "LRUCache", "commander" => "Command", _ => "", }; diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 145a34a2f6..e98f4a86ad 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -89,7 +89,6 @@ pub(crate) fn register_native_from_new_and_calls( } "WebSocket" | "WebSocketServer" => Some("ws".to_string()), "Redis" => Some("ioredis".to_string()), - "LRUCache" => Some("lru-cache".to_string()), "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), @@ -223,7 +222,6 @@ pub(crate) fn register_native_from_new_and_calls( "AsyncResource" => Some("async_hooks".to_string()), "WebSocket" | "WebSocketServer" => Some("ws".to_string()), "Redis" => Some("ioredis".to_string()), - "LRUCache" => Some("lru-cache".to_string()), "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), diff --git a/crates/perry-hir/src/destructuring/var_decl_sources.rs b/crates/perry-hir/src/destructuring/var_decl_sources.rs index 2bb91915ac..6571e755b6 100644 --- a/crates/perry-hir/src/destructuring/var_decl_sources.rs +++ b/crates/perry-hir/src/destructuring/var_decl_sources.rs @@ -83,47 +83,6 @@ pub(crate) fn require_is_shadowed_by_local(ctx: &LoweringContext) -> bool { || ctx.lookup_imported_func("require").is_some() } -/// The CJS-to-ESM wrapper's synthetic `require` is deliberately a real local -/// function, so the ordinary native-require fast paths must not steal calls -/// from it (see #8342). There is one narrower exception: a destructured -/// constructor supplied by a Perry native npm shim has no runtime namespace -/// value to destructure in the first place. The wrapper-generated helper -/// pair identifies that compiler-owned function without mistaking an ordinary -/// user `function require(...) { ... }` for the intrinsic. -fn require_is_perry_cjs_wrapper(ctx: &LoweringContext) -> bool { - ctx.lookup_func("require").is_some() - && ctx.lookup_func("__perry_cjs_require_error").is_some() - && ctx.lookup_func("__perry_cjs_require_is_builtin").is_some() -} - -/// Native npm-shim destructures that can be lowered exactly like named ESM -/// imports even inside Perry's CJS wrapper. Keep this an explicit surface: -/// broadening it to every native module would regress #8342's builtin-module -/// namespace semantics, while broadening it to arbitrary lru-cache exports -/// would pretend the partial shim implements API that it does not have. -fn cjs_wrapper_static_native_destructure( - ctx: &LoweringContext, - init: &ast::Expr, - obj_pat: &ast::ObjectPat, -) -> bool { - if !require_is_perry_cjs_wrapper(ctx) - || require_literal_specifier(init).as_deref() != Some("lru-cache") - { - return false; - } - - !obj_pat.props.is_empty() - && obj_pat.props.iter().all(|prop| match prop { - ast::ObjectPatProp::Assign(assign) => assign.key.sym.as_ref() == "LRUCache", - ast::ObjectPatProp::KeyValue(kv) => match &kv.key { - ast::PropName::Ident(key) => key.sym.as_ref() == "LRUCache", - ast::PropName::Str(key) => key.value.as_str() == Some("LRUCache"), - _ => false, - }, - ast::ObjectPatProp::Rest(_) => false, - }) -} - /// #5216: the canonical (`node:`-stripped) native module name for a require /// specifier `raw`, iff it resolves to a Perry-supported native/Node-builtin /// module; otherwise `None`. `node:`-prefixed specifiers must name a real Node @@ -265,10 +224,7 @@ pub(super) fn register_destructured_stream_ctors( // here — the native namespace isn't initialized in a CJS-wrapped module, // so the bindings would be undefined at runtime. Let the destructure run // off the runtime `require(...)` call result instead. - if require_is_shadowed_by_local(ctx) - && require_literal_specifier(init).is_some() - && !cjs_wrapper_static_native_destructure(ctx, init, obj_pat) - { + if require_is_shadowed_by_local(ctx) && require_literal_specifier(init).is_some() { return Vec::new(); } diff --git a/crates/perry-hir/src/js_transform/imports.rs b/crates/perry-hir/src/js_transform/imports.rs index 420e379028..a0ad44f030 100644 --- a/crates/perry-hir/src/js_transform/imports.rs +++ b/crates/perry-hir/src/js_transform/imports.rs @@ -675,7 +675,7 @@ pub fn transform_expr( // even if imported from JS modules - the codegen handles them directly const NATIVE_CODEGEN_CLASSES: &[&str] = &[ "Redis", "Command", "Pool", "WebSocket", "WebSocketServer", - "LRUCache", "Big", "Decimal", "BigNumber", "URLSearchParams", + "Big", "Decimal", "BigNumber", "URLSearchParams", ]; // Check if this is a JS class (but not one handled natively) if !NATIVE_CODEGEN_CLASSES.contains(&class_name.as_str()) && tracker.is_js_class(class_name) { diff --git a/crates/perry-hir/src/lower/expr_member/native_dispatch.rs b/crates/perry-hir/src/lower/expr_member/native_dispatch.rs index 02bc0e31d4..6da81b86bf 100644 --- a/crates/perry-hir/src/lower/expr_member/native_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_member/native_dispatch.rs @@ -130,18 +130,6 @@ pub(crate) fn is_native_dispatch_member(module: &str, class: &str, prop: &str) - // behind a compact native handle. Bare reads must invoke the FFI // getters; methods still travel through the call-expression path. "readline" => matches!(prop, "line" | "terminal"), - // lru-cache: `.size` is the one native DATA GETTER whose value comes - // from the FFI helper `js_lru_cache_size`, so a bare read must dispatch - // as a 0-arg `NativeMethodCall` through the `lru-cache` NativeModSig row - // (see `lower_call/native_table/node_misc.rs`). Without this arm the read - // fell to the inverted default (a plain `PropertyGet`) and the runtime's - // handle-property lookup — which has no `size` handler for the compact - // lru-cache handle — returned `undefined`. Every other member - // (`get`/`set`/`has`/`delete`/`clear`/`peek`) is a METHOD: a method CALL - // arrives via the call-expression path, and a bare method-VALUE read must - // stay a plain PropertyGet, never a 0-arg invoking dispatch. Mirrors the - // `blob` (`size`/`type`/…) and `__disposable__` (`disposed`) getter arms. - "lru-cache" => prop == "size", // #6364 — DisposableStack / AsyncDisposableStack: `disposed` is the // only native data getter (its value comes from the FFI helper // `js_disposable_stack_disposed`), so a bare read must dispatch as a @@ -624,21 +612,6 @@ mod tests { assert!(!is_native_dispatch_member("perry/ui", "Canvas", "value")); } - #[test] - fn lru_cache_dispatches_only_the_size_getter() { - // `cache.size` (bare property read) must dispatch as a 0-arg - // NativeMethodCall → `js_lru_cache_size`, not fall through to a plain - // PropertyGet (which read `undefined`). Regression for the handle-backed - // property-getter dispatch gap. - assert!(is_native_dispatch_member("lru-cache", "LRUCache", "size")); - // Methods stay method-VALUE reads (plain PropertyGet); their CALL form - // arrives via the call-expression path, so they must NOT dispatch here. - assert!(!is_native_dispatch_member("lru-cache", "LRUCache", "get")); - assert!(!is_native_dispatch_member("lru-cache", "LRUCache", "set")); - assert!(!is_native_dispatch_member("lru-cache", "LRUCache", "has")); - assert!(!is_native_dispatch_member("lru-cache", "LRUCache", "clear")); - } - #[test] fn ws_dispatches_client_and_server_data_getters_separately() { assert!(is_native_dispatch_member("ws", "WebSocket", "readyState")); diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 0a5f50f6b6..72104933b5 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -688,7 +688,6 @@ pub(crate) fn lower_module_decl( Some("ws".to_string()) } "Redis" => Some("ioredis".to_string()), - "LRUCache" => Some("lru-cache".to_string()), "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), @@ -756,7 +755,6 @@ pub(crate) fn lower_module_decl( Some("ws".to_string()) } "Redis" => Some("ioredis".to_string()), - "LRUCache" => Some("lru-cache".to_string()), "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 2f3660611a..7b6f42c592 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1738,39 +1738,14 @@ fn test_function_require_with_body_still_shadows_the_namespace_fast_path() { ); } -/// A compilePackages CJS module receives Perry's synthetic `require` function. -/// Native npm shims do not materialize a complete runtime namespace object, so -/// destructuring their constructor from that function must become the same -/// static native alias as an ESM named import. hosted-git-info uses this exact -/// shape for lru-cache at module initialization. -#[test] -fn test_cjs_wrapper_lru_cache_destructure_uses_static_constructor() { - let source = r#" - function __perry_cjs_require_error(kind: string, code: string, message: string): any { - return { kind, code, message }; - } - function __perry_cjs_require_is_builtin(specifier: string): boolean { - return false; - } - function require(specifier: string): any { - return undefined; - } - const { LRUCache } = require("lru-cache"); - const cache = new LRUCache({ max: 2 }); - cache.set("answer", 42); - "#; - let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); - let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); - let dump = format!("{hir:?}"); - assert!( - dump.contains("New { class_name: \"LRUCache\""), - "the CJS shim destructure must lower to the static LRUCache constructor: {dump}" - ); - assert!( - !dump.contains("name: \"LRUCache\", ty: Any") && !dump.contains("NewDynamic"), - "the unreified runtime namespace local must not survive: {dump}" - ); -} +// `test_cjs_wrapper_lru_cache_destructure_uses_static_constructor` removed +// here -- it asserted `const { LRUCache } = require("lru-cache"); new +// LRUCache(...)` lowers to the static native constructor +// (`cjs_wrapper_static_native_destructure` in `var_decl_sources.rs`), which +// no longer exists now that lru-cache's native binding is gone (#10685). +// The same CJS-destructure shape now goes through the ordinary +// resolvable-native-module path (any Node-builtin or well-known module, +// not lru-cache specifically), unaffected by this removal. /// #8470: the plain, non-reactive documented form /// `widget.animateOpacity(target, dur)` must lower to the perry/ui animation diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index ae640e409b..f085ab3dd3 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -203,11 +203,6 @@ pub fn lower_class_decl( } "AsyncResource" => Some(("async_hooks".to_string(), "AsyncResource".to_string())), "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), - // #10293: lru-cache's LRUCache is a compile-time lowering with - // no runtime value; recognising it here routes `extends` to the - // subclass-init path instead of the dynamic parent registration - // that throws "Class extends value is not a constructor". - "LRUCache" => Some(("lru-cache".to_string(), "LRUCache".to_string())), // Issue #562: user classes extending the Web Streams // base classes get a runtime-side subclass-init shim // wired through `Expr::SuperCall` (codegen). The diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index ab3b35cb8e..5ce310ff1e 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1443,7 +1443,6 @@ pub(crate) fn detect_native_instance_expr( "Big" => "big.js", "Decimal" => "decimal.js", "BigNumber" => "bignumber.js", - "LRUCache" => "lru-cache", "Command" => "commander", _ => return None, }; diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 2898e95751..239d59e69a 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -122,7 +122,6 @@ pub mod hot_diag; pub mod intl; pub mod iter_result; pub mod iterator_helpers; -pub mod lru_subclass; pub mod macos_bundle; pub mod map; pub mod math; diff --git a/crates/perry-runtime/src/lru_subclass.rs b/crates/perry-runtime/src/lru_subclass.rs deleted file mode 100644 index 7454627d7e..0000000000 --- a/crates/perry-runtime/src/lru_subclass.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! #10293: `class X extends LRUCache` — give the name a usable base. -//! -//! Perry lowers `new LRUCache(options)` as a compile-time pattern on the -//! identifier (`lower_call/builtin.rs`), so `LRUCache` has no runtime value. -//! Reading it yields `undefined`, which is fine for a direct `new` and fatal -//! for anything using the name as a value: `class X extends LRUCache` throws -//! `TypeError: Class extends value is not a constructor`. `path-scurry` does -//! exactly that (`class ResolveCache extends LRUCache`), so `glob@9` and -//! everything under it fails to initialize. -//! -//! This is the shape `node:stream` and `EventEmitter` already use — HIR records -//! the native parent and `super(...)` lowers to a subclass-init that installs -//! the native methods onto `this` instead of constructing a JS base. The -//! node:stream entry's own comment names this exact error as what that path -//! exists to avoid. -//! -//! The cache lives behind the `js_lru_cache_*` C ABI, implemented by whichever -//! of `perry-ext-lru-cache` (the well-known binding) or perry-stdlib's -//! `bundled-lru-cache` the link selects. Exactly one is ever present and both -//! export the same symbols, so a plain `extern "C"` block binds to the live -//! one. This object file is pulled in only when codegen emits the -//! subclass-init, which happens only for a program that extends `LRUCache`. -//! -//! The small value helpers are local on purpose: `dgram`, where the crate's -//! equivalents live, is feature-gated, and the auto-optimized runtime is built -//! with feature sets that exclude it. - -use crate::closure::ClosureHeader; -use crate::node_stream::dispatch::{ - cast0, cast1, cast2, install_methods_on_existing_object, StubFn, -}; -use crate::object::ObjectHeader; -use crate::value::JSValue; - -extern "C" { - fn js_lru_cache_new(options: f64) -> i64; - fn js_lru_cache_get(handle: i64, key: f64) -> f64; - fn js_lru_cache_set(handle: i64, key: f64, value: f64) -> i64; - fn js_lru_cache_has(handle: i64, key: f64) -> f64; - fn js_lru_cache_delete(handle: i64, key: f64) -> f64; - fn js_lru_cache_clear(handle: i64); - fn js_lru_cache_peek(handle: i64, key: f64) -> f64; -} - -/// Hidden slot on the subclass instance holding the native cache id. Stored as -/// a plain number, not a NaN-boxed pointer: handles are small monotonic ids, -/// exactly representable in an `f64`, and keeping it a number keeps the GC's -/// pointer scan away from a value that is not an address. -const HANDLE_KEY: &[u8] = b"__perry_lru_handle"; - -fn undefined_value() -> f64 { - f64::from_bits(JSValue::undefined().bits()) -} - -fn bool_value(v: bool) -> f64 { - f64::from_bits(JSValue::bool(v).bits()) -} - -/// Normalise a predicate result to a JS boolean. -/// -/// TWO `js_lru_cache_has`/`js_lru_cache_delete` symbols exist: perry-stdlib's -/// legacy pair answers a NUMBER (`1.0`/`0.0`) and perry-ext-lru-cache's answers -/// a NaN-boxed boolean. Which one these thunks bind to depends on the archive -/// set, while the DIRECT `cache.has(k)` lowering always reaches the ext one — -/// so without this a subclass diverged from its own base in the same program: -/// `base.has(k)` was `true` and `sub.has(k)` was `1`. Pass a real boolean -/// through untouched and convert a numeric answer. -fn as_bool_value(v: f64) -> f64 { - let bits = v.to_bits(); - if bits == JSValue::bool(true).bits() || bits == JSValue::bool(false).bits() { - return v; - } - bool_value(!v.is_nan() && v != 0.0) -} - -fn hidden_key(bytes: &[u8]) -> *mut crate::StringHeader { - crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) -} - -fn object_ptr(value: f64) -> Option<*mut ObjectHeader> { - let bits = value.to_bits(); - let jsval = JSValue::from_bits(bits); - if !jsval.is_pointer() { - return None; - } - let raw = (bits & crate::value::POINTER_MASK) as usize; - if raw < 0x10000 { - return None; - } - unsafe { - let header = crate::value::addr_class::try_read_tracked_gc_header(raw)?; - if (*header.as_ptr()).obj_type != crate::gc::GC_TYPE_OBJECT { - return None; - } - } - Some(raw as *mut ObjectHeader) -} - -/// The receiver `install_methods_on_existing_object` stores in capture 0. -fn this_value(closure: *const ClosureHeader) -> f64 { - if !closure.is_null() { - let bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64; - if bits != 0 { - return f64::from_bits(bits); - } - } - crate::object::js_implicit_this_get() -} - -fn handle_of(this: f64) -> i64 { - let Some(obj) = object_ptr(this) else { - return 0; - }; - let value = crate::object::js_object_get_field_by_name_f64( - obj as *const ObjectHeader, - hidden_key(HANDLE_KEY), - ); - if value.is_finite() && value > 0.0 { - value as i64 - } else { - 0 - } -} - -extern "C" fn lru_m_get(closure: *const ClosureHeader, key: f64) -> f64 { - let handle = handle_of(this_value(closure)); - if handle == 0 { - return undefined_value(); - } - unsafe { js_lru_cache_get(handle, key) } -} - -extern "C" fn lru_m_set(closure: *const ClosureHeader, key: f64, value: f64) -> f64 { - let this = this_value(closure); - let handle = handle_of(this); - if handle != 0 { - unsafe { js_lru_cache_set(handle, key, value) }; - } - // npm's `set` returns the cache, so `.set(a,1).set(b,2)` chains. - this -} - -extern "C" fn lru_m_has(closure: *const ClosureHeader, key: f64) -> f64 { - let handle = handle_of(this_value(closure)); - if handle == 0 { - return bool_value(false); - } - as_bool_value(unsafe { js_lru_cache_has(handle, key) }) -} - -extern "C" fn lru_m_delete(closure: *const ClosureHeader, key: f64) -> f64 { - let handle = handle_of(this_value(closure)); - if handle == 0 { - return bool_value(false); - } - as_bool_value(unsafe { js_lru_cache_delete(handle, key) }) -} - -extern "C" fn lru_m_peek(closure: *const ClosureHeader, key: f64) -> f64 { - let handle = handle_of(this_value(closure)); - if handle == 0 { - return undefined_value(); - } - unsafe { js_lru_cache_peek(handle, key) } -} - -extern "C" fn lru_m_clear(closure: *const ClosureHeader) -> f64 { - let handle = handle_of(this_value(closure)); - if handle != 0 { - unsafe { js_lru_cache_clear(handle) }; - } - undefined_value() -} - -/// Only the surface the binding implements is installed. `forEach`, `dispose`, -/// `fetch` and the iterator surface are deliberately absent so a use throws -/// `is not a function` at the call site — a better failure than the binding's -/// documented silent no-ops, which is why it is marked `partial` in -/// `well_known_bindings.toml`. -fn lru_methods() -> [(&'static str, StubFn); 6] { - [ - ("get", cast1(lru_m_get)), - ("set", cast2(lru_m_set)), - ("has", cast1(lru_m_has)), - ("delete", cast1(lru_m_delete)), - ("peek", cast1(lru_m_peek)), - ("clear", cast0(lru_m_clear)), - ] -} - -/// `super(options)` for a source-compiled `class X extends LRUCache`. -#[no_mangle] -pub extern "C" fn js_lru_cache_subclass_init(this: f64, opts: f64) -> f64 { - if object_ptr(this).is_none() { - return this; - } - let handle = unsafe { js_lru_cache_new(opts) }; - if handle == 0 { - return this; - } - // Interning the key allocates and can move the receiver, so re-read the - // object pointer after each allocating step rather than caching it. - if let Some(obj) = object_ptr(this) { - crate::object::js_object_set_field_by_name(obj, hidden_key(HANDLE_KEY), handle as f64); - } - let Some(obj) = object_ptr(this) else { - return this; - }; - let methods = lru_methods(); - install_methods_on_existing_object(obj, this, &methods, &[]); - this -} - -/// Link-time default for the `js_lru_cache_*` ABI on MSVC, and nowhere else. -/// -/// The extern block above is satisfied by whichever cache provider the PROGRAM -/// links. A Rust binary that links perry-runtime without one still carries the -/// references, and two of them are built in CI on every Windows leg: the -/// `perry` compiler itself (`cargo build -p perry …`) and this crate's own -/// `--lib` test harness (`cargo test --lib -p perry-runtime`). Neither wants an -/// LRU cache; neither links a provider. -/// -/// On every other target that is harmless, because the linker dead-strips -/// before it reports: `ld64 -dead_strip` / `ld --gc-sections` drop the thunks -/// above out of a binary that never calls them, and the references go with -/// them. Verified on macOS — the linked `target/perry-dev/perry` contains no -/// `js_lru_cache_subclass_init` symbol at all, and the build succeeds while the -/// rlib it links still shows all seven as `U`. `link.exe` resolves symbols -/// BEFORE `/OPT:REF`, so the same inputs are 7 × LNK2019 there. -/// -/// A Cargo feature cannot express "this link has no provider". The Windows job -/// builds `-p perry -p perry-runtime-static -p perry-stdlib-static` in ONE -/// invocation, so perry-stdlib's `perry-runtime/stdlib` feature is unified onto -/// the copy of perry-runtime that the `perry` binary links — even though -/// perry-stdlib is not in that binary's link. Anything gated on `stdlib` -/// (`crate::stdlib_stubs`, an `external-*-symbols` flag) is therefore compiled -/// out in exactly the configuration that fails. -/// -/// `/ALTERNATENAME` is MSVC's spelling of a weak default: link.exe substitutes -/// the alternate only for a symbol still undefined after every input has been -/// read. A program that does link `perry_stdlib.lib` or the ext archive binds -/// the real implementation and never reaches these — so this cannot shadow a -/// provider the way an unconditional definition would. They live in this module -/// so that they share a codegen unit with the thunks whose references they -/// answer. -/// -/// `js_lru_cache_new` answering 0 is already the "no cache" path: the -/// subclass-init returns `this` without installing any method, so a `.get()` on -/// it throws `is not a function` at the call site — the same failure this -/// module deliberately chooses for `forEach`/`dispose`/`fetch`. -#[cfg(all(windows, target_env = "msvc"))] -mod msvc_absent_provider { - use crate::stub_diag::perry_stub_warn; - - const REASON: &str = - "no lru-cache provider (perry-ext-lru-cache / perry-stdlib bundled-lru-cache) \ - is linked into this binary"; - - /// Emit one `/ALTERNATENAME:=` linker directive. - macro_rules! alternatename { - ($stat:ident, $bytes:literal) => { - #[used] - #[link_section = ".drectve"] - static $stat: [u8; $bytes.len()] = *$bytes; - }; - } - - alternatename!( - D_NEW, - b" /ALTERNATENAME:js_lru_cache_new=perry_lru_cache_absent_new" - ); - alternatename!( - D_GET, - b" /ALTERNATENAME:js_lru_cache_get=perry_lru_cache_absent_get" - ); - alternatename!( - D_SET, - b" /ALTERNATENAME:js_lru_cache_set=perry_lru_cache_absent_set" - ); - alternatename!( - D_HAS, - b" /ALTERNATENAME:js_lru_cache_has=perry_lru_cache_absent_has" - ); - alternatename!( - D_DELETE, - b" /ALTERNATENAME:js_lru_cache_delete=perry_lru_cache_absent_delete" - ); - alternatename!( - D_CLEAR, - b" /ALTERNATENAME:js_lru_cache_clear=perry_lru_cache_absent_clear" - ); - alternatename!( - D_PEEK, - b" /ALTERNATENAME:js_lru_cache_peek=perry_lru_cache_absent_peek" - ); - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_new(_options: f64) -> i64 { - perry_stub_warn("js_lru_cache_new", REASON, None); - 0 - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_get(_handle: i64, _key: f64) -> f64 { - perry_stub_warn("js_lru_cache_get", REASON, None); - super::undefined_value() - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_set(handle: i64, _key: f64, _value: f64) -> i64 { - perry_stub_warn("js_lru_cache_set", REASON, None); - handle - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_has(_handle: i64, _key: f64) -> f64 { - perry_stub_warn("js_lru_cache_has", REASON, None); - super::bool_value(false) - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_delete(_handle: i64, _key: f64) -> f64 { - perry_stub_warn("js_lru_cache_delete", REASON, None); - super::bool_value(false) - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_clear(_handle: i64) { - perry_stub_warn("js_lru_cache_clear", REASON, None); - } - - #[no_mangle] - pub extern "C" fn perry_lru_cache_absent_peek(_handle: i64, _key: f64) -> f64 { - perry_stub_warn("js_lru_cache_peek", REASON, None); - super::undefined_value() - } -} diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 57d1141486..6126378fa8 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -23,7 +23,7 @@ default = ["full"] # must stay out of this list: release archives enable `full` without linking # their per-program provider archives, and adding an external HTTP pump here # made HTTP-free Linux UI links require libperry_ext_http.a (#5983, #8587). -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-dotenv", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] +full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-dotenv", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] # Minimal core - just what's needed for basic programs core = [] @@ -36,12 +36,6 @@ core = [] # don't import dotenv pay nothing for this either way. bundled-dotenv = [] -# In-tree implementation of `lru-cache`. Default-on through -# `default = ["full"]`; flipped to perry-ext-lru-cache by the -# well-known table (#466 Phase 4). Pulls the `lru` crate dep so -# both halves of the gate move in lockstep. -bundled-lru-cache = ["dep:lru"] - # Pure-Rust wrapper, no extra deps. Default-on through `full`. bundled-exponential-backoff = [] @@ -445,11 +439,6 @@ governor = { version = "0.10", optional = true } uuid = { version = "1.23", features = ["v4", "v1", "v3", "v5", "v7"] } nanoid = { version = "0.5", optional = true } -# LRU Cache — optional from v0.5.539 so the well-known flip can -# strip the perry-stdlib copy when `import 'lru-cache'` resolves -# to perry-ext-lru-cache instead. -lru = { version = "0.18", optional = true } - # Decimal math (Big.js / Decimal.js) — optional from v0.5.547 so # the well-known flip can strip perry-stdlib's copy when # `import 'decimal.js'` resolves to perry-ext-decimal instead. diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 8812fb2a15..9b01c7a5de 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -67,10 +67,6 @@ pub mod lodash; // `_js_moment_*` symbols at link. #[cfg(feature = "bundled-moment")] pub mod moment; -// lru_cache is feature-gated as of v0.5.539 so the well-known -// flip can route `import 'lru-cache'` to perry-ext-lru-cache. -#[cfg(feature = "bundled-lru-cache")] -pub mod lru_cache; pub mod readline; // string_decoder — issue #848. Native StringDecoder with real `write` / // `end` methods + `lastNeed` / `lastTotal` / `lastChar` getters wired @@ -108,8 +104,6 @@ pub use events::*; #[cfg(feature = "bundled-exponential-backoff")] pub use exponential_backoff::*; pub use lodash::*; -#[cfg(feature = "bundled-lru-cache")] -pub use lru_cache::*; #[cfg(feature = "bundled-moment")] pub use moment::*; pub use querystring::*; diff --git a/crates/perry-stdlib/src/lru_cache.rs b/crates/perry-stdlib/src/lru_cache.rs deleted file mode 100644 index 767d079451..0000000000 --- a/crates/perry-stdlib/src/lru_cache.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! LRUCache implementation -//! -//! Native implementation of the lru-cache npm package. -//! Provides a Least Recently Used cache with configurable max size. - -use lru::LruCache; -use std::num::NonZeroUsize; - -use crate::common::{get_handle_mut, register_handle, Handle}; - -/// LRUCache handle storing the cache -pub struct LruCacheHandle { - cache: LruCache, -} - -impl LruCacheHandle { - pub fn new(max_size: usize) -> Self { - let size = NonZeroUsize::new(max_size.max(1)).unwrap(); - LruCacheHandle { - cache: LruCache::new(size), - } - } -} - -/// Create a new LRUCache with the specified max size -/// new LRUCache({ max: number }) -#[no_mangle] -pub extern "C" fn js_lru_cache_new(max_size: f64) -> Handle { - let max = if max_size.is_nan() || max_size < 1.0 { - 100 // default - } else { - max_size as usize - }; - register_handle(LruCacheHandle::new(max)) -} - -/// LRUCache.get(key) -/// Returns the value for the key, or NaN if not found -#[no_mangle] -pub extern "C" fn js_lru_cache_get(handle: Handle, key: f64) -> f64 { - let key_bits = key.to_bits() as i64; - - if let Some(cache) = get_handle_mut::(handle) { - if let Some(&value) = cache.cache.get(&key_bits) { - return value; - } - } - - f64::NAN // undefined -} - -/// LRUCache.set(key, value) -/// Sets the value for the key -#[no_mangle] -pub extern "C" fn js_lru_cache_set(handle: Handle, key: f64, value: f64) -> Handle { - let key_bits = key.to_bits() as i64; - - if let Some(cache) = get_handle_mut::(handle) { - cache.cache.put(key_bits, value); - } - - handle // return self for chaining -} - -/// LRUCache.has(key) -/// Returns 1.0 if the key exists, 0.0 otherwise -#[no_mangle] -pub extern "C" fn js_lru_cache_has(handle: Handle, key: f64) -> f64 { - let key_bits = key.to_bits() as i64; - - if let Some(cache) = get_handle_mut::(handle) { - return if cache.cache.contains(&key_bits) { - 1.0 - } else { - 0.0 - }; - } - - 0.0 -} - -/// LRUCache.delete(key) -/// Deletes the key from the cache, returns 1.0 if it existed, 0.0 otherwise -#[no_mangle] -pub extern "C" fn js_lru_cache_delete(handle: Handle, key: f64) -> f64 { - let key_bits = key.to_bits() as i64; - - if let Some(cache) = get_handle_mut::(handle) { - return if cache.cache.pop(&key_bits).is_some() { - 1.0 - } else { - 0.0 - }; - } - - 0.0 -} - -/// LRUCache.clear() -/// Clears all entries from the cache -#[no_mangle] -pub extern "C" fn js_lru_cache_clear(handle: Handle) { - if let Some(cache) = get_handle_mut::(handle) { - cache.cache.clear(); - } -} - -/// LRUCache.size -/// Returns the current number of entries -#[no_mangle] -pub extern "C" fn js_lru_cache_size(handle: Handle) -> f64 { - if let Some(cache) = get_handle_mut::(handle) { - return cache.cache.len() as f64; - } - - 0.0 -} - -/// LRUCache.peek(key) -/// Returns the value without updating recency -#[no_mangle] -pub extern "C" fn js_lru_cache_peek(handle: Handle, key: f64) -> f64 { - let key_bits = key.to_bits() as i64; - - if let Some(cache) = get_handle_mut::(handle) { - if let Some(&value) = cache.cache.peek(&key_bits) { - return value; - } - } - - f64::NAN // undefined -} diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 07eafc3afe..cbfaaab35e 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -1055,38 +1055,6 @@ pub extern "C" fn js_lodash_upper_first() -> i64 { 0 } #[no_mangle] -pub extern "C" fn js_lru_cache_clear() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_delete() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_get() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_has() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_new() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_peek() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_set() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_lru_cache_size() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_moment_add() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index a62db7c938..0716bcb4e6 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -339,13 +339,7 @@ pub(super) fn detect_optional_feature_usage( if hir_debug.contains("module: \"bun\"") || hir_debug.contains("NativeModuleRef(\"bun\")") { ctx.native_module_imports.insert("bun".to_string()); } - for native_module in [ - "lru-cache", - "big.js", - "decimal.js", - "bignumber.js", - "commander", - ] { + for native_module in ["big.js", "decimal.js", "bignumber.js", "commander"] { if hir_debug.contains(&format!("module: \"{native_module}\"")) { ctx.needs_stdlib = true; ctx.native_module_imports.insert(native_module.to_string()); diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index da67f6e48d..bf71f8dc3e 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -911,8 +911,8 @@ pub(super) fn apply_pkg_and_toml_config( continue; } // #5137: don't let the `"*"` / `@scope/*` wildcard sweep in packages - // Perry ships a native stdlib shim for (commander, dayjs, lru-cache, - // …). The shim is the supported, optimized path; compiling the real + // Perry ships a native stdlib shim for (commander, dayjs, …). The + // shim is the supported, optimized path; compiling the real // npm source instead routes the import away from the native module // table, so hardcoded lowerings like `new Command()` → // `js_commander_new` no longer fire and the binding resolves to diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index f23031ba8a..6b74c9ed7d 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -19,7 +19,7 @@ use std::sync::OnceLock; /// (see `is_native_module` + the resolver short-circuit). That is only /// safe-by-construction when the wrapper is a genuine drop-in. A wrapper /// that ports a *subset* of the surface (undici's dispatcher-only client, -/// node-forge's PKI-only slice, lru-cache's numeric-only store) can +/// node-forge's PKI-only slice, qs's array-limit-only parser) can /// silently diverge from the real package, so it is marked `Partial` and /// perry surfaces a diagnostic (and, under /// `PERRY_REQUIRE_FAITHFUL_BINDINGS=1`, refuses to auto-prefer it). @@ -396,41 +396,6 @@ mod tests { assert!(lookup_well_known("definitely-not-a-real-package").is_none()); } - /// `lru-cache` must stay `partial`, and for a reason that outlives the - /// comment in the toml. - /// - /// #7136 made the binding genuinely faithful for the surface it *does* - /// implement — JS-value keys and values, content-compared string keys, GC - /// rooting of cached values, `ttl`, `updateAgeOnGet` — which invites the - /// conclusion that the marker should be flipped. It should not. `full` - /// means an exhaustively audited drop-in for the package's ENTIRE public - /// API, and it licenses auto-preferring this wrapper over a user's - /// installed `node_modules/lru-cache`. Measured against npm - /// `lru-cache@11.5.2`, two of the wrapper's gaps fail SILENTLY rather - /// than loudly: `cache.forEach(...)` visits nothing where npm visits - /// every entry, and a `dispose` callback is never invoked where npm - /// invokes it on eviction. `maxSize`/`sizeCalculation`, `fetch`, - /// `allowStale`, per-call option objects, and the rest of the iterator - /// surface are absent too. - /// - /// Flipping this to `full` would therefore let Perry silently swap a - /// wrong implementation in for a correct installed one. Promote it only - /// once those surfaces exist and are conformance-tested — and update this - /// test with the evidence when you do. - #[test] - fn lru_cache_stays_partial_until_the_silent_gaps_are_closed() { - let binding = - lookup_well_known("lru-cache").expect("lru-cache must be a well-known binding"); - assert_eq!(binding.krate, "perry-ext-lru-cache"); - assert_eq!( - binding.compat, - BindingCompat::Partial, - "lru-cache's wrapper silently no-ops forEach/dispose — it cannot be \ - auto-preferred over an installed copy" - ); - assert!(!binding.is_faithful()); - } - #[test] fn compat_defaults_to_partial_when_absent() { let raw = r#" @@ -473,7 +438,7 @@ mod tests { /// stay `Partial` and are never silently treated as complete drop-ins. #[test] fn shipped_subset_bindings_are_partial() { - for name in ["undici", "node-forge", "lru-cache", "qs"] { + for name in ["undici", "node-forge", "qs"] { let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); assert_eq!( b.compat, diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index ae8e640b0c..e58d55e2b9 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -173,9 +173,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { &["container"] } - // lru-cache: feature-gated v0.5.539; well-known flip - // routes to perry-ext-lru-cache. - "lru-cache" => &["bundled-lru-cache"], // exponential-backoff: feature-gated v0.5.542 alongside // the perry-ffi closure-invocation surface that powers // its `backOff(fn)` retry loop. diff --git a/crates/perry/tests/issue_10293_lru_cache_subclass.rs b/crates/perry/tests/issue_10293_lru_cache_subclass.rs deleted file mode 100644 index 92bf01de89..0000000000 --- a/crates/perry/tests/issue_10293_lru_cache_subclass.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Regression test for #10293: `class X extends LRUCache` must construct. -//! -//! `lru-cache` is one of the packages perry serves from a bundled native -//! binding rather than compiling from npm source. The binding is a -//! COMPILE-TIME lowering with no runtime class value, so heritage that names -//! it reached the dynamic parent-registration path and threw `Class extends -//! value is not a constructor` before any user code ran. -//! -//! Recognising `LRUCache` as a native parent routes it to the same -//! subclass-init pattern `EventEmitter` and the `node:stream` bases use: the -//! runtime builds a real cache, stashes the handle on a hidden slot of `this`, -//! and installs the method surface directly onto the instance. -//! -//! The surface is deliberately PARTIAL — the six methods the binding -//! implements faithfully. `forEach`/`dispose`/`fetch`/the iterator protocol are -//! NOT installed, so a subclass that reaches for them throws at the call site -//! instead of inheriting the binding's documented silent no-ops. That is NOT -//! bun parity — bun runs the real package and `forEach` works there — it is a -//! deliberate choice to fail loudly rather than answer wrongly, and the second -//! test pins it so it stays a decision rather than an accident. -//! -//! Both `has` and `delete` normalise their result to a real boolean. Two -//! `js_lru_cache_has`/`js_lru_cache_delete` symbols exist — perry-stdlib's -//! legacy pair answers a NUMBER and perry-ext-lru-cache's answers a NaN-boxed -//! boolean — and the DIRECT `cache.has(k)` lowering reaches the ext one, so -//! without normalising, a subclass disagreed with its own base in one program: -//! `base.has(k)` was `true` while `sub.has(k)` was `1`. - -use std::path::PathBuf; -use std::process::Command; - -fn perry_bin() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_perry")) -} - -fn runtime_dir() -> PathBuf { - std::env::var_os("PERRY_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| { - perry_bin() - .parent() - .expect("compiler directory") - .to_path_buf() - }) -} - -const SUBCLASS: &str = r#" -import { LRUCache } from "lru-cache" - -class Store extends LRUCache { - hits = 0 - bump(k: string) { - this.hits++ - return this.get(k) - } -} - -const s = new Store({ max: 3 }) -s.set("a", 1) -s.set("b", 2) -console.log("get:", s.get("a"), s.get("b")) -console.log("has:", s.has("a"), s.has("zzz")) -console.log("peek:", s.peek("b")) -console.log("own field:", s.bump("a"), s.hits) -s.delete("a") -console.log("after delete:", s.has("a"), s.get("b")) -s.clear() -console.log("after clear:", s.has("b")) -"#; - -const EXPECTED: &str = "\ -get: 1 2 -has: true false -peek: 2 -own field: 1 1 -after delete: false 2 -after clear: false -"; - -/// The surface perry does NOT install must throw, not silently answer. -const PARTIAL_SURFACE: &str = r#" -import { LRUCache } from "lru-cache" -class Store extends LRUCache {} -const s: any = new Store({ max: 2 }) -s.set("a", 1) -try { - s.forEach(() => {}) - console.log("forEach: did not throw") -} catch (e: any) { - console.log("forEach: threw") -} -"#; - -fn compile_and_run(source: &str) -> String { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - std::fs::write(root.join("main.ts"), source).unwrap(); - - let output = root.join("main_bin"); - let out = Command::new(perry_bin()) - .current_dir(root) - .arg("compile") - .arg(root.join("main.ts")) - .arg("-o") - .arg(&output) - .arg("--no-cache") - .env("PERRY_NO_AUTO_OPTIMIZE", "1") - .env("PERRY_RUNTIME_DIR", runtime_dir()) - .output() - .expect("run perry compile"); - assert!( - out.status.success(), - "`class X extends LRUCache` must compile; stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - - let run = Command::new(&output).output().expect("run compiled binary"); - assert!( - run.status.success(), - "compiled binary must run; stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run.stdout), - String::from_utf8_lossy(&run.stderr) - ); - String::from_utf8(run.stdout).expect("UTF-8 stdout") -} - -#[test] -fn lru_cache_subclass_constructs_and_caches() { - let stdout = compile_and_run(SUBCLASS); - assert!( - !stdout.contains("not a constructor"), - "heritage naming a compile-time binding must not reach dynamic parent \ - registration; stdout:\n{stdout}" - ); - assert_eq!( - stdout, EXPECTED, - "a subclass instance must carry the cache surface AND its own fields" - ); -} - -/// NOT a bun-parity assertion: bun runs the real package, where `forEach` -/// works. This pins perry's deliberate boundary — the binding implements six -/// methods faithfully and a subclass reaching past them must throw at the call -/// site rather than inherit a silent no-op. -#[test] -fn uninstalled_surface_throws_rather_than_lying() { - assert_eq!( - compile_and_run(PARTIAL_SURFACE), - "forEach: threw\n", - "only the faithfully-implemented methods are installed; the rest must \ - fail loudly instead of inheriting the binding's silent no-ops" - ); -} diff --git a/crates/perry/tests/issue_10439_native_binding_import_provenance.rs b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs index 6bd71c58d8..1e6a85fc29 100644 --- a/crates/perry/tests/issue_10439_native_binding_import_provenance.rs +++ b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs @@ -291,26 +291,12 @@ console.log(program.constructor.name); ); } -/// Same legitimate-case guard for lru-cache: without `compilePackages`, -/// `new LRUCache(...).set(...).get(...)` must still reach the native -/// `js_lru_cache_*` handle path (which happens to compute the right answer -/// for this simple, non-evicting case) rather than falling through to a -/// nonexistent real source. -#[test] -fn lru_cache_default_name_still_uses_native_binding_without_compile_packages() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - std::fs::write( - root.join("main.ts"), - r#" -import { LRUCache } from "lru-cache"; -console.log(new LRUCache({ max: 3 }).set("a", 1).get("a")); -"#, - ) - .expect("write main.ts"); - assert_eq!( - compile_and_run(root, "main.ts"), - "1\n", - "the native-binding path (no compilePackages) must be byte-for-byte unchanged" - ); -} +// `lru_cache_default_name_still_uses_native_binding_without_compile_packages` +// removed here -- it guarded the "legitimate native case" (no +// compilePackages, native binding still handles `new LRUCache(...)`), which +// no longer exists: #10685 deletes the native lru-cache binding entirely. +// Without compilePackages AND without a real installed package, this shape +// now falls through to the generic unresolved-import handling instead +// (compiles, but throws at the first call that isn't actually there) -- +// correctly reflecting that there is no source of truth for `LRUCache` +// left to reach, exactly as removing the binding intends. diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 0b537d3ae1..f27f0a8b88 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -116,51 +116,6 @@ repo = "https://github.com/ranisalt/node-argon2" ref = "786de7152f95881b0683aea1d2ca60ed0d6d9e2f" ported-at = "0.45.1" date = "2026-07-30" -[bindings.lru-cache] -crate = "perry-ext-lru-cache" -lib = "perry_ext_lru_cache" -tracking = "#466" -# PARTIAL (explicit) — but NOT for the reason this comment used to give. -# -# The old wording ("the wrapper's store is numeric-value-oriented") went -# stale when #7136 landed: the store holds real JS keys and values, string -# keys hash and compare by CONTENT, cached heap values are registered GC -# roots (marked and rewritten across copying evacuation), and `ttl` / -# `updateAgeOnGet` follow npm's clock semantics. A 20-assertion probe of -# real-world usage — string keys, object values, object identity, mutation -# through the cached reference, overwrite, size, delete, clear, miss, -# has, eviction at max, get-promotes / peek-does-not, numeric values, ttl, -# and object survival across forced heap churn — prints IDENTICAL output -# under npm `lru-cache@11.5.2` on Node 26.5.0 and under this binding. -# -# It stays `partial` because `full` is a stronger claim than "faithful for -# what it implements": per docs/src/native-libraries/zero-config-and- -# faithfulness.md it means an exhaustively audited drop-in for the pinned -# package's ENTIRE public API, and it licenses auto-preferring this wrapper -# over a user's installed `node_modules/lru-cache`. This wrapper exports 8 -# entry points and does not cover `maxSize`/`sizeCalculation`, -# `dispose`/`disposeAfter`, `fetch`/`forceFetch`, `allowStale`, per-call -# `set`/`get` option objects, or the iterator / `forEach` / `entries` / -# `keys` / `values` surface (the ABI only carries `(key, value)`). -# -# Two of those gaps fail SILENTLY rather than loudly, which is precisely -# what the marker exists to prevent: measured against the same probe, -# `cache.forEach(...)` visits nothing (npm visits every entry) and a -# `dispose` callback is never invoked (npm invokes it on eviction). Both -# print a wrong answer instead of throwing, so a `full` marker here would -# let Perry silently substitute this wrapper for a correct installed copy. -# Promote to `full` only once those surfaces exist and are conformance- -# tested; until then `PERRY_REQUIRE_FAITHFUL_BINDINGS=1` refusing this -# binding is the correct outcome, not a false positive. -compat = "partial" - -[bindings.lru-cache.upstream] -version = "11.5.2" -sha256 = "e46c8eaafc64f168603aebd39cfd0e987bec39a93ade280653e2331fd2516a22" -repo = "https://github.com/isaacs/node-lru-cache" -ref = "16b3a916662ab449d496b7b4b4f04132565d1d28" -ported-at = "11.5.2" -date = "2026-07-30" [bindings.better-sqlite3] crate = "perry-ext-better-sqlite3" lib = "perry_ext_better_sqlite3" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 6497a7386b..d92da049fb 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: 2067 entries across 132 modules +// Coverage: 2066 entries across 131 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2117,11 +2117,6 @@ declare module "lodash" { export function uniq(p0: any): any; } -declare module "lru-cache" { - /** stdlib */ - export default function (p0: any): any; -} - declare module "module" { /** stdlib */ export class Module { [key: string]: any; } diff --git a/docs/examples/stdlib/other/snippets.ts b/docs/examples/stdlib/other/snippets.ts index f6d39f6873..673fb2bcd9 100644 --- a/docs/examples/stdlib/other/snippets.ts +++ b/docs/examples/stdlib/other/snippets.ts @@ -14,7 +14,7 @@ // process, neither hermetic in CI. Compile + link is the contract here. // // Only packages with wired NativeModSig dispatch (nodemailer, commander, -// decimal.js, lru-cache, child_process) are anchored. sharp / cheerio / +// decimal.js, child_process) are anchored. sharp / cheerio / // zlib / cron / worker_threads have runtime declarations but no dispatch // path from user-visible imports yet, so the markdown page keeps those // snippets as `,no-test` with a clear status note above each fence. @@ -74,20 +74,6 @@ function decimalExample(): void { } // ANCHOR_END: decimal -// ANCHOR: lru-cache -import { LRUCache } from "lru-cache" - -function lruCacheExample(): void { - const cache = new LRUCache({ max: 100 }) // max 100 entries - - cache.set("key", "value") - console.log(cache.get("key")) // "value" - console.log(cache.has("key")) // true - cache.delete("key") - cache.clear() -} -// ANCHOR_END: lru-cache - // ANCHOR: child-process // `spawnBackground` / `getProcessStatus` / `killProcess` are Perry EXTENSIONS — // Node's `child_process` has no such named exports, so importing them by name diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 458ee6d20a..7109b37ea7 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: 3009 entries across 134 modules. +Total: 3001 entries across 133 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3009 entries across 134 modules. - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) - [`lodash`](#lodash) -- [`lru-cache`](#lru-cache) - [`module`](#module) - [`moment`](#moment) - [`mongodb`](#mongodb) @@ -2026,19 +2025,6 @@ Total: 3009 entries across 134 modules. - `times` — module - `uniq` — module -## `lru-cache` - -### Methods - -- `clear` — instance -- `default` — module -- `delete` — instance -- `get` — instance -- `has` — instance -- `peek` — instance -- `set` — instance -- `size` — instance - ## `module` ### Classes diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index bc06dda3f5..a4bab7e00b 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -101,7 +101,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-lru-cache` | `lru-cache` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-moment` | `moment` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-mongodb` | `mongodb` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-mysql2` | `mysql2`
`mysql2/promise` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index 9bed2ebc46..3ac295a2ee 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -298,22 +298,6 @@ such as `file:///$bunfs/root/worker.js` mapped through `--bunfs-root`. {{#include ../../examples/stdlib/other/snippets.ts:commander}} ``` -## lru-cache - -The wired constructor takes the npm v7+ options-object shape -(`new LRUCache({ max: 100 })`) and validates it the way npm does, throwing -the same errors rather than clamping: `max` must be a positive integer no -larger than the JS array-length limit, `ttl` must be a positive integer, -and at least one of `max` or `ttl` is required — so `new LRUCache()` and -the older positional form `new LRUCache(100)` both throw a `TypeError`, -exactly as they do on npm. `ttl`, `updateAgeOnGet` and `peek` are honored; -`maxSize`/`sizeCalculation`, `dispose`, `fetch`, `allowStale` and the -iterator surface are not yet implemented. - -```typescript,no-test -{{#include ../../examples/stdlib/other/snippets.ts:lru-cache}} -``` - ## child_process ```typescript,no-test diff --git a/docs/src/stdlib/overview.md b/docs/src/stdlib/overview.md index 90543c9487..da1fab5033 100644 --- a/docs/src/stdlib/overview.md +++ b/docs/src/stdlib/overview.md @@ -57,7 +57,6 @@ for compatibility guarantees. - **commander** — CLI argument parsing - **decimal.js** — Arbitrary precision decimals - **bignumber.js** — Big number math -- **lru-cache** — LRU caching ### Other - **sharp** — Image processing diff --git a/scripts/ci_ext_link_scope.py b/scripts/ci_ext_link_scope.py index e26f30b6df..b21040280e 100755 --- a/scripts/ci_ext_link_scope.py +++ b/scripts/ci_ext_link_scope.py @@ -130,9 +130,10 @@ def _self_test() -> int: return 1 # The five that actually failed in #7650 must be covered by the derived # list — if a rename drops one, this says so instead of quietly shrinking. + # #10685 removed perry-ext-lru-cache (native binding deleted); it was + # one of #7650's five and can never appear in the derived list again. for name in ( "perry-ext-pdf", - "perry-ext-lru-cache", "perry-ext-node-forge", "perry-ext-mongodb", "perry-ext-http", diff --git a/scripts/native_result_ledger.py b/scripts/native_result_ledger.py index 7ca06de8c5..f98a66b5ef 100644 --- a/scripts/native_result_ledger.py +++ b/scripts/native_result_ledger.py @@ -32,8 +32,8 @@ # `js_net_socket_unpipe`. Each returns its `handle: i64` argument unchanged, a # `next_id_or_throw()` registry id rather than a heap address, so all four are # NR_HANDLE_ID. -EXPECTED_ROWS = 376 -EXPECTED_PROVIDERS = 326 +EXPECTED_ROWS = 374 +EXPECTED_PROVIDERS = 324 KINDS = { "NR_GCPTR", "NR_NULLABLE_GCPTR", diff --git a/scripts/native_result_ledger.tsv b/scripts/native_result_ledger.tsv index 170454102d..b0560bfc9e 100644 --- a/scripts/native_result_ledger.tsv +++ b/scripts/native_result_ledger.tsv @@ -122,8 +122,6 @@ js_lodash_tail NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader js_lodash_take NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader js_lodash_times NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader js_lodash_uniq NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader -js_lru_cache_new NR_HANDLE_ID crates/perry-ext-lru-cache/src/lib.rs Handle -js_lru_cache_set NR_HANDLE_ID crates/perry-ext-lru-cache/src/lib.rs Handle js_mongodb_client_close NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise js_mongodb_client_db NR_HANDLE_ID crates/perry-ext-mongodb/src/lib.rs Handle js_mongodb_collection_count_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise diff --git a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml index 1523a800c4..2a0b745650 100644 --- a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml +++ b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml @@ -19,7 +19,6 @@ perry-stdlib-core = { package = "perry-stdlib", path = "../../../../../../crates "html-parser", "rate-limit", "bundled-dotenv", - "bundled-lru-cache", "bundled-exponential-backoff", "bundled-decimal", "bundled-dayjs", diff --git a/workspace-architecture.json b/workspace-architecture.json index 240f5ba834..abe8507a7e 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 78, + "workspace_members": 77, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 29, + "externalize": 28, "keep": 44, "merge": 1, "remove": 1, @@ -240,11 +240,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-lru-cache": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-moment": { "category": "binding", "decision": "externalize", From b01009438784bc27127a3fa35992f877e2add6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:16:08 +0000 Subject: [PATCH 2/2] changelog: add fragment for #10708 (lru-cache binding removal) --- changelog.d/10708-remove-lrucache-binding.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/10708-remove-lrucache-binding.md diff --git a/changelog.d/10708-remove-lrucache-binding.md b/changelog.d/10708-remove-lrucache-binding.md new file mode 100644 index 0000000000..081eaad9c9 --- /dev/null +++ b/changelog.d/10708-remove-lrucache-binding.md @@ -0,0 +1,9 @@ +**Removed the native `lru-cache` binding** — `import { LRUCache } from "lru-cache"` now resolves to +the real npm package, compiled from source. Native `instanceof` threw `Right-hand side of +'instanceof' is not callable` and `constructor.name` was undefined (the handle isn't a real class +object); `cache.forEach(...)` silently visited nothing and a `dispose` callback was never invoked, +where npm's real implementation does both. Also removes the dedicated #10293 native-subclass +machinery (`class X extends LRUCache` support for a binding with no runtime class value) — the real +compiled package's `LRUCache` is an ordinary JS class, so subclassing needs no special support at +all. Fixes #10685. Requires #10439's import-provenance fix (#10699) to reach the real package at its +default import name.