Repro
const keep = { nested: { deep: "leaf-string-4916" } };
gc();
console.log(keep.nested.deep.length); // garbage, e.g. 3833593936
console.log(keep.nested.deep); // [invalid utf8]
Same corruption with a class instance (class Widget { x = 1; constructor(public name: string) {} }) reading w.name after gc(). Reproduces in both auto-optimize and PERRY_NO_AUTO_OPTIMIZE=1 builds (v0.5.1155, macOS arm64).
Bisection
PERRY_CONSERVATIVE_STACK_SCAN=full → correct (prints 16 / the string).
PERRY_GEN_GC=0 (legacy full mark-sweep) → correct.
- Default (
auto scan mode) → live objects reclaimed; later field reads return dangling-pointer garbage.
So in the default auto mode (gc/roots.rs::conservative_stack_scan_decision_for maps Auto → SkipDisabled), a full collection skips the native stack scan, and module-init/top-level locals like keep are evidently not covered by the precise shadow-stack roots or the module-var scanners at the gc() callsite — the whole object graph is treated as dead. The auto skip was motivated by copied-minor eligibility ("copied-minor eligibility only depends on exact mutable roots"), but it also applies to explicit full collections where these locals are live.
Impact
Any program calling the documented explicit gc() (or any API that forces a full collection) with live state held in top-level locals can have that state reclaimed and read back as garbage — silent memory corruption, no crash.
v8.getHeapSnapshot()/writeHeapSnapshot() (#4916) force a full collection like Node does; they sidestep this by setting the per-thread set_conservative_stack_scan_override(Full) around their collect. Explicit gc() likely wants the same treatment (or shadow-stack coverage for module-init locals) — the override mechanism already exists for the GC unit tests.
Found while validating the real heap-snapshot graph for #4916.
Repro
Same corruption with a class instance (
class Widget { x = 1; constructor(public name: string) {} }) readingw.nameaftergc(). Reproduces in both auto-optimize andPERRY_NO_AUTO_OPTIMIZE=1builds (v0.5.1155, macOS arm64).Bisection
PERRY_CONSERVATIVE_STACK_SCAN=full→ correct (prints 16 / the string).PERRY_GEN_GC=0(legacy full mark-sweep) → correct.autoscan mode) → live objects reclaimed; later field reads return dangling-pointer garbage.So in the default
automode (gc/roots.rs::conservative_stack_scan_decision_formapsAuto→SkipDisabled), a full collection skips the native stack scan, and module-init/top-level locals likekeepare evidently not covered by the precise shadow-stack roots or the module-var scanners at thegc()callsite — the whole object graph is treated as dead. Theautoskip was motivated by copied-minor eligibility ("copied-minor eligibility only depends on exact mutable roots"), but it also applies to explicit full collections where these locals are live.Impact
Any program calling the documented explicit
gc()(or any API that forces a full collection) with live state held in top-level locals can have that state reclaimed and read back as garbage — silent memory corruption, no crash.v8.getHeapSnapshot()/writeHeapSnapshot()(#4916) force a full collection like Node does; they sidestep this by setting the per-threadset_conservative_stack_scan_override(Full)around their collect. Explicitgc()likely wants the same treatment (or shadow-stack coverage for module-init locals) — the override mechanism already exists for the GC unit tests.Found while validating the real heap-snapshot graph for #4916.