Skip to content

fix(lru-cache): faithful JS-value keys/values, GC rooting, TTL + updateAgeOnGet - #7136

Open
jdalton wants to merge 2 commits into
PerryTS:mainfrom
jdalton:feat/lru-cache-faithful
Open

fix(lru-cache): faithful JS-value keys/values, GC rooting, TTL + updateAgeOnGet#7136
jdalton wants to merge 2 commits into
PerryTS:mainfrom
jdalton:feat/lru-cache-faithful

Conversation

@jdalton

@jdalton jdalton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Perry's built-in lru-cache binding only ever worked correctly for numeric keys and numeric values with no expiry. The way people actually use the package, string keys mapping to objects or strings with ttl and updateAgeOnGet set, misbehaved silently. Three separate things went wrong, and none of them raised an error.

A get("k") after a set("k", …) would miss, because string keys were compared by their pointer bits rather than their text, so two separately allocated copies of the same string looked like different keys. Cached values that live on the heap were never registered as garbage-collector roots, so the collector was free to reclaim or relocate a value that was still in the cache, and reading it afterwards produced the TypeError: value is not a function class of failure. And the ttl and updateAgeOnGet options were parsed but ignored, so entries never expired.

This makes the binding behave like the npm package for the surface it covers. The extern signatures do not change; the interface stays 64-bit-float-wide.

Background: how a JavaScript value fits in a 64-bit float

The binding's interface passes every key and value as an f64, a 64-bit floating-point number. That is not a restriction to numbers, because Perry uses NaN-boxing.

A 64-bit float has a large family of bit patterns that all mean "not a number", and only one of them is ever produced by arithmetic. The rest are unused, so a runtime can hide other things in them: a pointer to a heap object, a boolean, null, undefined. A real number is stored as itself, and everything else is stored as a tagged pattern inside the unused NaN space.

The old binding treated the incoming f64 as raw bits rather than as a NaN-boxed value. That is the root of the first two defects: raw bits of a string are its pointer, so comparing them compares addresses, and raw bits are invisible to the collector, so nothing kept the pointed-at object alive.

Keys and values are now real NaN-boxed values

String keys are hashed and compared by content. The string's bytes are materialized through js_get_string_pointer_unified and the map is keyed on those bytes, so two separately allocated copies of "k" are the same key.

Numbers, booleans, null, and undefined are keyed by canonical value using SameValueZero, the same comparison JavaScript's own Map uses. That unifies +0 with -0 and treats all NaN values as one key.

A miss from get now returns a real undefined, where it previously returned a raw NaN.

Cached values are garbage-collector roots while cached

A mutable root scanner is registered with gc_register_mutable_root_scanner_named, and it visits every cached value slot on each collection cycle.

That does two jobs at once. It marks the value as live, so the collector does not reclaim something the cache still holds. And under copying evacuation, where the collector physically moves objects, it rewrites the cache's stored pointer to the value's forwarded address, so the cache keeps pointing at the object after it moves.

This mirrors what perry-ext-events and perry-ext-exponential-backoff already do.

Constructor options are now honored

new LRUCache({ max, ttl, updateAgeOnGet }) is parsed from the NaN-boxed options object at runtime. Code generation now forwards the whole object rather than statically extracting only max, which is what makes dynamically built or variable option objects work rather than only object literals.

ttl sets a per-entry lifetime measured against the runtime's performance.now() clock. get, has, and peek all treat an expired entry as absent, and get evicts it. updateAgeOnGet resets an entry's expiry clock on a get that finds it still live.

peek is now wired into method dispatch, and has and delete return real booleans.

Test runs

cargo test -p perry-ext-lru-cache reports 16 passing. The new tests cover a string-key content round-trip, an object value surviving a forced gc_collect_minor() cycle, expiry and eviction under ttl, updateAgeOnGet refreshing versus not refreshing, eviction once max is reached, and primitive-key derivation.

The full Perry compiler builds clean with the code-generation changes.

What is deliberately not covered

