Skip to content

Microtask pump SIGSEGVs at exit after ~6k util.callbackify(fn)(cb) calls whose thenable resolves synchronously — stale pointer inside pump_protected, survives the #9445 rooting #9539

Description

@proggeramlug

Found by the #9445 fixture; reproduced on the #9445 branch (every save/restore rooted) AND on unfixed main 0b24670, Linux x86-64, no GC env knobs.

Repro

Run ~6000 util.callbackify(fn)(cb) calls where fn returns an object-literal thenable whose then allocates and resolves synchronously, then let the process exit. Every bad= line prints 0 (the callbacks themselves are fine); the crash is the exit-time microtask drain:

Program received signal SIGSEGV, Segmentation fault.
0x... in perry_runtime::promise::microtasks::pump_protected ()
#1  perry_runtime::exception::arm_trap_and_run::invoke::<…run_microtasks::{closure#2}, ()> ()
#2  perry_sjlj_try ()
#3  perry_runtime::promise::microtasks::run_microtasks ()
#4  main ()

(PERRY_KEEP_SYMBOLS=1 build.) The fault is in the pump itself, not in a callback, so a queued reaction/promise record names a moved cell. N=1500 does not crash on the fixed branch (it did on main), N=6000 and N=20000 crash on both — the number of nursery collections between enqueue and drain decides it, which is the moving-GC stale-pointer signature.

import * as util from "node:util";
import * as util from "node:util";

const N = 6000;

function churn(): number {
  const tmp: any[] = [];
  for (let k = 0; k < 480; k++) tmp.push({ k: k, s: "t" + k, pad: [k, k + 1] });
  return tmp.length;
}

function check(name: string, factory: (i: number) => any): void {
  let bad = 0;
  const notes: string[] = [];
  for (let i = 0; i < N; i++) {
    const c: any = factory(i);
    const want = "c" + i + ":480";
    let got: any;
    try {
      got = c.run();
    } catch (e: any) {
      got = "THREW:" + (e && e.message);
    }
    if (got !== want) {
      bad++;
      if (bad <= 2) notes.push("[" + i + " got=" + String(got) + "]");
    }
  }
  console.log(name + " bad=" + bad + notes.join(""));
}

function host(i: number, run: (this: any) => string): any {
  return { id: i, inner: { def: "c" + i }, run: run };
}

function userIterable(onNext: () => void): any {
  return {
    [Symbol.iterator]: function () {
      let done = false;
      return {
        next: function () {
          if (done) return { done: true, value: undefined };
          done = true;
          onNext();
          return { done: false, value: 1 };
        },
      };
    },
  };
}


check("callbackify_object_thenable", function (i) {
  return host(i, function (this: any) {
    let n = 0;
    const f = util.callbackify(function () {
      return {
        then: function (res: any, _rej: any) {
          n = churn();
          res(1);
        },
      };
    } as any);
    f(function () {});
    return this.inner.def + ":" + n;
  });
});

node 26.8 prints callbackify_object_thenable bad=0 and exits 0.

Likely the same family as #9520 (native-async promises and moving GC). Not fixed in the #9445 PR — that PR roots the implicit-this save/restore and the JSON replacer closure; this crash needs the promise/microtask side.

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