Summary
#7217 established the invariant and fixed the one site that had a runtime witness:
A bootstrap that builds an IMMORTAL object graph through raw pointers held across its own allocations must run in a NO-MOVE WINDOW.
populate_global_this_builtins now runs in a GcSuppressScope, gated by a cargo-test-visible unit test that arms a collection, shows the bootstrap does not service it, and shows the same armed request IS serviced by ordinary allocation once the window closes.
Two sibling builders have the identical shape and are NOT covered, because I could not build a gate for them that can fail. Filing rather than shipping ungated, per CLAUDE.md's knob-kill policy ("shipping a GC-trigger change with no test that can fail without it is the thing [it] exists to stop", quoted verbatim at crates/perry-runtime/src/string/alloc.rs:49).
The two sites
| builder |
the raw locals |
also reachable ahead of the bootstrap from |
object/global_this/generator.rs::ensure_generator_intrinsics → build_generator_tower |
*mut ObjectHeader / *mut ClosureHeader ctor+proto locals threaded across every allocating install in the tower |
generator_function_proto_of, generator_function_prototype_of, generator_prototype_ptr — i.e. any program that reflects on a generator without touching globalThis |
object/global_this/typed_array.rs::ensure_typed_array_intrinsic |
ctor and proto, held across ~a dozen set_builtin_property_attrs / set_field installs |
typedarray_props.rs:812 |
Both build an immortal graph (the towers hang off process-global intrinsic slots), so the same argument applies verbatim: a collection inside them frees nothing and can only move the holders out from under them.
When they are reached from populate_global_this_builtins they inherit its window and are safe today. The exposure is the lazy-ahead-of-bootstrap path only.
Why #7217 could not gate them, measured
populate_global_this_builtins is gateable behaviourally — arm a pending collection, run it, assert nothing collected — because it allocates ~1.15 MB and therefore crosses several arena block boundaries, and arena_alloc_gc calls gc_check_trigger() on each. A tower is three orders of magnitude smaller and fits inside one block's tail, so it can reach no trigger at all:
- a first version of the gate arming a collection around
ensure_generator_intrinsics() passed with the GcSuppressScope deleted;
- a second version pre-filling to a block boundary, and re-execing the test binary so the child process is the first to touch a tower (the slots are process-global
AtomicI64s, so libtest's name ordering meant array::tests's js_get_global_this() built them first and the test was measuring nothing) — also passed with the scope deleted;
- a third version recording
gc_is_suppressed() from inside the builder under #[cfg(test)] reported suppressed even with the scope removed, which is unexplained and is itself a reason not to trust it as a gate. (Worth a look: something else may already be suppressing across part of the tower build — symbol/get.rs calls the manual gc_suppress()/gc_unsuppress() pair at three sites, and well_known_symbol is on this path.)
What would close this
Either of:
- A workload witness. A gap test that reflects on a generator (or touches a typed array) under enough allocation pressure to put a collection inside the tower build, run on the allocation-point arm (
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off) with PERRY_GC_PROTECT_FROMSPACE=1. If the quarantine faults inside build_generator_tower, the window is justified and the witness is its gate.
- A trigger the tower's size can reach.
GcTriggerThresholdTestGuard::make_malloc_sweep_due() arms the malloc-count trigger, which gc_check_trigger sees on every gc_malloc rather than once per arena block. If the tower's installs go through gc_malloc at all, that gives a lever a few-hundred-object build can actually trip.
Resolve the third bullet's anomaly first — if something already suppresses across these builds, the windows may be redundant rather than missing, and that is a better answer than adding them.
Refs #7217, #7196.
Summary
#7217 established the invariant and fixed the one site that had a runtime witness:
populate_global_this_builtinsnow runs in aGcSuppressScope, gated by acargo-test-visible unit test that arms a collection, shows the bootstrap does not service it, and shows the same armed request IS serviced by ordinary allocation once the window closes.Two sibling builders have the identical shape and are NOT covered, because I could not build a gate for them that can fail. Filing rather than shipping ungated, per CLAUDE.md's knob-kill policy ("shipping a GC-trigger change with no test that can fail without it is the thing [it] exists to stop", quoted verbatim at
crates/perry-runtime/src/string/alloc.rs:49).The two sites
object/global_this/generator.rs::ensure_generator_intrinsics→build_generator_tower*mut ObjectHeader/*mut ClosureHeaderctor+proto locals threaded across every allocating install in the towergenerator_function_proto_of,generator_function_prototype_of,generator_prototype_ptr— i.e. any program that reflects on a generator without touchingglobalThisobject/global_this/typed_array.rs::ensure_typed_array_intrinsicctorandproto, held across ~a dozenset_builtin_property_attrs/set_fieldinstallstypedarray_props.rs:812Both build an immortal graph (the towers hang off process-global intrinsic slots), so the same argument applies verbatim: a collection inside them frees nothing and can only move the holders out from under them.
When they are reached from
populate_global_this_builtinsthey inherit its window and are safe today. The exposure is the lazy-ahead-of-bootstrap path only.Why #7217 could not gate them, measured
populate_global_this_builtinsis gateable behaviourally — arm a pending collection, run it, assert nothing collected — because it allocates ~1.15 MB and therefore crosses several arena block boundaries, andarena_alloc_gccallsgc_check_trigger()on each. A tower is three orders of magnitude smaller and fits inside one block's tail, so it can reach no trigger at all:ensure_generator_intrinsics()passed with theGcSuppressScopedeleted;AtomicI64s, so libtest's name ordering meantarray::tests'sjs_get_global_this()built them first and the test was measuring nothing) — also passed with the scope deleted;gc_is_suppressed()from inside the builder under#[cfg(test)]reported suppressed even with the scope removed, which is unexplained and is itself a reason not to trust it as a gate. (Worth a look: something else may already be suppressing across part of the tower build —symbol/get.rscalls the manualgc_suppress()/gc_unsuppress()pair at three sites, andwell_known_symbolis on this path.)What would close this
Either of:
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off) withPERRY_GC_PROTECT_FROMSPACE=1. If the quarantine faults insidebuild_generator_tower, the window is justified and the witness is its gate.GcTriggerThresholdTestGuard::make_malloc_sweep_due()arms the malloc-count trigger, whichgc_check_triggersees on everygc_mallocrather than once per arena block. If the tower's installs go throughgc_mallocat all, that gives a lever a few-hundred-object build can actually trip.Resolve the third bullet's anomaly first — if something already suppresses across these builds, the windows may be redundant rather than missing, and that is a better answer than adding them.
Refs #7217, #7196.