maxSize and sizeCalculation, dispose and disposeAfter, fetch, allowStale, per-call option objects, and the iterator surface are all out of scope, because the (key, value) interface cannot carry them.

Object-identity keys work through pointer identity, but they are not tracked across a garbage-collector relocation, so an object key can stop matching after a collection moves it. Primitive keys are the collector-safe path and cover all real usage seen so far.

Tracked under #466, Phase 5 native bindings.

Summary by CodeRabbit

  • New Features

    • Improved LRU cache compatibility with JavaScript key and value behavior.
    • Added support for TTL expiration and refreshing entry age on access.
    • Added peek access without updating entry recency.
    • Added support for cache configuration options, including capacity limits.
  • Bug Fixes

    • Cached values now remain available through garbage collection.
    • Improved handling of primitive keys, strings, special numeric values, and invalid cache handles.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ad08bd55-425e-4cd0-b8ac-2510e3248e80

📥 Commits

Reviewing files that changed from the base of the PR and between de11c2b and f6675e8.

📒 Files selected for processing (3)
  • changelog.d/7136-lru-cache-faithful.md
  • crates/perry-ext-lru-cache/src/lib.rs
  • crates/perry-ext-lru-cache/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • changelog.d/7136-lru-cache-faithful.md
  • crates/perry-ext-lru-cache/src/tests.rs
  • crates/perry-ext-lru-cache/src/lib.rs

📝 Walkthrough

Walkthrough

The native lru-cache binding now supports JavaScript-value keys and values, runtime options, TTL expiry, recency updates, peek, and GC-safe cached values. Code generation forwards complete constructor options, and tests cover the new behavior.

Changes

LRU cache fidelity

Layer / File(s) Summary
Cache keys, values, and core operations
crates/perry-ext-lru-cache/src/lib.rs, crates/perry-ext-lru-cache/src/tests.rs
The cache uses canonical JavaScript keys, NaN-boxed values, GC scanning, CRUD operations, and LRU eviction. Tests cover primitive and string keys, stored values, eviction, and invalid handles.
Options, expiration, peek, and validation
crates/perry-ext-lru-cache/src/lib.rs, crates/perry-ext-lru-cache/src/tests.rs, crates/perry-ext-lru-cache/Cargo.toml, changelog.d/7136-lru-cache-faithful.md
The cache parses max, ttl, and updateAgeOnGet, applies expiration rules, implements peek, and tests GC survival. The changelog documents supported behavior and limitations.
Constructor and method wiring
crates/perry-codegen/src/lower_call/builtin.rs, crates/perry-codegen/src/lower_call/native_table/node_misc.rs
Constructor lowering forwards the complete options value. The native signature table dispatches lru-cache.peek to js_lru_cache_peek.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JavaScript
  participant NativeDispatch
  participant LruCacheHandle
  participant GarbageCollector
  JavaScript->>NativeDispatch: construct LRUCache(options)
  NativeDispatch->>LruCacheHandle: create cache with options
  JavaScript->>NativeDispatch: get, has, delete, or peek(key)
  NativeDispatch->>LruCacheHandle: canonicalize key and access entry
  LruCacheHandle-->>NativeDispatch: return value or undefined
  GarbageCollector->>LruCacheHandle: scan cached heap values
  LruCacheHandle-->>GarbageCollector: update forwarded value pointers
Loading

Possibly related PRs

  • PerryTS/perry#7147: Changes conservative-scan configuration and GC relocation tests, related to the cached-value GC survival test.

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main lru-cache fixes for JavaScript values, garbage-collection rooting, TTL, and access-age updates.
Description check ✅ Passed The description explains the problems, implementation, tests, supported behavior, and out-of-scope items, although it does not use every template heading.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jdalton
jdalton force-pushed the feat/lru-cache-faithful branch 2 times, most recently from bf0c65a to 51b0a1b Compare July 31, 2026 16:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
crates/perry-ext-lru-cache/src/tests.rs (2)

188-212: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce timing flakiness in the TTL tests.

