Skip to content

GC: js_object_assign_one's string-source arm held a raw borrow into the source StringHeader across its allocating copy loop #7215

Closed

Description

@proggeramlug

Summary

object_assign_string_source (crates/perry-runtime/src/object/alloc.rs) decoded the SOURCE string once into a raw (*const u8, len) view and held that borrow across every allocation in its copy loop. Found in CodeRabbit review of #7207, which had rooted the other three values in that same function.

str_bytes_from_jsvalue returns hdr + size_of::<StringHeader>() for any string past the SSO limit (SHORT_STRING_MAX_LEN = 5 bytes), string_storage_alloc allocates header and payload as one contiguous arena_alloc_gc block, and GC_TYPE_STRING carries movable = true. The decoder's own safety note forbids exactly this use:

Callers must not hold this pointer past a subsequent scratch modification or a GC cycle that could sweep the heap-backed StringHeader.

The loop held it across three allocation points per character (two js_string_from_bytes, plus the write funnel's key interning / keys-array growth).

Measured, not asserted

An instrumented runtime rooted both the source and the target inside the loop and counted observed relocations. On a 26 001-character source, evacuating arm, PERRY_GC_FORCE_EVACUATE=1:

[probe7214] chars=26001 src_moves=0 tgt_moves=1

Under the threshold the source is a movable nursery string, but one call then allocates too little to reliably span a collection. Four shapes (209 / 8 000 / 26 001 chars × 4 arm configurations, up to 120 cycles) all stayed clean.

So there is no runtime witness, and the shipped test says so. The exposure is real and narrow: a source in the band just under 16 KiB is both movable and long enough to allocate ~32 000 times. The safety margin rests entirely on a tunable constant.

Fixed

#7215 snapshots the characters once before the loop (s.to_string()), matching the expandos snapshot precedent a few lines down in the same file, on a path that is already O(n).

The general lesson, which is bigger than this call site

str_bytes_from_jsvalue has ~N callers and its contract is stated only in a doc comment. Any caller that allocates between the decode and the last use of the view has this bug, and no static gate reaches it: gc_root_dominance_check.py (both modes) reads emitted LLVM IR, and this is runtime Rust. #7207's --unrooted-allocas mode and #7206's --stale-registers mode are both blind to it by construction.

Worth an audit of the other callers, and worth considering whether the decoder should return an owned/rooted form on the heap-string path so the contract is enforced by the type rather than by a comment.

Refs #7207, #7200, #7154.

Activity

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in #7216.

  2. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Re-verified on main = d4ac65f21. Fixed — closing.

    Fixed by #7216 (dd83c7e92). object_assign_string_source (crates/perry-runtime/src/object/alloc.rs:1171) now takes one owned copy before the loop:

    let owned: String = s.to_string();

    matching the expandos snapshot precedent a few lines down, on a path that is already O(n).

    No runtime witness is being claimed, and the fix comment in the tree says so — this issue's own measurement is the reason: chars=26001 src_moves=0 tgt_moves=1. The target moved (so #7207's target rooting there is load-bearing) but that source could not, because at 26 KB it exceeds LARGE_OBJECT_THRESHOLD_BYTES and is born GC_FLAG_TENURED in the non-moving old generation. Under the threshold it is movable but one call allocates too little to reliably span a collection. The exposure is the band just under 16 KiB, and the point of fixing rather than documenting it is that the safety margin rested entirely on a tunable constant.

    Closing on the code change, which is present and correct, rather than on a green run that would prove nothing here.

    The general lesson in this issue is NOT closed by it — str_bytes_from_jsvalue / string_as_str / js_string_key_bytes all hand out pointers into a relocatable payload with the contract stated only in a doc comment, and no static gate reaches them. #7247 is a live, 10/10-deterministic instance of exactly this family in js_regexp_new, and it remains open.

    Part of the sweep in #7262.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions