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.
Found by the #9445 fixture; reproduced on the #9445 branch (every save/restore rooted) AND on unfixed
main0b24670, Linux x86-64, no GC env knobs.Repro
Run ~6000
util.callbackify(fn)(cb)calls wherefnreturns an object-literal thenable whosethenallocates and resolves synchronously, then let the process exit. Everybad=line prints 0 (the callbacks themselves are fine); the crash is the exit-time microtask drain:(
PERRY_KEEP_SYMBOLS=1build.) 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 onmain), 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.node 26.8 prints
callbackify_object_thenable bad=0and 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-
thissave/restore and the JSON replacer closure; this crash needs the promise/microtask side.