update_age_on_get_refreshes_ttl and no_update_age_on_get_lets_ttl_expire depend on real sleeps with a 30 ms margin against an 80 ms TTL. On a loaded runner a 50 ms sleep can overshoot 80 ms, and the refresh test then fails. Increase the TTL relative to the sleeps, or drive the mock-timer facility that the crate docs describe for js_performance_now.

♻️ Proposed change: widen the margins
-    let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(80.0), true));
+    let h = perry_ffi::register_handle(LruCacheHandle::new(10, Some(300.0), true));
     js_lru_cache_set(h, 1.0, 111.0);
     // Halfway through the TTL, a get refreshes the clock.
-    std::thread::sleep(std::time::Duration::from_millis(50));
+    std::thread::sleep(std::time::Duration::from_millis(150));
     assert_eq!(js_lru_cache_get(h, 1.0), 111.0);
     // Another half-TTL later the entry is still live *because* it was
-    // refreshed (without updateAgeOnGet it would have expired at ~80ms).
-    std::thread::sleep(std::time::Duration::from_millis(50));
+    // refreshed (without updateAgeOnGet it would have expired at ~300ms).
+    std::thread::sleep(std::time::Duration::from_millis(150));
     assert_eq!(js_lru_cache_get(h, 1.0), 111.0, "get refreshed the TTL");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-lru-cache/src/tests.rs` around lines 188 - 212, Reduce
timing flakiness in update_age_on_get_refreshes_ttl and
no_update_age_on_get_lets_ttl_expire by widening the TTL margin relative to
their existing sleeps, or use the crate’s documented mock-timer facility for
js_performance_now. Preserve the assertions that get refreshes the TTL when
enabled and that the entry expires without refresh when disabled.

236-269: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Restore the GC global state even when an assertion fails.

cached_value_survives_gc_cycle mutates process-global state: it enables write barriers and pushes a shadow frame. The teardown at lines 267-268 runs only on the success path. If an assertion panics, barriers stay enabled and the shadow frame stays pushed for every later test in the same binary. The unwrap_or_else(|poisoned| …) on the lock shows that a panic is expected to be survivable, so make the teardown unconditional with a Drop guard.

♻️ Proposed change: teardown in `Drop`
     perry_runtime::gc::js_gc_write_barriers_emitted(1);
     let frame = perry_runtime::gc::js_shadow_frame_push(0);
+    struct GcGuard(u64);
+    impl Drop for GcGuard {
+        fn drop(&mut self) {
+            perry_runtime::gc::js_shadow_frame_pop(self.0);
+            perry_runtime::gc::js_gc_write_barriers_emitted(0);
+        }
+    }
+    let _gc_guard = GcGuard(frame);
     ensure_gc_scanner();
     perry_ffi::drop_handle(h);
-    perry_runtime::gc::js_shadow_frame_pop(frame);
-    perry_runtime::gc::js_gc_write_barriers_emitted(0);
 }

Adjust GcGuard's field type to the type that js_shadow_frame_push returns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-lru-cache/src/tests.rs` around lines 236 - 269, Update
cached_value_survives_gc_cycle to restore GC global state through an
unconditional Drop guard rather than success-path teardown. Add or reuse
GcGuard, set its field type to the return type of js_shadow_frame_push, and have
its Drop implementation pop the shadow frame and disable write barriers; ensure
the guard is created after acquiring GC_TEST_LOCK and remains active through all
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/lower_call/builtin.rs`:
- Around line 531-536: Update the "LRUCache" arm to lower every argument after
the first via the existing side-effect-preserving pattern used by neighboring
arms such as "Utf8Stream" and "EventEmitter". Keep the first argument as
opts_val, and ensure calls like new LRUCache(opts, f()) still emit f().

In `@crates/perry-ext-lru-cache/src/lib.rs`:
- Around line 126-135: Update the string-key conversion logic around
js_get_string_pointer_unified and read_bytes so failed materialization returns a
distinct non-colliding CacheKey variant instead of
CacheKey::Str(Box::default()). Preserve CacheKey::Str for successfully
materialized strings, including the real empty string, and add the variant to
CacheKey and any exhaustive matching or hashing logic that requires it.
- Around line 248-252: The max option handling in LruCacheHandle initialization
must cap large finite values before converting n to usize and passing max to
LruCacheHandle::new. Add an upper bound to the n >= 1.0 branch, preserving valid
values while preventing oversized allocations from lru’s internal HashMap.

---

Nitpick comments:
In `@crates/perry-ext-lru-cache/src/tests.rs`:
- Around line 188-212: Reduce timing flakiness in
update_age_on_get_refreshes_ttl and no_update_age_on_get_lets_ttl_expire by
widening the TTL margin relative to their existing sleeps, or use the crate’s
documented mock-timer facility for js_performance_now. Preserve the assertions
that get refreshes the TTL when enabled and that the entry expires without
refresh when disabled.
- Around line 236-269: Update cached_value_survives_gc_cycle to restore GC
global state through an unconditional Drop guard rather than success-path
teardown. Add or reuse GcGuard, set its field type to the return type of
js_shadow_frame_push, and have its Drop implementation pop the shadow frame and
disable write barriers; ensure the guard is created after acquiring GC_TEST_LOCK
and remains active through all assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 211cad3f-14d1-4dd6-902b-e081fdf14826

📥 Commits

Reviewing files that changed from the base of the PR and between a3b31c0 and 51b0a1b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • changelog.d/7136-lru-cache-faithful.md
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-codegen/src/lower_call/native_table/node_misc.rs
  • crates/perry-ext-lru-cache/Cargo.toml
  • crates/perry-ext-lru-cache/src/lib.rs
  • crates/perry-ext-lru-cache/src/tests.rs

Comment on lines 531 to 536
"LRUCache" => {
let max_val = if let Some(opts_arg) = args.first() {
let mut found_max: Option<String> = None;
if let Some(props) = extract_options_fields(ctx, opts_arg) {
for (k, vexpr) in &props {
if k == "max" {
found_max = Some(lower_expr(ctx, vexpr)?);
} else {
// Lower other fields for side effects (e.g. ttl
// option's setter calls).
let _ = lower_expr(ctx, vexpr)?;
}
}
} else {
// Non-literal arg (variable, dynamic shape) — lower for
// side effects only; cannot extract max statically.
let _ = lower_expr(ctx, opts_arg)?;
}
found_max.unwrap_or_else(|| "100.0".to_string())
let opts_val = if let Some(opts_arg) = args.first() {
lower_expr(ctx, opts_arg)?
} else {
"100.0".to_string()
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Lower the remaining arguments for their side effects.

This arm lowers only args.first(). Every neighboring arm in this file lowers the tail arguments for side effects, for example Utf8Stream at lines 74-76 and EventEmitter at lines 278-280. If a caller writes new LRUCache(opts, f()), the call to f() is never emitted. Keep the convention.

🐛 Proposed fix
             let opts_val = if let Some(opts_arg) = args.first() {
                 lower_expr(ctx, opts_arg)?
             } else {
                 double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
             };
+            // Lower any extra args for side effects.
+            for arg in args.iter().skip(1) {
+                let _ = lower_expr(ctx, arg)?;
+            }
             let blk = ctx.block();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"LRUCache" => {
let max_val = if let Some(opts_arg) = args.first() {
let mut found_max: Option<String> = None;
if let Some(props) = extract_options_fields(ctx, opts_arg) {
for (k, vexpr) in &props {
if k == "max" {
found_max = Some(lower_expr(ctx, vexpr)?);
} else {
// Lower other fields for side effects (e.g. ttl
// option's setter calls).
let _ = lower_expr(ctx, vexpr)?;
}
}
} else {
// Non-literal arg (variable, dynamic shape) — lower for
// side effects only; cannot extract max statically.
let _ = lower_expr(ctx, opts_arg)?;
}
found_max.unwrap_or_else(|| "100.0".to_string())
let opts_val = if let Some(opts_arg) = args.first() {
lower_expr(ctx, opts_arg)?
} else {
"100.0".to_string()
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
"LRUCache" => {
let opts_val = if let Some(opts_arg) = args.first() {
lower_expr(ctx, opts_arg)?
} else {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
// Lower any extra args for side effects.
for arg in args.iter().skip(1) {
let _ = lower_expr(ctx, arg)?;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/lower_call/builtin.rs` around lines 531 - 536,
Update the "LRUCache" arm to lower every argument after the first via the
existing side-effect-preserving pattern used by neighboring arms such as
"Utf8Stream" and "EventEmitter". Keep the first argument as opts_val, and ensure
calls like new LRUCache(opts, f()) still emit f().

