Repository navigation
Box cells for mutable closure captures are never released outside async state machines: every call leaks a registered GC root plus everything the captured variable points to #10464
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. When a closure captures a local that is reassigned, Perry stores the local in a malloc'd box cell registered in BOX_REGISTRY, and every registered cell is a strong GC root. Only the async-to-generator transform ever emits the release (Stmt::ReleaseBoxes, #7933/#8208). In a normal synchronous function, and in an async function with no await, the cell outlives the call permanently, together with the object graph stored in it.
Reproduction
main.ts
// Per call, a closure captures a local that is reassigned -> Perry stores the local in a box cell.
const mode = process.argv[2] ?? "payload";
const N = Number(process.argv[3] ?? 100_000);
function cell(i: number): number { // boxed capture holding a number
let x = i;
const bump = () => { x += 1; };
bump();
return x;
}
function payload(i: number): number { // boxed capture holding a 512-element array (~4 KB)
let buf: number[] = new Array(512).fill(i);
const swap = () => { buf = new Array(512).fill(i + 1); };
swap();
return buf.length;
}
function noBox(i: number): number { // control: same allocations, but the closure mutates a
const holder = { buf: new Array(512).fill(i) }; // property of a const object -> no box cell
const swap = () => { holder.buf = new Array(512).fill(i + 1); };
swap();
return holder.buf.length;
}
const run = mode === "cell" ? cell : mode === "payload" ? payload : noBox;
const rssMb = () => Math.round(process.memoryUsage().rss / 1048576);
let acc = 0;
for (let i = 1; i <= N; i++) {
acc += run(i);
if (i % (N / 5) === 0) console.log(mode, "calls", i, "rss_mb", rssMb());
}
console.log("done", acc);node main.ts payload 100000
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o app
PERRY_GC_DIAG=1 ./app payload 100000 2>diag.txt; grep box-stats diag.txt
PERRY_GC_DIAG=1 ./app nobox 100000 2>diag.txt; grep box-stats diag.txt
PERRY_GC_DIAG=1 ./app cell 1000000 2>diag.txt; grep box-stats diag.txtExpected (Node 26.5.1)
Flat memory in all three modes:
payload calls 20000 rss_mb 69
payload calls 40000 rss_mb 71
payload calls 60000 rss_mb 71
payload calls 80000 rss_mb 71
payload calls 100000 rss_mb 71
done 51200000
(nobox ends at 72 MB; cell with 1M calls ends at 71 MB.)
Actual (Perry)
payload: every call's array stays alive, about 5.3 KB per call, which is the 512-double array:
payload calls 20000 rss_mb 208
payload calls 40000 rss_mb 293
payload calls 60000 rss_mb 410
payload calls 80000 rss_mb 505
payload calls 100000 rss_mb 601
done 51200000
[box-stats] allocs=100000 pool_reuses=0 releases=0 resident_cells=100000 registry_len=100000 i32_registry_len=0 bool_registry_len=0 flushes=6 published=0
nobox control, with the same allocations and no box:
nobox calls 100000 rss_mb 69
[box-stats] allocs=0 pool_reuses=0 releases=0 resident_cells=0 registry_len=0 i32_registry_len=0 bool_registry_len=0 flushes=6 published=0
cell (1M calls): rss_mb 79 → 139 → 147 → 243 → 240, and
[box-stats] allocs=1000000 pool_reuses=0 releases=0 resident_cells=1000000 registry_len=1000000 i32_registry_len=0 bool_registry_len=0 flushes=6 published=0
The default auto-optimize build gives the same results: payload 595 MB, nobox 64 MB, cell registry_len=1000000, releases=0 everywhere.
The time cost grows with the leaked population. cell at N = 1M / 2M / 4M takes 1.56 / 2.89 / 6.84 s with max RSS 246 / 278 / 351 MB. Node takes 0.15 / 0.15 / 0.21 s at about 72 MB. The same shape with a const-object holder instead of a boxed let takes 0.17 s at 1M and 0.59 s at 4M (68 MB) under Perry.
Contrast with the async path:
async function payloadAsync(i)with the same body but noawait:releases=8,registry_len=100000; it leaks like the sync case.- The same function with
await null;inserted beforeswap():releases=600008 registry_len=0 published=600008. Those cells are released.
Impact
Found while profiling real packages. [box-stats] per workload:
| workload | box cells | releases |
|---|---|---|
qs 6.15.3 qs.stringify({ a: { b: { c: 'd' } }, list: ['1','2'], f: 'hello world', g: String(i) }), 4k warm-up + 20k calls (side-channel var $wm, $m, … captured and reassigned) |
792,358 (33/call) | 0 |
validator 13.15.35 isURL, 6k calls (from audit profiling, not re-run) |
303k (42/call) | 0 |
| dayjs 1.11.21 format/add/startOf, 20k calls (from audit profiling, not re-run) | 434k | 0 |
On a re-run of the qs row: [gc-time] wall_us=13058602 … minor_us=3675379 … share_permille=281, max RSS 513 MB. The audit measured qs stringify reaching 7.2 GiB RSS at N = 250k and dayjs 1.9 GiB (not re-run). A GC share that grows with N fits collections walking an ever-growing root set, but pinning that on box cells is inferred. The pattern (a var/let captured by an inner function that assigns it) is extremely common in hand-written and transpiled JS: memoization, lazy init, counters, self-style state.
Notes
Lifetime model, from reading crates/perry-runtime/src/box.rs and crates/perry-runtime/src/closure/box_captures.rs:
- Allocation:
js_box_alloc_bits(box.rs:691-734) takes a cell from the free pool orstd::allocs 8 bytes and inserts the address into the thread-localBOX_REGISTRY(box.rs:99). A cell is not a GC-heap object. Codegen emits the alloc per activation for each boxed local (crates/perry-codegen/src/stmt/boxed_local_init.rs:34) and stores the raw cell address in closure capture slots. - Rooting:
scan_box_roots_mut(box.rs:954-) visits the payload of every registered cell on every full trace, and in minors through the young log. The only exception is a cell in the async "drained, pending release" state. A registered cell is therefore a permanent strong root for whatever its payload references. - Validation:
is_registered_box_ptr(box.rs:1459-1472) checks capture reads against the registry, with a positive cache in front. Its cost grows with registry size. Audit profiles measured 1.28 % self time inis_registered_box_ptr(dayjs) and 7.8 % inscan_box_roots_mut(dayjs format); not re-run. - Release: it happens only through
Stmt::ReleaseBoxes→emit_release_boxes(crates/perry-codegen/src/stmt/mod.rs:654,:802) →js_box_release(box.rs:825-866). The only producer isbuild_box_release_stmts(crates/perry-transform/src/generator/box_release.rs:50), called from the async step lowering (crates/perry-transform/src/generator/lower/async_step.rs:306,:313). No pass emits it for ordinary functions, arrows, methods, or anasyncfunction that has noawait, which is presumably not lowered to a state machine (inferred fromreleases=8above). - Escaping closures: for async activations, fix(async): release and reuse a completed plain-async activation's box cells (#7933 follow-up) #8208 made release safe even when a closure escapes. Cells enter a pending state and the per-cell capture counts in
box_captures.rs(set_closure_box_capture, death pruning) publish a cell only when its last capturing closure dies (box_capture_count_reached_zero,box.rs:493-500). During a full trace a pending cell's payload is reached only through live closures (visit_closure_box_payload_slots_mut), which avoidsbox → closure → boxcycles. Synchronous frames never enter that state.
Suggested direction (inferred): emit a release at every exit of a function that allocated boxed locals, covering normal return, throw/unwind, and generator completion. It must go through the same pending/capture-count path, not the immediate js_box_release de-register used outside an activation (box.rs:843-866). Otherwise a returned closure (function counter() { let n = 0; return () => ++n; }) would read a parked cell. Loops that create per-iteration let boxes need a release per iteration, or the cells stay pending until the frame exits.
Related: #7933 (async box retention, closed), PR #8208 (async release and reuse), #8213 (Next.js async box cells per request, closed). #10463 was found in the same profiling pass but is unrelated.
Activity