You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SIGSEGV from explicit gc() inside try/catch on a real workload (minimal cases all pass) #1830
A user (Andrea Giammarchi, @WebReflection) reported that a manual gc() call inside a try/catch crashes a compiled Perry program with:
Segmentation fault (core dumped) ./app
((core dumped) ⇒ Linux glibc; the report is from a Linux build.)
He found this while exploring the Weak References limitations docs, so the program was likely also exercising WeakRef / FinalizationRegistry / WeakMap and/or other real work.
What I could NOT reproduce (all pass on a clean main build, macOS arm64, v0.5.1028)
Every minimal/stress form of gc() in a try/catch runs cleanly (exit 0):
gc() in a plain try/catch
gc() inside a catch block after a real throw
200k-object allocation + gc(), including PERRY_GC_FORCE_EVACUATE=1
So bare gc() is not the root cause — it almost certainly surfaces a separate latent bug while the collector scans a specific object graph the real program built. This matches the shape of #1824 (silent SIGSEGV after a large number of sequential await fetch() + DB calls in one async invocation — not fixable by PERRY_GEN_GC=0).
Asks
@WebReflection — could you share a minimal repro (the smallest program that still segfaults on gc())? Even the surrounding shape helps: is it inside an async function, after fetch/network/DB calls, after building large structures, after registering FinalizationRegistry callbacks?
Useful diagnostics if you can capture them:
Does it still crash with PERRY_GEN_GC=0 (full mark-sweep)? With PERRY_WRITE_BARRIERS=0? With PERRY_NO_AUTO_OPTIMIZE=1?
PERRY_GC_DIAG=1 output near the crash.
The Perry version (perry --version) and a core-dump backtrace if available.
Notes
gc() is always exposed in Perry (unlike Node's --expose-gc); a crash from it is a real bug regardless of the trigger.
Why every minimal repro passed but a real workload crashed: the trigger is not gc() in a try/catch per se — it's gc() (or any GC) running in a catch after the exception propagated up from a deeper function that had pointer-typed locals. Every minimal repro here throws at the same frame as the catch, so there are no deeper frames involved.
Mechanism:js_throw unwinds with longjmp, which jumps straight back to the setjmp site and skips the epilogues of the functions being unwound past — including their codegen-emitted js_shadow_frame_pop calls. The precise-root shadow stack's frame_top is then left pointing at the orphaned (now-dead) callee frames. The shadow stack is consumed by the GC (mark phase + the copying/evacuating fast path), and on the copying path the collector reads and writes the rewritten pointer back through slot pointers that, for an orphaned frame, point into stack memory that has already been unwound and is being reused by the catch body. Whether that reused memory passes the GC's valid_ptrs classifier decides crash-vs-silently-fine — hence the load-dependent heisenbug, and why PERRY_GEN_GC=0 doesn't reliably help (the mark path scans the orphaned frames too). This matches the #1824 profile.
Fix (#1836, runtime-only, no codegen changes):js_try_push captures a shadow-stack savepoint before the protected region can push any callee frames; js_throw restores it right before the longjmp, dropping the orphaned frames before any later GC can scan them.
@WebReflection — a deep-throw-then-gc() shape should stop crashing once this lands. If you still see a crash on your program after, please reopen with the surrounding shape; thanks for the report.
Activity
proggeramlug commentedon May 26, 2026
Found the root cause and opened #1836.
Why every minimal repro passed but a real workload crashed: the trigger is not
gc()in atry/catchper se — it'sgc()(or any GC) running in acatchafter the exception propagated up from a deeper function that had pointer-typed locals. Every minimal repro here throws at the same frame as the catch, so there are no deeper frames involved.Mechanism:
js_throwunwinds withlongjmp, which jumps straight back to thesetjmpsite and skips the epilogues of the functions being unwound past — including their codegen-emittedjs_shadow_frame_popcalls. The precise-root shadow stack'sframe_topis then left pointing at the orphaned (now-dead) callee frames. The shadow stack is consumed by the GC (mark phase + the copying/evacuating fast path), and on the copying path the collector reads and writes the rewritten pointer back through slot pointers that, for an orphaned frame, point into stack memory that has already been unwound and is being reused by the catch body. Whether that reused memory passes the GC'svalid_ptrsclassifier decides crash-vs-silently-fine — hence the load-dependent heisenbug, and whyPERRY_GEN_GC=0doesn't reliably help (the mark path scans the orphaned frames too). This matches the #1824 profile.Fix (#1836, runtime-only, no codegen changes):
js_try_pushcaptures a shadow-stack savepoint before the protected region can push any callee frames;js_throwrestores it right before thelongjmp, dropping the orphaned frames before any later GC can scan them.@WebReflection — a deep-throw-then-
gc()shape should stop crashing once this lands. If you still see a crash on your program after, please reopen with the surrounding shape; thanks for the report.fix(gc): restore shadow stack on exception unwind (#1830) (#1837)
WebReflection commentedon May 27, 2026
to whom it might concern, this was the failing example:
the app compiles and works fine but if you click
increment4+ times it bails out.runtime: scoped interpreter for new Function — ajv / fast-json-string…