Summary
PERRY_GC_PROTECT_FROMSPACE catches 55 stale from-space dereferences across
the 479-test gap suite — 44 of them in programs that otherwise exit cleanly and
produce correct output. The instrument already exists, is well built, and is
already wired into CI. It is simply pointed at the wrong workload, so none of
these have ever been visible.
This is layer 3 of docs/engine-plan.md (rooting in hand-written runtime Rust),
the layer that #7280's surviving fault moved into and that no static gate can
see.
Why these were invisible
scripts/gc_instrument_smoke.sh is a good gate — it asserts both knob
directions and has a real non-vacuity check (zeal must produce strictly more
retirements than no-zeal). But it drives the instrument with:
PERRY_GC_MOVING_LOOP_POLLS=1 ... $WORK/fixture
over one synthetic fixture. Back-edge polls fire only while user JS runs, so
that configuration structurally cannot expose an unrooted pointer in runtime code
that does not re-enter user JS — which is most of the runtime. That limitation is
already documented from #7249; the smoke script predates applying it here.
Driving the same instrument by allocation-point pressure over real
programs instead:
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 \
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
./test_binary
Results
Every gap test compiled and run twice — quarantine OFF as a control, quarantine
ON — so a pre-existing crash cannot be miscounted as a catch.
measured 479
QUARANTINE-ONLY FAULTS (control ok, exp SIGBUS) 55
...of which the control exited 0 (clean) 44
faulted in BOTH arms (pre-existing) 9
no fault in either arm 415
The faults are self-diagnosing:
[gc-fromspace-protect] FAULT: signal 10 at 0x5cb232a03a9
This address is RETIRED FROM-SPACE. The evacuating minor moved or
freed the object here and the holder kept the pre-collection address.
block=0x5cb232a0000 +937 retired_bytes=1048536 retired_by_minor=#0
last-known object: user_ptr=0x5cb232a03b0 obj_type=1 size=8208
This is not 55 independent bugs
The signatures are heavily concentrated, which is the useful part:
| dimension |
distribution |
| collection |
49 of 57 are retired_by_minor=#0 — the first minor, 7 at #1, 1 at #2 |
| object type |
34 are GC_TYPE_STRING, 8 GC_TYPE_PROMISE, 4 GC_TYPE_OBJECT, 2 GC_TYPE_CLOSURE |
| size |
size=32 dominates (22) |
A stale *mut StringHeader across the first minor collection is the dominant
shape. That is consistent with the runtime exposing 218 functions returning
*mut StringHeader with nothing obliging a caller to root the result, and with
#7247 (js_regexp_new holding &str borrows into movable StringHeaders).
The minor-#0 concentration also rhymes with #7217, where the first collection
landed inside a bootstrap holding a raw object graph — worth checking whether
these share that cause before treating them as separate.
Three of the catches are tests named for GC rooting, which is a useful
sanity check that the instrument is finding the right class:
test_gap_gc_assign_string_source_rooting, test_gap_gc_pointer_publish,
test_gap_gc_process_env_cache_rooting.
Verification
test_gap_gc_pointer_publish faults 20/20 runs — deterministic, not a
schedule lottery.
- Cross-checked against
PERRY_GC_PROTECT_FROMSPACE=poison, which poisons
retired bytes without changing page protection. Several tests change
observable behaviour under poison alone (test_gap_json_advanced goes
rc=0 → rc=1), which confirms retired memory is genuinely being read rather
than the mprotect being over-eager.
Proposed work
- Triage the minor-#0 /
GC_TYPE_STRING cluster first — it is very likely a
small number of causes behind ~60% of the catches.
- Add a CI arm that runs the quarantine over the gap suite by the
allocation-point route, alongside the existing loop-polls smoke. The
instrument is already correct; only its aim needs fixing.
Full list of the 44 clean-program catches:
test_gap_6300_const_cast_assign
test_gap_6375_legacy_url_parse_format
test_gap_6386_dataview_concat_regex_fastpaths
test_gap_6812_object_write_loop_generalization
test_gap_asynchooks_3089_3090_3091_3093
test_gap_bind_replace_2840_2867
test_gap_class_forward_capture_6523
test_gap_computed_namespace_dispatch
test_gap_concise_method_explicit_this
test_gap_cp_3079plus
test_gap_crypto_cipher_3382_3381_2954
test_gap_crypto_state_2944_2945_2962
test_gap_diagchannel_3082_3084_3085_3086
test_gap_dynamic_import_init_time
test_gap_encoding_timers
test_gap_fetch_response
test_gap_field_lane_semantics
test_gap_fs_write_error_propagation
test_gap_gc_assign_string_source_rooting
test_gap_gc_pointer_publish
test_gap_gc_process_env_cache_rooting
test_gap_http2_settings
test_gap_in_operator_numeric_key
test_gap_in_operator_to_property_key
test_gap_json_advanced
test_gap_json_array_element_overflow_fields
test_gap_node_crypto_buffer
test_gap_node_fs
test_gap_node_process
test_gap_object_assign_collection
test_gap_process_misc_3045plus
test_gap_put_value_plan_cache
test_gap_readable_stream_tee_pull
test_gap_reflect_semantics2
test_gap_repsel_canonical_str_locals
test_gap_request_binary_body_5483
test_gap_stream_async_transform_tick_parity
test_gap_stream_id_band_dynamic_dispatch
test_gap_stream_livetee_tick_parity
test_gap_stream_tee_tick_parity
test_gap_string_index_get_object_receiver
test_gap_transform_stream_deferred_flush
test_gap_worker_channels_3157plus
test_gap_zlib_fs_assert_2935_2752_2971
Summary
PERRY_GC_PROTECT_FROMSPACEcatches 55 stale from-space dereferences acrossthe 479-test gap suite — 44 of them in programs that otherwise exit cleanly and
produce correct output. The instrument already exists, is well built, and is
already wired into CI. It is simply pointed at the wrong workload, so none of
these have ever been visible.
This is layer 3 of
docs/engine-plan.md(rooting in hand-written runtime Rust),the layer that #7280's surviving fault moved into and that no static gate can
see.
Why these were invisible
scripts/gc_instrument_smoke.shis a good gate — it asserts both knobdirections and has a real non-vacuity check (zeal must produce strictly more
retirements than no-zeal). But it drives the instrument with:
over one synthetic fixture. Back-edge polls fire only while user JS runs, so
that configuration structurally cannot expose an unrooted pointer in runtime code
that does not re-enter user JS — which is most of the runtime. That limitation is
already documented from #7249; the smoke script predates applying it here.
Driving the same instrument by allocation-point pressure over real
programs instead:
Results
Every gap test compiled and run twice — quarantine OFF as a control, quarantine
ON — so a pre-existing crash cannot be miscounted as a catch.
The faults are self-diagnosing:
This is not 55 independent bugs
The signatures are heavily concentrated, which is the useful part:
retired_by_minor=#0— the first minor, 7 at #1, 1 at #2GC_TYPE_STRING, 8GC_TYPE_PROMISE, 4GC_TYPE_OBJECT, 2GC_TYPE_CLOSUREsize=32dominates (22)A stale
*mut StringHeaderacross the first minor collection is the dominantshape. That is consistent with the runtime exposing 218 functions returning
*mut StringHeaderwith nothing obliging a caller to root the result, and with#7247 (
js_regexp_newholding&strborrows into movableStringHeaders).The minor-#0 concentration also rhymes with #7217, where the first collection
landed inside a bootstrap holding a raw object graph — worth checking whether
these share that cause before treating them as separate.
Three of the catches are tests named for GC rooting, which is a useful
sanity check that the instrument is finding the right class:
test_gap_gc_assign_string_source_rooting,test_gap_gc_pointer_publish,test_gap_gc_process_env_cache_rooting.Verification
test_gap_gc_pointer_publishfaults 20/20 runs — deterministic, not aschedule lottery.
PERRY_GC_PROTECT_FROMSPACE=poison, which poisonsretired bytes without changing page protection. Several tests change
observable behaviour under poison alone (
test_gap_json_advancedgoesrc=0→rc=1), which confirms retired memory is genuinely being read ratherthan the mprotect being over-eager.
Proposed work
GC_TYPE_STRINGcluster first — it is very likely asmall number of causes behind ~60% of the catches.
allocation-point route, alongside the existing loop-polls smoke. The
instrument is already correct; only its aim needs fixing.
Full list of the 44 clean-program catches:
test_gap_6300_const_cast_assigntest_gap_6375_legacy_url_parse_formattest_gap_6386_dataview_concat_regex_fastpathstest_gap_6812_object_write_loop_generalizationtest_gap_asynchooks_3089_3090_3091_3093test_gap_bind_replace_2840_2867test_gap_class_forward_capture_6523test_gap_computed_namespace_dispatchtest_gap_concise_method_explicit_thistest_gap_cp_3079plustest_gap_crypto_cipher_3382_3381_2954test_gap_crypto_state_2944_2945_2962test_gap_diagchannel_3082_3084_3085_3086test_gap_dynamic_import_init_timetest_gap_encoding_timerstest_gap_fetch_responsetest_gap_field_lane_semanticstest_gap_fs_write_error_propagationtest_gap_gc_assign_string_source_rootingtest_gap_gc_pointer_publishtest_gap_gc_process_env_cache_rootingtest_gap_http2_settingstest_gap_in_operator_numeric_keytest_gap_in_operator_to_property_keytest_gap_json_advancedtest_gap_json_array_element_overflow_fieldstest_gap_node_crypto_buffertest_gap_node_fstest_gap_node_processtest_gap_object_assign_collectiontest_gap_process_misc_3045plustest_gap_put_value_plan_cachetest_gap_readable_stream_tee_pulltest_gap_reflect_semantics2test_gap_repsel_canonical_str_localstest_gap_request_binary_body_5483test_gap_stream_async_transform_tick_paritytest_gap_stream_id_band_dynamic_dispatchtest_gap_stream_livetee_tick_paritytest_gap_stream_tee_tick_paritytest_gap_string_index_get_object_receivertest_gap_transform_stream_deferred_flushtest_gap_worker_channels_3157plustest_gap_zlib_fs_assert_2935_2752_2971