Comment on lines +126 to +135
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::Str(Box::default())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Distinguish a failed string materialization from an empty string key.

If js_get_string_pointer_unified returns null or read_bytes returns None, the code falls back to CacheKey::Str(Box::default()). That value equals the key for the real "" string, and every failed materialization aliases the same key. A get can then return a value stored under an unrelated key. Use a distinct variant for the unresolvable case so it cannot collide.

🐛 Proposed fix: add a non-colliding variant
     /// Heap pointer identity (lower 48 bits) for object/array/function keys.
     Obj(u64),
+    /// A string key whose bytes could not be materialized. Kept separate from
+    /// `Str(b"")` so a failed read never aliases the empty-string key.
+    UnresolvedStr(u64),
 }
-        CacheKey::Str(Box::default())
+        CacheKey::UnresolvedStr(key.to_bits())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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::Str(Box::default())
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())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-lru-cache/src/lib.rs` around lines 126 - 135, Update the
string-key conversion logic around js_get_string_pointer_unified and read_bytes
so failed materialization returns a distinct non-colliding CacheKey variant
instead of CacheKey::Str(Box::default()). Preserve CacheKey::Str for
successfully materialized strings, including the real empty string, and add the
variant to CacheKey and any exhaustive matching or hashing logic that requires
it.

Comment on lines +248 to +252
if let Some(n) = option_number(ptr, "max") {
if n >= 1.0 {
max = n as usize;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Fetch the lru 0.18 source and inspect `LruCache::new` / `with_capacity`.
mkdir -p /tmp/lru && cd /tmp/lru
curl -sL https://crates.io/api/v1/crates/lru/0.18.0/download -o lru.crate
tar xzf lru.crate
rg -n -A 20 'pub fn new\(|fn construct\(|with_capacity' lru-0.18.0/src/lib.rs | head -80

Repository: PerryTS/perry

Length of output: 256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files around target library =="
git ls-files | rg 'crates/perry-ext-lru-cache/src/lib.rs|Cargo.toml|Cargo.lock' || true

echo "== target snippet =="
cat -n crates/perry-ext-lru-cache/src/lib.rs | sed -n '220,270p'

echo "== dependency declarations for lru =="
rg -n 'name = "lru"|lru\s*\{' Cargo.toml crates/perry-ext-lru-cache/Cargo.toml Cargo.lock 2>/dev/null || true
rg -n 'lru ' Cargo.lock 2>/dev/null || true

echo "== option_number definition =="
rg -n 'fn option_number|option_number' crates/perry-ext-lru-cache/src/lib.rs crates -g '*.rs' -C 4 || true

Repository: PerryTS/perry

Length of output: 28289


🌐 Web query:

lru 0.18.0 LruCache::new with_capacity source HashMap with_capacity cap

💡 Result:

In the lru crate (version 0.18.0), the LruCache::new constructor does not use an explicit with_capacity method on the LruCache struct itself [1][2][3]. Instead, it internally utilizes std::collections::HashMap::with_capacity [2][3]. The implementation of LruCache::new is as follows: pub fn new(cap: NonZeroUsize) -> LruCache<K, V> { LruCache::construct(cap, HashMap::with_capacity(cap.get)) } The constructor takes a NonZeroUsize to define the capacity, which ensures the cache is always initialized with a valid, non-zero limit [1][2][3]. This capacity is passed directly to the underlying HashMap via HashMap::with_capacity, which pre-allocates memory for the hash map based on that size [2][3]. This design differs from some other LRU implementations that might expose a public with_capacity method [4][5]. In the lru crate, the primary constructor is new, and the internal HashMap capacity is managed automatically based on the NonZeroUsize value provided [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lru dependency lock section =="
python3 - <<'PY'
from pathlib import Path
p = Path("Cargo.lock")
text = p.read_text()
for line in text.splitlines():
    if line.startswith('name = "lru"'):
        print(line)
PY

echo "== LruCacheHandle and cache construction =="
cat -n crates/perry-ext-lru-cache/src/lib.rs | sed -n '1,130p'

echo "== Rust as-cast saturation probe =="
python3 - <<'PY'
import subprocess, sys
code = r'''
#![cfg(target_pointer_width = "")]
fn main() {
    let values: &[f64] = &[1e12, 1e19, 1e30, f64::INFINITY, f64::MAX, 1.0, 2.7, 0.0, -1.0];
    for v in values {
        println!("{v} -> {}", if v.is_finite() { v as usize } else { usize::MAX });
    }
}
'''
# Avoid executing repository code: create temp file and run cargo/rustc only if tooling exists.
# For deterministic behavior, use a tiny external crate-free source is not possible here due command syntax.
PY

cat > /tmp/rust_f64_usize_sat.rs <<'RS'
#![allow(unused)]
fn main() {
    let values: &[f64] = &[1e12, 1e19, 1e30, f64::INFINITY, f64::MAX, 1.0, 2.7, 0.0, -1.0];
    for v in values {
        println!("{v} -> {}", if v.is_finite() { v as usize } else { usize::MAX });
    }
}
RS

if command -v rustc >/dev/null 2>&1; then
  rustc /tmp/rust_f64_usize_sat.rs -o /tmp/rust_f64_usize_sat
  /tmp/rust_f64_usize_sat
else
  echo "rustc not available"
fi

Repository: PerryTS/perry

Length of output: 6763


🌐 Web query:

docs.rs lru 0.18.0 source lru.rs new HashMap::with_capacity cap

💡 Result:

In version 0.18.0 of the lru crate, the LruCache::new function initializes the internal HashMap using HashMap::with_capacity(cap.get()) [1][2]. Specifically, the source code for the new constructor in src/lib.rs is implemented as follows [3]: pub fn new(cap: NonZeroUsize) -> LruCache<K, V> { LruCache::construct(cap, HashMap::with_capacity(cap.get)) } The LruCache::construct function then uses this map to initialize the cache structure [3]. For caches utilizing a custom hasher, the LruCache::with_hasher method similarly uses HashMap::with_capacity_and_hasher(cap.into(), hash_builder) to allocate the internal map [1][3].

Citations:


🌐 Web query:

site:docs.rs lru 0.18.0 LruCache::new HashMap with_capacity

💡 Result:

In the lru crate version 0.18.0, the LruCache::new constructor takes a NonZeroUsize parameter to specify the capacity of the cache [1][2]. The implementation internally initializes a HashMap with this capacity [2][3]: pub fn new(cap: NonZeroUsize) -> LruCache<K, V> { LruCache::construct(cap, HashMap::with_capacity(cap.get)) } As shown above, the constructor implicitly calls HashMap::with_capacity using the value provided by the NonZeroUsize input to allocate the underlying hash map [2][3]. Users do not need to manually specify a hash map capacity, as this is handled automatically during the initialization of the LruCache [2].

Citations:


Clamp max before creating the LruCache.

option_number accepts large finite numbers, and n as usize saturates to usize::MAX, which lru 0.18 passes to the internal HashMap capacity. A call like new LRUCache({ max: 1e12 }) can request a huge allocation and abort the process. Add an upper bound before passing max to LruCacheHandle::new.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-lru-cache/src/lib.rs` around lines 248 - 252, The max option
handling in LruCacheHandle initialization must cap large finite values before
converting n to usize and passing max to LruCacheHandle::new. Add an upper bound
to the n >= 1.0 branch, preserving valid values while preventing oversized
allocations from lru’s internal HashMap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-ext-lru-cache/src/tests.rs`:
- Around line 236-268: Add a local Drop guard immediately after the GC setup in
the test, covering the state changed by js_gc_write_barriers_emitted and
js_shadow_frame_push. Implement its cleanup to pop frame and disable write
barriers, then remove the manual cleanup calls at the test end so cleanup also
runs during panics from ensure_gc_scanner, js_lru_cache_get, or assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a229d22a-8368-4eb3-aeed-92ef3ac8160a

📥 Commits

Reviewing files that changed from the base of the PR and between a3b31c0 and 51b0a1b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • changelog.d/7136-lru-cache-faithful.md
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-codegen/src/lower_call/native_table/node_misc.rs
  • crates/perry-ext-lru-cache/Cargo.toml
  • crates/perry-ext-lru-cache/src/lib.rs
  • crates/perry-ext-lru-cache/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • changelog.d/7136-lru-cache-faithful.md
  • crates/perry-codegen/src/lower_call/native_table/node_misc.rs
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-ext-lru-cache/Cargo.toml
  • crates/perry-ext-lru-cache/src/lib.rs

Comment on lines +236 to +268
let _lock = GC_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());

// 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);
ensure_gc_scanner();

let h = js_lru_cache_new(f64::from_bits(TAG_UNDEFINED));
// 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.
js_lru_cache_set(h, 1.0, string_value("value-object-1234567890"));

// 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 — root scanner did not keep it alive"
);
assert_eq!(
read_string_value(got).as_deref(),
Some("value-object-1234567890"),
"cached value corrupted across GC — slot was not rewritten to the forwarded address"
);

perry_ffi::drop_handle(h);
perry_runtime::gc::js_shadow_frame_pop(frame);
perry_runtime::gc::js_gc_write_barriers_emitted(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make GC-state cleanup panic-safe.

Lines 242-244 modify process-global GC state. If ensure_gc_scanner, js_lru_cache_get, or an assertion panics, lines 266-268 do not run. Later tests can then run with write barriers enabled and an unpopped shadow frame.

Install a local Drop guard immediately after the GC setup. The guard must always pop frame and disable write barriers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-lru-cache/src/tests.rs` around lines 236 - 268, Add a local
Drop guard immediately after the GC setup in the test, covering the state
changed by js_gc_write_barriers_emitted and js_shadow_frame_push. Implement its
cleanup to pop frame and disable write barriers, then remove the manual cleanup
calls at the test end so cleanup also runs during panics from ensure_gc_scanner,
js_lru_cache_get, or assertions.

