An idle collection finishes while the JS thread is parked; the GC tick backs off under timer chatter

Merged
JA
23d ago
+195-28

What was wrong

Two things in GarbageCollectionController at an idle prompt (a TUI sitting at its input line, a server after a burst):

  1. The 1 s GC tick never backed off. It drops to its 30 s cadence after 30 ticks without heap growth, but that test had no slack, while the idle-collection logic next to it allows 2 MB for timer chatter. An application whose timers allocate a few blocks a second never left the 1 s tick: one eden request and one wakeup per second for as long as it sat there.

  2. An idle full collection could not finish while the program was parked. It is requested, not run, and a requested collection only advances at the mutator's safepoints while the mutator holds the collector's conn — which in Bun it always did, because the JS thread keeps heap access across its wait in the event loop (#21598). A parked program has no safepoints, so the controller re-armed 30 fast ticks after every idle collection to provide some; the collection still took a second or more of wall time, in a program that allocates nothing its concurrent phase never ended at all, and the eden requests those ticks made raced the sweep that decommits the dead pages a full collection leaves behind (JSC skips that when the last collection to finish was an eden one — oven-sh/WebKit#708 is the JSC half of that).

What this does

  • The back-off uses the same 2 MB slack as the idle collections.
  • While an idle collection the controller requested is unfinished (JSVMClientData::idleCollectionsPending), the JS thread gives up JSC heap access across the epoll/kqueue wait (Bun__JSC_onBeforeWait reports it through an out-parameter) and takes it back before anything runs (Bun__JSC_acquireHeapAccessAfterWait in us_loop_run_bun_tick). JSC hands the conn to the collector thread, which runs the collection while the JS thread sleeps. The request's didFinishEndPhase hook clears the flag and wakes the loop once, so the JS thread runs the epilogue (the sweep of precise allocations) right away and a burst's memory is back within the second rather than whenever the program next does something. No extra ticks, no extra requests, nothing changes while the program is busy. libuv dispatches callbacks inside its poll, so on Windows the ticks stay as they were.

Numbers

Release build, Linux x64.

mainthis PR
bun -e 'setInterval(()=>{}, 60000)', BUN_IDLE_GC_SECONDS=2: voluntary context switches over 5 s, well after the idle collection55
same: does that idle full collection ever finish?no (stays in its concurrent phase)yes
timers allocating 4 KB every 20 ms for 3 s, BUN_GC_TIMER_INTERVAL=20: collections14930
350 MB burst then parked (gc-controller-cadence "a burst's garbage is given back"): RSS 2 s after the idle collection103 MB104 MB
a TUI application, 20 scripted requests then 3 min idle at its prompt (2–3 runs each): CPU consumed between +60 s and +180 s660–780 ms370–410 ms
same: voluntary context switches in that window856–1376408–468
same: collections logged over the whole run20682
same: anonymous RSS at +30 / +60 / +180 s185–187 / 185–187 / 147–152 MB185–190 / 184–189 / 148–152 MB
same: seconds until the 10 s / 2 min idle collections show in RSS+10–11 / +121–122+10–12 / +132–135
same: first request after the idle prompt, wall / CPU (median of 7 / 9 runs; single runs swing ±150 ms on this machine)997 / 799 ms875 / 791 ms

test/js/bun/gc/gc-controller-cadence.test.ts: 18 pass on the release build (the two FTL-aging tests time out on a loaded machine on main and here alike in debug). Two new tests, both failing on main: "the tick backs off while timers allocate a trickle" (80–149 collections vs < 55) and "an idle collection finishes while the program is parked" (150 MB live heap, parked, no courtesy safepoints: on main the idle collection's log has a START and no END).

Notes for review

  • The heap-access release only happens on a park that will actually idle (will_idle_inside_event_loop) and only while such a request is pending — a few parks per idle period; everything else keeps today's "hold access, stopIfNecessary() for a few polls after JS ran" policy.
  • Taking access back can run a finished collection's epilogue between the wait and dispatch; current_ready_poll is now reset right after the wait so anything that stops a poll of the fresh batch scrubs it.
  • With the conn handed over, the collection's stop-the-world phases — including the end phase (sweepArrayBuffers, weak-reference reconciliation, deleteUnmarkedCompiledCode, finalizeUnconditionally) — run on the collector thread while the JS thread is parked or blocked in acquireAccess(). That is JSC's contract (and was routine in Bun before #21598). Two places that had come to assume the JS thread are handled here: Error stack formatting from ErrorInstance::finalizeUnconditionally looked up the global through a thread-local (defaultGlobalObject()), which is null on the collector thread and silently skipped source-map remapping — it now resolves the VM's default global from JSVMClientData (defaultGlobalObject(VM&)); and bun:ffi's toArrayBuffer/toBuffer deallocator callbacks, which follow JSC's JSTypedArrayBytesDeallocator contract, are now documented as possibly running on a GC thread. NAPI external-buffer finalizers already route through the VM handle when off-thread. Cell destructors still run at sweep, on the JS thread.
  • On the pre-#21598 hang (acquire under JSC's SIGPWR thread suspension while nothing held access): the epoll wait is epoll_pwait2 with an empty signal mask and EINTR retry, so the suspend signal is delivered while parked; the top-level-await-on-stdin shape (prompt waits under TLA, idle collection runs on the collector thread, input arrives) completes normally in testing. Release happens only while an idle collection is pending, so nothing is added to the per-park path of a busy server.
  • In the TUI runs the 2-minute collection's memory shows up ~10 s later than on main (the 10-second one is on time; a standalone burst-then-park script returns its memory as fast as main). End state is identical; I have not pinned down where those seconds go — most likely the coarser quiet accounting once the tick is slow — and left it rather than add machinery for it.
  • A full collection that was already queued ahead of the idle request and ends on the collector thread has no wake of its own; its epilogue then waits for the next timer tick (1 s, or 30 s once slow) before the idle request proceeds. JSC's StopIfNecessaryTimer would cover that but is disabled in Bun outside --smol.