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
Scoped 2026-08-16. This issue now tracks mechanism (a) only — the async-activation box cells. The two mechanisms below have independent fixes and were blocking each other:
Closing condition here is (a): #8208 lands, and the fixture slope (RSS + [rss-diag-tables] box= over 100+ passes) is measured before/after on the #8034 route. The measurements below are kept whole because they were taken together.
Serving the pinned #8034 production App Route fixture (release layout: providers + app dylib from clean main3c95020f8, C host), one warm process's RSS climbs 160 MB -> 510-620 MB and [gc-step] post_in_use climbs 5.4 -> 39.7 MB over 100 verifier passes (2,100 requests) with no plateau — first noted in the #8040 DoD audit and #8163's 2026-08-16 comment ("it wants its own home"). This issue is that home, with the diagnosis. All numbers measured 2026-08-16 on the bench mini (M1, 8 GB), 21 requests/pass (20 concurrent GETs + 1 POST via verify.mjs), PERRY_GC_DIAG=1 (+PERRY_GC_TRACE=1 where noted). Logs: ~/perry-bench.noindex/logs-rss/ on the mini; throwaway instrumented providers (counting #[global_allocator] wrapper + per-root-source retained-BFS, runtime crate only) in ~/perry-bench.noindex/build-rss-diag/, patch in the session scratchpad — not shipped.
1. The growth is per-REQUEST (~150 KB/req), not per-connection (~0.6 KB/conn)
client
connections
RSS pass 10
RSS pass 100
slope
stock verify.mjs loop (21 fresh conns/pass)
~21/pass
301 MB
609 MB
163 KB/request
http.Agent {keepAlive, maxSockets:1}, sequential
1 total
323 MB
603 MB
148 KB/request
20 persistent sockets, concurrent
20 total
—
600 MB
same
2,100 open/close, no request completes
2,100
—
+1.3 MB total
0.62 KB/conn
2,100 partial request head then close
2,100
—
+1.4 MB total
0.65 KB/conn
One keep-alive socket reproduces the stock slope exactly, and connection churn alone is noise-level. ext-http's per-connection tracking is clean (its GC scanner exposes only 10 live pointer roots at pass ~92).
2. The retained heap is real, rooted retention — 11 full collections reclaimed 0.00 MB
post_in_use climbs 5.4 -> ~50.4 MB over the first ~50 passes, then creeps ~4 KB/cycle. Across two 140-pass runs, all 11 fulls (arena_bytes + old_gen_bytes major pacing) reclaimed nothing from the arena:
full #46: in_use 48.96 -> 48.96 MB (pause 592 ms)
full #125: in_use 50.45 -> 50.45 MB (pause 450 ms)
full #214: in_use 50.73 -> 50.75 MB (pause 551 ms)
full #259: in_use 50.87 -> 50.87 MB (pause 868 ms)
The fulls do clear the malloc-object registry (97,950 -> 400 objects at one full — almost all GC_TYPE_REGEXP headers; 12.6 MB allocated / 12.2 MB freed over a run), so RegExp churn collects fine and is not the retainer.
3. Off-heap: 100% of the RSS delta is mimalloc-owned VM, and most of it is LIVE Rust heap
The counting-allocator build decomposes Tag 240 (values at GC cycle 1 ≈ pass 2 vs cycle 33 ≈ pass 119, ~2,450 requests apart, rss-idiag-host.log):
cycle 1
cycle 33
delta
RSS
292 MB
670 MB
+378 MB
live Rust heap (exact, from the wrapper)
177.5 MB
454.2 MB
+277 MB = ~113 KB/request live
of which GC-arena blocks
25.2 MB
91.2 MB
+66 MB (plateaus with the heap)
non-arena live
152.3 MB
363.0 MB
+211 MB = ~86 KB/request
RSS − live (mimalloc committed-but-free)
115 MB
216 MB
+101 MB
The committed-but-free overlay is real but reclaimable: in a 400-pass run, when a concurrent build put the host under memory pressure, RSS fell 710 -> 405-413 MB in ~2 passes with no behavior change, then re-inflated when pressure eased — macOS MADV_FREE lag + fragmentation, the same mechanism PR #8208 measured on asyncpipe_big.
Live-histogram growth by size class (cycle 1 -> 33):
8-byte allocations: 107,666 -> 3,052,271 (0.7 -> 23.5 MB). Box cells: the runtime's box registries grew 68,284 -> 2,686,743 (+1,068 cells/request) plus i32/bool boxes (+60/request) — 94% of the 8 B growth.
Power-of-2 giant tables step up alongside: at cycle 33 single allocations of 69.2, 37.7, 19.8 MB plus 3x ~11.6 MB (8-16 MB bucket). The 69.2 MB allocation matches a hashbrownusize table at 8M-bucket capacity — the box-registry/GC-census family; not individually named by this instrument.
The ≤1 MB bucket grew +69 allocations/+70 MB ≈ exactly the +66 arena blocks.
4. Which structures retain (counted, not guessed)
Side-table growth per request (cycle 1 -> 33, all monotone, none per-connection):
table
growth/request
end count (2,478 req)
BOX_REGISTRY (+i32/bool)
~1,128 cells
2.84 M
PROXIES (proxy.rs)
4.0 entries
10,037 live, 0 revoked
descriptor side tables (props+accessors)
14
49,159
closure dynamic props (fn.x = ...)
3.9
14,742
symbol side table
3.0
7,690
streams id quarantine
bounded
11,741 roots (cap 16,384)
timers / task queue / promise contexts / ALS guards
0
0-22
Per-root-source reachability over the live heap (retained-BFS, cycle 20 ≈ pass 92, live = 36.78 MB / 548k objects; independent walk then marginal in the same order):
root source
reach (independent)
marginal
crate::r#box::scan_box_roots_mut (2.10 M slots, 291,736 live ptrs)
20.41 MB (55%)
20.41 MB
symbol side table
6.74 MB
6.28 MB
crate::proxy::scan_proxy_roots_mut
3.24 MB
2.91 MB
module globals (actual app state)
2.37 MB
1.58 MB
descriptors + closures + streams + rest
~5 MB
~3.4 MB
So the "retained requests" decompose into two mechanisms:
(a) Async-activation box cells — the dominant term in all three symptoms. Every request leaks ~1,100 8-byte box cells whose registry entries are permanent (#7933 cleared values but kept cells+entries). They account for 55% of the retained JS heap (stale values in cells the clearing pass could not touch), ~25 MB of cells + tens of MB of registry table on the Rust heap, and 96% of the minor-GC root-scan slots — which is why minor pause grows 59 ms -> ~460-550 ms over 140 passes (scan slots 261k -> 6.5M; the collector rescans every box ever allocated, every minor). In a long run this compounds: once the trigger regime tightens, the host runs ~70 collections/pass at ~0.5 s each and throughput collapses ~15x.
(b) The Proxy registry is append-only and roots its targets forever.js_proxy_new pushes into the thread-local PROXIES: Vec<Option<Box<ProxyEntry>>> (id = v.len(), no slot reuse); nothing but explicit revocation ever detaches an entry (the only slot.take() is #[cfg(test)]), and scan_proxy_roots_mut marks every entry's target+handler as strong GC roots each cycle. A Perry proxy is an id-band handle, not a heap object, so the GC has no death signal for it — in Node an unreferenced Proxy is ordinary garbage. Next creates ~4 proxies/request on this route (headers/cookies/mutableCookies/searchParams adapters; chunks/430.js alone has 34 new Proxy( sites), each pinning its request's header/cookie graph: ~10k immortal proxies by 2,500 requests, 2.9 MB retained at ~1,900, growing without bound. The symbol/descriptor side-table growth is largely downstream of (a)+(b): #8174 gave owner-keyed tables death stories, but these owners never die.
Mechanism (a), yes — fix/async-state-rss-accumulation releases + pools exactly these cells, de-registers them, and its [box-stats] gate asserts constant residue. If it works on this workload it should remove the box share of the Rust heap, the box 55% of the JS-heap retention, and the pause growth. Caveat that must be A/B'd on this fixture: its release only covers closure-unobservable locals and skips a body entirely when the escape scan is poisoned; Next's minified route code is closure-dense, so the asyncpipe numbers (releases == allocs) do not transfer automatically. Acceptance for this issue should be the fixture slope: RSS and [rss-diag-tables] box= over 100+ passes, before/after.
Mechanism (b) is NOT covered by #8208 and needs its own fix: give Proxy entries a lifetime — either back proxies with a real GC heap object (death hook drops the registry slot) or key the registry entries weakly so an unreferenced proxy id can be collected. Not a small change; the registry is load-bearing for lookup() misclassification defense.
Not covered by either: the ~100-215 MB mimalloc committed-but-free overlay (returned only under OS memory pressure). Once (a)+(b) stop the churn's live tail, purge_delay/page-retirement tuning can be evaluated separately.
Reproduction
Stock: ~/mini-batch-rss.sh <tag> 100 PERRY_GC_DIAG=1 (mini; runs verify.mjs N times against one warm host, records per-pass RSS + snapshots).
Client-mode variants (keep-alive / conn-only) + instrumented providers: ~/mini-batch-rss2.sh <tag> <stock|ka1|ka20|conn|partial> <N> [env..] with PROVIDERS=~/perry-bench.noindex/build-rss-diag and PERRY_RSS_DIAG=1 PERRY_RSS_DIAG_RETAINED_EVERY=20.
Refs: #8040 (DoD audit that surfaced this), #8037 ("do not retain a request" acceptance bullet), #8163 (separate defect, same runs), #7933 / PR #8208 (box-cell accumulation and its in-flight fix).
Serving the pinned #8034 production App Route fixture (release layout: providers + app dylib from clean
main3c95020f8, C host), one warm process's RSS climbs 160 MB -> 510-620 MB and[gc-step] post_in_useclimbs 5.4 -> 39.7 MB over 100 verifier passes (2,100 requests) with no plateau — first noted in the #8040 DoD audit and #8163's 2026-08-16 comment ("it wants its own home"). This issue is that home, with the diagnosis. All numbers measured 2026-08-16 on the bench mini (M1, 8 GB), 21 requests/pass (20 concurrent GETs + 1 POST viaverify.mjs),PERRY_GC_DIAG=1(+PERRY_GC_TRACE=1where noted). Logs:~/perry-bench.noindex/logs-rss/on the mini; throwaway instrumented providers (counting#[global_allocator]wrapper + per-root-source retained-BFS, runtime crate only) in~/perry-bench.noindex/build-rss-diag/, patch in the session scratchpad — not shipped.1. The growth is per-REQUEST (~150 KB/req), not per-connection (~0.6 KB/conn)
verify.mjsloop (21 fresh conns/pass)http.Agent {keepAlive, maxSockets:1}, sequentialOne keep-alive socket reproduces the stock slope exactly, and connection churn alone is noise-level. ext-http's per-connection tracking is clean (its GC scanner exposes only 10 live pointer roots at pass ~92).
2. The retained heap is real, rooted retention — 11 full collections reclaimed 0.00 MB
post_in_useclimbs 5.4 -> ~50.4 MB over the first ~50 passes, then creeps ~4 KB/cycle. Across two 140-pass runs, all 11 fulls (arena_bytes+old_gen_bytesmajor pacing) reclaimed nothing from the arena:The fulls do clear the malloc-object registry (97,950 -> 400 objects at one full — almost all
GC_TYPE_REGEXPheaders; 12.6 MB allocated / 12.2 MB freed over a run), so RegExp churn collects fine and is not the retainer.3. Off-heap: 100% of the RSS delta is mimalloc-owned VM, and most of it is LIVE Rust heap
vmmap --summary(one process, pass 0/10/50/100):Memory Tag 240(mimalloc,os_tag=240) resident grows 95.0 -> 225.6 -> 421.8 -> 526.3 MB; libsystem MALLOC zones stay ~0.5 MB; stack/__DATA/__PERRY_GCMAPflat.The counting-allocator build decomposes Tag 240 (values at GC cycle 1 ≈ pass 2 vs cycle 33 ≈ pass 119, ~2,450 requests apart,
rss-idiag-host.log):The committed-but-free overlay is real but reclaimable: in a 400-pass run, when a concurrent build put the host under memory pressure, RSS fell 710 -> 405-413 MB in ~2 passes with no behavior change, then re-inflated when pressure eased — macOS MADV_FREE lag + fragmentation, the same mechanism PR #8208 measured on
asyncpipe_big.Live-histogram growth by size class (cycle 1 -> 33):
hashbrownusizetable at 8M-bucket capacity — the box-registry/GC-census family; not individually named by this instrument.4. Which structures retain (counted, not guessed)
Side-table growth per request (cycle 1 -> 33, all monotone, none per-connection):
BOX_REGISTRY(+i32/bool)PROXIES(proxy.rs)fn.x = ...)Per-root-source reachability over the live heap (retained-BFS, cycle 20 ≈ pass 92, live = 36.78 MB / 548k objects; independent walk then marginal in the same order):
crate::r#box::scan_box_roots_mut(2.10 M slots, 291,736 live ptrs)crate::proxy::scan_proxy_roots_mutSo the "retained requests" decompose into two mechanisms:
(a) Async-activation box cells — the dominant term in all three symptoms. Every request leaks ~1,100 8-byte box cells whose registry entries are permanent (#7933 cleared values but kept cells+entries). They account for 55% of the retained JS heap (stale values in cells the clearing pass could not touch), ~25 MB of cells + tens of MB of registry table on the Rust heap, and 96% of the minor-GC root-scan slots — which is why minor pause grows 59 ms -> ~460-550 ms over 140 passes (scan slots 261k -> 6.5M; the collector rescans every box ever allocated, every minor). In a long run this compounds: once the trigger regime tightens, the host runs ~70 collections/pass at ~0.5 s each and throughput collapses ~15x.
(b) The Proxy registry is append-only and roots its targets forever.
js_proxy_newpushes into the thread-localPROXIES: Vec<Option<Box<ProxyEntry>>>(id = v.len(), no slot reuse); nothing but explicit revocation ever detaches an entry (the onlyslot.take()is#[cfg(test)]), andscan_proxy_roots_mutmarks every entry'starget+handleras strong GC roots each cycle. A Perry proxy is an id-band handle, not a heap object, so the GC has no death signal for it — in Node an unreferenced Proxy is ordinary garbage. Next creates ~4 proxies/request on this route (headers/cookies/mutableCookies/searchParams adapters;chunks/430.jsalone has 34new Proxy(sites), each pinning its request's header/cookie graph: ~10k immortal proxies by 2,500 requests, 2.9 MB retained at ~1,900, growing without bound. The symbol/descriptor side-table growth is largely downstream of (a)+(b): #8174 gave owner-keyed tables death stories, but these owners never die.Does PR #8208 cover this?
Mechanism (a), yes —
fix/async-state-rss-accumulationreleases + pools exactly these cells, de-registers them, and its[box-stats]gate asserts constant residue. If it works on this workload it should remove the box share of the Rust heap, the box 55% of the JS-heap retention, and the pause growth. Caveat that must be A/B'd on this fixture: its release only covers closure-unobservable locals and skips a body entirely when the escape scan is poisoned; Next's minified route code is closure-dense, so the asyncpipe numbers (releases == allocs) do not transfer automatically. Acceptance for this issue should be the fixture slope: RSS and[rss-diag-tables] box=over 100+ passes, before/after.Mechanism (b) is NOT covered by #8208 and needs its own fix: give Proxy entries a lifetime — either back proxies with a real GC heap object (death hook drops the registry slot) or key the registry entries weakly so an unreferenced proxy id can be collected. Not a small change; the registry is load-bearing for
lookup()misclassification defense.Not covered by either: the ~100-215 MB mimalloc committed-but-free overlay (returned only under OS memory pressure). Once (a)+(b) stop the churn's live tail,
purge_delay/page-retirement tuning can be evaluated separately.Reproduction
~/mini-batch-rss.sh <tag> 100 PERRY_GC_DIAG=1(mini; runsverify.mjsN times against one warm host, records per-pass RSS + snapshots).~/mini-batch-rss2.sh <tag> <stock|ka1|ka20|conn|partial> <N> [env..]withPROVIDERS=~/perry-bench.noindex/build-rss-diagandPERRY_RSS_DIAG=1 PERRY_RSS_DIAG_RETAINED_EVERY=20.Refs: #8040 (DoD audit that surfaced this), #8037 ("do not retain a request" acceptance bullet), #8163 (separate defect, same runs), #7933 / PR #8208 (box-cell accumulation and its in-flight fix).