…teAgeOnGet

perry-ext-lru-cache only handled numeric (f64) keys/values with no TTL, so
real usage (string keys -> object/string values with ttl + updateAgeOnGet,
e.g. Socket Firewall) silently misbehaved.

- Keys/values are treated as real NaN-boxed JS values, not raw f64 bits.
  String keys hash/compare by CONTENT (materialized via
  js_get_string_pointer_unified); numbers/bools/null/undefined key by
  canonical value (SameValueZero). get returns real `undefined` on a miss;
  has/delete return real booleans.
- Cached heap values are GC roots while cached: a mutable root scanner
  (gc_register_mutable_root_scanner_named) visits every cached value slot
  each cycle, so values are marked AND rewritten to their forwarded address
  under copying evacuation -- fixing a use-after-free.
- new LRUCache({ max, ttl, updateAgeOnGet }) is parsed from the NaN-boxed
  options object; codegen forwards the whole object (dynamic options work)
  instead of statically extracting only max. ttl uses performance.now for
  expiry (get/has/peek treat expired as absent; get evicts); updateAgeOnGet
  refreshes the TTL clock on a live get. peek wired into method dispatch.

Adds Rust unit tests (string-key content round-trip, object-value survival
across a forced GC cycle, TTL expiry, updateAgeOnGet, eviction at max) --
all green via `cargo test -p perry-ext-lru-cache`.

Tracking: PerryTS#466 (Phase 5 native bindings). PR PerryTS#7136.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant