Skip to content

SIGSEGV from explicit gc() inside try/catch on a real workload (minimal cases all pass) #1830

Closed

Description

@proggeramlug

Summary

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):

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

Activity

  1. proggeramlug commented on May 26, 2026

    @proggeramlug
    ContributorAuthor

    Found the root cause and opened #1836.

    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.

  2. WebReflection commented on May 27, 2026

    @WebReflection

    to whom it might concern, this was the failing example:

    import { App, Text, Button, VStack, State } from "perry/ui";
    
    const count = State(0);
    const gone = State(false);
    
    let obj = {};
    let wr = new WeakRef(obj);
    
    App({
      title: "My Counter",
      body: VStack(8, [
        Text(`Count: ${count.value}`),
        Text(`Gone: ${gone.value}`),
        Button("Increment", () => {
          count.set(count.value + 1);
          if (count.value > 3) {
            obj = null as any;
            try {
              gc();
            } catch (e) {
              count.set(e.message);
            }
          }
          else if (count.value > 4) {
            gone.set(wr.deref() ?? true);
          }
        }),
        Button("Decrement", () => count.set(count.value - 1)),
        Button("Reset", () => count.set(0)),
      ]),
    });

    the app compiles and works fine but if you click increment 4+ times it bails out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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