fix(runtime): cap object keys-array length at capacity in property walks (prevents multi-GB / spin on a corrupt keys length)#6101
Conversation
The wide-key field-get index (get_field_by_name) and Object.assign's source enumeration both size their work by the object's keys array length (js_array_length). A dense keys array's logical length can never exceed its capacity, but a malformed keys array can report a bogus, pointer-sized length (observed roughly equal to the keys pointer's own low bits -- hundreds of millions). Fed straight into wide_key_index_lookup's HashMap::with_capacity(key_count) or a 0..key_count copy loop, that turns a single missing-property read or an Object.assign into a multi-GB allocation / minutes-long spin walking a phantom tail through the slow sparse-array path. Add keys_array_len_capped_to_capacity (array/indexing.rs) and use it in both walks. length <= capacity holds for well-formed dense keys arrays, so this is a no-op on the common path and a hard bound otherwise. Regression test forges an oversized length and asserts the cap returns capacity. This is defensive hardening for the property-walk consumers; the upstream cause of the oversized length (js_array_length reporting a pointer-sized value for a particular object's keys array) is a separate issue.
📝 WalkthroughWalkthroughAdds a new unsafe helper ChangesKeys array length capping
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry-runtime/src/array/indexing.rs (1)
411-418: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider a debug-only guard for the dense-array invariant.
The doc comment correctly notes this helper is only valid for dense keys/property arrays (
length <= capacity), not general sparse JS arrays. Since this ispub(crate)and could be reached by a future caller unaware of that constraint, adebug_assert!could catch misuse early without adding release-mode overhead.🛡️ Optional debug guard
pub(crate) unsafe fn keys_array_len_capped_to_capacity(arr: *const ArrayHeader) -> usize { let raw = js_array_length(arr) as usize; if arr.is_null() { raw } else { + debug_assert!( + raw <= (*arr).capacity as usize || cfg!(test), + "keys_array_len_capped_to_capacity called on a possibly-sparse array (length > capacity)" + ); raw.min((*arr).capacity as usize) } }🤖 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-runtime/src/array/indexing.rs` around lines 411 - 418, Add a debug-only invariant check in keys_array_len_capped_to_capacity to catch misuse of this helper on non-dense arrays. Keep the current release behavior unchanged, but insert a debug_assert! that the array is either null or satisfies the dense-array contract (length <= capacity) before using (*arr).capacity, so future callers of this pub(crate) helper get an early signal if they violate the documented constraint.
🤖 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.
Nitpick comments:
In `@crates/perry-runtime/src/array/indexing.rs`:
- Around line 411-418: Add a debug-only invariant check in
keys_array_len_capped_to_capacity to catch misuse of this helper on non-dense
arrays. Keep the current release behavior unchanged, but insert a debug_assert!
that the array is either null or satisfies the dense-array contract (length <=
capacity) before using (*arr).capacity, so future callers of this pub(crate)
helper get an early signal if they violate the documented constraint.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 2c79cccf-e0e0-4345-a914-3897a1ba0dfc
📒 Files selected for processing (4)
crates/perry-runtime/src/array/indexing.rscrates/perry-runtime/src/array/mod.rscrates/perry-runtime/src/object/alloc.rscrates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
…ubs (#233) (#6138) When an array outgrows its capacity, `js_array_grow` allocates a larger copy and installs a `GC_FLAG_FORWARDED` stub at the old location — its first 8 bytes (length|capacity) become the forwarding pointer to the grown array. Every hot array accessor resolves this via `clean_arr_ptr`, and the plain-JSON path (`json/stringify.rs`) already did, but the JSON.stringify *replacer* paths — the array arm of `dispatch_pointer_with_replacer` and `stringify_array_with_array_replacer` — read `(*arr).length` directly on the stub, yielding a bogus multi-GB "length". A stale pre-grow pointer reaches these paths from the object graph: e.g. React's RSC flight stores a `[key, value]` pair, then grows it with `pair[i] = …`, while the serialized payload still holds the pre-grow reference. The garbage length was only kept from SIGBUS-ing by the 10M sanity cap (emit "null"), silently dropping the real data. Follow the forwarding chain before reading, so the CURRENT grown array is serialized. This is the shared root cause of the two length-cap band-aids (#6101 property-walk cap, #6055 JSON null-cap); the caps stay as defensive backstops for genuinely mis-classified pointers. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Problem
Two object property walks size their work by an object's keys-array length via
js_array_length:get_field_by_name(get_field_by_name_tail.rs), which doesHashMap::with_capacity(key_count)+ a0..key_countbuild inwide_key_index_lookup; andObject.assign's source enumeration (js_object_assign_one), which does a0..key_countcopy loop.A dense keys array's logical length can never exceed its capacity. But when a keys array is malformed and
js_array_lengthreports a bogus, pointer-sized length (observed ≈ the keys pointer's own low bits — hundreds of millions), both walks are driven into unbounded work: a single missing-property read allocates a multi-hundred-million-bucketHashMap(multi-GB RSS), and anObject.assignspins for minutes walking a phantom tail through the slow sparse-arrayjs_array_getpath. On a large bundled application this manifested as the process pinning ~100% CPU at 9 GB RSS (the map build) or a flat-RSS spin (the copy loop), never making progress.Fix
Add
keys_array_len_capped_to_capacity(array/indexing.rs) —min(js_array_length(arr), (*arr).capacity)— and use it in both walks. For a well-formed dense keys arraylength <= capacity, so this is a no-op on the common path and a hard bound otherwise (work is limited to physically-present slots). Scoped to object keys/property arrays; general JS arrays may legitimately havelength > capacity(sparse), so the helper is documented as keys-array-only.Test
keys_len_capped_bounds_bogus_length_to_capacityforges an oversized length on a small array and asserts the cap returnscapacity(not the bogus length). Full suite:cargo test --release -p perry-runtime→ 1162 passed / 0 failed.Note
This is defensive hardening at the two consumers. The upstream cause —
js_array_lengthreturning a pointer-sized value for a particular object's keys array (a length/pointer that looks transiently stale) — is a separate, deeper issue worth its own fix; other keys-array walks (field-set, arguments enumeration) share the same iterate-by-length pattern and would benefit from the same guard or, better, the root fix.Summary by CodeRabbit