Skip to content

gc(layer 3): from-space quarantine catches 55 stale dereferences across the gap suite — the instrument is in CI but aimed at one synthetic fixture #7341

Description

@proggeramlug

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=0rc=1), which confirms retired memory is genuinely being read rather
    than the mprotect being over-eager.

Proposed work

  1. Triage the minor-#0 / GC_TYPE_STRING cluster first — it is very likely a
    small number of causes behind ~60% of the catches.
  2. 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

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