Skip to content

GarbageCollectionController: replace per-tick heap sampler with idle timer only - #35356

Merged
Jarred-Sumner merged 26 commits into
mainfrom
farm/627d5b17/gc-controller-eden-cadence
Jul 30, 2026
Merged

Jarred-Sumner merged 26 commits into
mainfrom
farm/627d5b17/gc-controller-eden-cadence

Conversation

@robobun

@robobun robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

A production report of "switched bun→node, CPU and loop-lag p95 dropped" traced to Bun's GarbageCollectionController running an eden collection roughly every 16 ms of event-loop activity regardless of allocation volume. With a non-trivial live set each of those is a multi-millisecond stop-the-world pause, so a steady-state Bun.serve at 150 rps with a ~300 MB live heap spent up to ~40% of wall time in GC at ~62 collections/s (vs ~1.4% for Node's scavenger at the same load).

BUN_JSC_logGC=true on a bare setInterval(() => alloc(~50KB), 20) shows ~128 EdenCollection over 2 s; BUN_GC_TIMER_DISABLE=1 has no effect on the count.

Cause

process_gc_timer_with_heap_size sampled vm.heap.blockBytesAllocated() + vm.heap.extraMemorySize() on every event-loop tick and re-armed the 16 ms one-shot whenever that value != prev. Both counters move on every 16 KB block allocation and every reportExtraMemory call, and the collection itself perturbs them, so the RunOnNextTick → collect_async() → != prev → re-arm 16 ms loop self-perpetuated at the 1000/16 ≈ 62.5/s ceiling for as long as the event loop was active.

Separately, init() read BUN_GC_TIMER_DISABLE / BUN_GC_TIMER_INTERVAL / BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS via vm.env_loader_opt(); at ensure_waker() time the dotenv loader exists but load_process() has not run yet, so its map is empty and the knobs were silently ignored.

Fix

Delete the 16 ms one-shot and the per-tick / per-request heap sampler entirely (the GcOneShot tag, the GCTimerState machine, request_gc_hint/drain_pending_gc_hint, and the process_gc_timer() calls from on_request_complete / auto_tick / the fetch/h2 completion paths). JSC's own allocation-rate-budgeted GCActivityCallback::didAllocate / Heap::collectIfNecessaryOrDefer is already wired onto Bun's timer heap via WTFTimer and is the primary eden driver; with the sampler gone, nothing is left to self-perpetuate.

Keep the controller's 1 s / 30 s repeating idle timer, which requests a collect_async() once per interval so a process that stops allocating still releases memory. process_gc_timer() now just arms that timer on first call.

Lower JSC::Options::largeHeapSize from 32 MB to 8 MB so JSC's proportional-heap budget engages sooner on small heaps, trimming the peak-RSS plateau the sampler used to mask.

Declare the three env-var knobs in bun_core::env_var so init() reads them via the typed, cached, process-env accessors; the dotenv-loader ordering issue does not arise on that path.

Follow-on fix exposed by the sampler removal

moduleLoaderFetch / functionFulfillModuleSync / moduleLoaderImportModule drop GCOwnedDataScope before transpile

The auto moduleKey = jsString->value(globalObject) locals were GCOwnedDataScope<const String&>, alive across fetchESMSourceCode → synchronous transpile → an async macro's wait_for_promise event-loop spin. IncrementalSweeper can fire during that spin with vm.entryScope null and trips the ASSERT(!m_topGCOwnedDataScope) in Heap::clearConcurrentRetainedDataIfPossible (x64-asan macro-test.test.ts). Take a plain WTF::String so the scope temporary dies at the statement. 5/5 pass on linux debug+asan.

Test adjustments

  • test/js/bun/gc/gc-controller-cadence.test.ts (new): setInterval(~50 KB / 20 ms) × 100 ticks, counts => EdenCollection lines from BUN_JSC_logGC. Asserts eden < 30 for the default case (was ~128) and eden < 5 with BUN_GC_TIMER_DISABLE=1 (was ~128; env var ignored). skipIf(isDebug) since debug+ASAN's ~100 ms GC cycles coalesce collectAsync() requests and the count cannot distinguish fixed from unfixed there.
  • test/js/bun/http/bun-server.test.ts: add Bun.gc(false) (= collect_async()) to the four server-wrapper-count drain loops. On release+ASAN the wrapper was only collected by the old per-request collect_async(); Bun.gc(true) + fullGC() alone do not collect it there.
  • test/regression/issue/28632.test.ts: Bun.gc(true) every 500 queries so the RSS delta measures the name_or_index leak rather than the peak MarkedBlock high-water mark between opportunistic passes.

Open item

test/js/node/test/parallel/test-fs-filehandle.js on Windows x64 release: the abandoned FileHandle is not collected by 20× sync globalThis.gc() within 200 ms. jsc.generateHeapSnapshotForDebugging() reports FileHandle with a single WeakRef[Internal] retainer and not in roots (no ConservativeScan root reason recorded for any node), so per the snapshot it is unreachable; globalThis.gc() disagrees. Passing thisValue=undefined to JSC::profiledCall in Bun__JSTimeout__call makes the Immediate wrapper itself collectible but not callback-locals or the FileHandle. A sanitizeStackForVM(*vm) at Bun__JSC_onBeforeWait makes the test pass 10/10 but was rejected in review; an #[inline(never)] split of run_immediate_task did not change the outcome. Needs a root-cause fix or the lane accepted as a known failure.

Fixes #27365
Related: #8897, #26784


no test proof · iteration 36 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-server.test.ts

@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The pull request centralizes dotenv truthiness, adds configurable and adaptive GC growth thresholds, updates GC timer transitions, and adds GC cadence and MySQL regression test coverage.

Garbage collection runtime

Layer / File(s) Summary
GC configuration and truthiness
src/dotenv/env_loader.rs, src/jsc/GarbageCollectionController.rs
Adds Loader::is_truthy; GC initialization uses typed environment lookups, parses threshold configuration, and initializes the public growth-threshold field.
Threshold-aware GC scheduling
src/jsc/GarbageCollectionController.rs
GC timer state transitions now use configured or adaptive heap-growth thresholds when deciding whether to collect or re-arm timers.
GC cadence and memory regression coverage
test/js/bun/gc/gc-controller-cadence.test.ts, test/regression/issue/28632.test.ts
Adds subprocess-based GC cadence checks and invokes Bun.gc(true) periodically in the MySQL regression workload.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the changes, but it does not use the required template headings or separate verification section. Add the required 'What does this PR do?' and 'How did you verify your code works?' sections, and summarize the validation steps used.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main GC controller change, though it omits the new growth-threshold and env-var details.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:49 PM PT - Jul 29th, 2026

@Jarred-Sumner, your commit c588b56 is building: #85609

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Severe GC performance regression with basedpyright language server #26784 - Basedpyright shows 18–20 eden GC cycles per autocomplete op (~1 every 16ms), exactly matching the over-triggering fixed here
  2. Bun uses 2-10% idle CPU with large module registry + timers (Node.js uses ~0%) #27365 - 2–10% idle CPU with large module registry + timers; constant GC wakeups on a large closure graph would be gated by the new growth threshold
  3. Serious latency issues in Bun #26139 - Latency spikes attributed to stop-the-world GC pauses on large heaps in Next.js workloads
  4. Implement a version of vm.performOpportunisticallyScheduledTasks / JSC::IncrementalSweeper integrated into Bun's event loop #8897 - Tracking issue for improving GC scheduling (IncrementalSweeper); this PR partially addresses the broken GC timer logic called out there
  5. Memory leak in Next.js SSR under bun --bun next start — JSC GC fails to reclaim heap after concurrent requests #29267 - JSC GC fails to reclaim heap under Next.js SSR load; excessive eden GC may be crowding out full GC opportunities

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #26784
Fixes #27365
Fixes #26139
Fixes #8897
Fixes #29267

🤖 Generated with Claude Code

Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated
@robobun
robobun force-pushed the farm/627d5b17/gc-controller-eden-cadence branch from 021ba29 to 09390bc Compare July 24, 2026 04:03
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts
Comment thread src/jsc/GarbageCollectionController.rs Outdated
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status at 9323008

Remaining [new] failures from build #79262 that recur across the last three runs and are plausibly tied to the cadence change, but which I cannot reproduce locally:

  • test/js/bun/http/bun-server.test.ts (handler GC tracing, x64-asan only): after=2 vs baseline=1 after 30 iterations of Bun.gc(true) + fullGC(). Passes 10/10 on local debug+ASAN and release; passes with BUN_GC_TIMER_DISABLE=1 too, so the explicit GCs in drain() are sufficient here. Only release+ASAN fails. If it recurs on the next run the likely fix is BUN_GC_TIMER_THRESHOLD: "0" in those two subprocess envs to restore the old cadence for collection timing, but I held off since the explicit fullGC() loop should already be sufficient and masking whatever release+ASAN-specific ordering is at play seems worse than understanding it.
  • test/js/node/test/parallel/test-fs-filehandle.js (Windows x64): common.mustCall for the unclosed-FileHandle uncaughtException never fires. Passes 10/10 on local windows-x64 debug. The 20-iteration globalThis.gc() retry loop should be independent of controller cadence.
  • test/bundler/transpiler/macro-test.test.ts (x64-asan): pre-existing intermittent ASSERT(!m_topGCOwnedDataScope) in Heap::clearConcurrentRetainedDataIfPossible; see e21c71b on farm/46de06ae/macro-tagged-template. Reported separately.

test/regression/issue/28632.test.ts is fixed in 0fd4f8e (periodic Bun.gc(true) every 500 queries so the RSS delta reflects the actual leak).

The cadence test itself is now skipIf(isDebug) (fbfb0e6): on debug+ASAN a collection cycle is ~100 ms so JSC coalesces the ~60/s collectAsync() requests into 2-3 actual collections, and no eden-count assertion can distinguish fixed from unfixed there. Release lanes carry the fail-before (128/129 vs 3).

@robobun
robobun force-pushed the farm/627d5b17/gc-controller-eden-cadence branch from 9323008 to 0fd4f8e Compare July 24, 2026 05:03
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Fail-before proof (for manual verification)

The cadence test is skipIf(isDebug) because on debug+ASAN each collection cycle takes ~100 ms and JSC coalesces the ~60/s collectAsync() requests into 2-3 actual collections, so no eden-count assertion can distinguish fixed from unfixed there. The release lane is where the symptom (and the assertion) lives.

Manual fail-before against any release ≤ 1.3.14:

$ /usr/local/bin/bun --revision
1.3.14+0d9b296af
$ /usr/local/bin/bun test test/js/bun/gc/gc-controller-cadence.test.ts
(fail) ... low-allocation setInterval ... Expected: < 30, Received: 129
(fail) ... BUN_GC_TIMER_DISABLE ... Expected: < 5, Received: 129

With the fix (release build at HEAD):

(pass) ... low-allocation setInterval ... [2020ms]
(pass) ... BUN_GC_TIMER_DISABLE ... [2021ms]

The automated fail-before check's release-without-fix step is running the binary the previous iteration's with-fix step built (header shows fbfb0e63d, which already carries the growth threshold), so it reports 0 failures there; the debug step correctly skips.

@robobun
robobun force-pushed the farm/627d5b17/gc-controller-eden-cadence branch from 0fd4f8e to 87ee43a Compare July 24, 2026 05:16
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/gc/gc-controller-cadence.test.ts`:
- Around line 50-55: Update the cadence helper that collects stdout, stderr, and
exitCode to return exitCode alongside eden and full instead of asserting it
internally. In each test caller, assert the GC cadence/count invariant first,
then assert the returned exitCode is 0.
- Around line 71-87: Extend the GC controller tests around countEdenCollections
to cover non-default BUN_GC_TIMER_INTERVAL and
BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS values deterministically, preserving
assertions that prove each setting is applied. Parameterize BUN_GC_TIMER_DISABLE
coverage for the requested falsy representations ("false", empty, and
quoted-empty values) alongside existing "1"/"0" cases, and assert the controller
remains enabled for each falsy input while retaining the disabled behavior for
truthy input.

In `@test/regression/issue/28632.test.ts`:
- Around line 57-62: Remove the multi-line explanatory comment above the query
loop in the regression test, leaving the test logic unchanged. Ensure any
remaining regression-test comment contains only the issue URL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 783a3991-cde8-42f5-b1ad-fcc3f2c492f6

📥 Commits

Reviewing files that changed from the base of the PR and between 50bb3bd and 1ca9df4.

📒 Files selected for processing (4)
  • src/dotenv/env_loader.rs
  • src/jsc/GarbageCollectionController.rs
  • test/js/bun/gc/gc-controller-cadence.test.ts
  • test/regression/issue/28632.test.ts

Comment thread test/js/bun/gc/gc-controller-cadence.test.ts Outdated
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts Outdated
Comment thread test/regression/issue/28632.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jsc/GarbageCollectionController.rs (2)

304-308: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Apply grew in the Scheduled branch.

This branch can call perform_gc() without the new threshold gate. For example, with prev = 16 MiB and this_heap_size = 33 MiB, the default 32 MiB threshold is not reached, but this_heap_size > prev * 2 is true. Gate this branch with grew && before performing GC.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/GarbageCollectionController.rs` around lines 304 - 308, Update the
GCTimerState::Scheduled branch in the garbage-collection controller to require
grew alongside the existing heap-growth condition before updating the repeat
timer or calling perform_gc().

273-275: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the growth threshold inclusive.

The stated contract says the timer re-arms when growth reaches the threshold, but > requires one additional byte. Preserve the threshold = 0 behavior while using an overflow-safe growth calculation, for example growth > 0 && growth >= threshold.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/GarbageCollectionController.rs` around lines 273 - 275, Update
process_gc_timer_with_heap_size so heap growth reaching the calculated threshold
re-arms the timer by using an inclusive comparison. Compute growth
overflow-safely, preserve the threshold = 0 behavior, and require positive
growth before applying the threshold check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/GarbageCollectionController.rs`:
- Around line 304-308: Update the GCTimerState::Scheduled branch in the
garbage-collection controller to require grew alongside the existing heap-growth
condition before updating the repeat timer or calling perform_gc().
- Around line 273-275: Update process_gc_timer_with_heap_size so heap growth
reaching the calculated threshold re-arms the timer by using an inclusive
comparison. Compute growth overflow-safely, preserve the threshold = 0 behavior,
and require positive growth before applying the threshold check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1becb971-5c5d-4284-8c1c-a74bbb978e73

📥 Commits

Reviewing files that changed from the base of the PR and between 1ca9df4 and dbd2821.

📒 Files selected for processing (1)
  • src/jsc/GarbageCollectionController.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found, but this changes GC pacing runtime-wide (the 32 MB / prev/4 growth gate is a heuristic tuning decision that affects every workload's RSS/CPU trade-off), and the author's own CI-status comment above lists two still-unexplained [new] failures (bun-server.test.ts on x64-asan, test-fs-filehandle.js on Windows) plausibly tied to the cadence change. Deferring to a maintainer to sign off on the constants and the remaining CI signal.

What was reviewed:

  • process_gc_timer_with_heap_size state machine — saturating_add/saturating_mul guard the arithmetic; the Pending/Scheduled paths still reach perform_gc() on > prev*2.
  • get_env fallback + Loader::is_truthy extraction — has() semantics unchanged; ZStr deref-coercion checked.
  • BUN_GC_TIMER_THRESHOLD now overrides both terms via Option::unwrap_or_else, so the cadence test's >= 5 bound is decoupled from startup heap size.
  • 28632.test.ts periodic Bun.gc(true) — keeps the 12 MB bound valid; verified it doesn't mask the original leak (500-query stride vs. per-query leak).
Extended reasoning...

Overview

The PR replaces the heap_size != prev re-arm test in GarbageCollectionController::process_gc_timer_with_heap_size with a growth threshold of max(32 MB, prev/4) (or an exact BUN_GC_TIMER_THRESHOLD override), fixes the four BUN_GC_* env knobs to fall back to bun_core::getenv_z when the dotenv loader is unpopulated at init time, extracts Loader::is_truthy so the falsy set is shared, adds a release-only cadence test, and adds periodic Bun.gc(true) to 28632.test.ts to keep its RSS bound valid under the new cadence.

Security risks

None identified. No user-controlled input reaches new parsing paths; the env-var reads use existing parse_decimal helpers with the same validation as before.

Level of scrutiny

High. This is a global runtime heuristic change: every Bun process's opportunistic-GC cadence drops from ~60/s to threshold-gated. The 32 MB floor and prev >> 2 shift are tuning constants chosen by analogy to JSC's Heap::updateAllocationLimits, but they trade CPU for peak RSS across the entire user base and already required patching one RSS-bound regression test (28632). That's exactly the kind of design decision REVIEW.md flags for maintainer agreement. The change has also produced two CI failures the author could not reproduce locally and left open (bun-server.test.ts handler-GC-tracing on x64-asan, test-fs-filehandle.js on Windows x64), which need a maintainer call on whether they're pre-existing flakes or real cadence fallout.

Other factors

All prior inline findings from earlier review passes are resolved at HEAD (single-ZStr get_env, THRESHOLD overriding both terms, 28632 periodic collect). The cadence test is skipIf(isDebug) with a documented reason (debug+ASAN coalesces collectAsync requests so counts can't distinguish fixed/unfixed); release lanes carry the fail-before proof (128 → 3). CodeRabbit's three findings were withdrawn or reasonably declined. The bug-hunting system found nothing new this run. The code itself looks correct for what it intends; the open question is whether the intended behavior (and its CI collateral) is what maintainers want.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

I independently landed on the same root cause and fix class in #35431 (now closed in favour of this one) and ran the allocation-heavy HTTP benchmark from the originating report against it, so posting the numbers here.

Allocation-heavy handler (20 small objects + JSON.stringify(~1 KB) per request, 150 MB live Map), server pinned to 4 cores, separate loadgen cores, 10 s @ 64 concurrent, linux-x64 release at 028f7a3b5, median of 3:

Node v26.3.0 main growth-gated vs main
node:http 18.8 k rps 12.6 k rps 21.4 k rps +70 %
fastify 17.7 k rps 12.0 k rps 18.0 k rps +50 %
express 12.2 k rps 8.4 k rps 13.2 k rps +57 %
Bun.serve direct n/a 17.0 k rps 31.4 k rps +85 %
eden GCs / 10 s n/a ~530 ~33

(Measured with clamp(prev/8, 1 MiB, 32 MiB); the shape is the same for max(32 MB, prev/4).)

Two design differences between the branches, for consideration:

  • max(32 MB, prev/4) has no ceiling, so at a 1 GB live set the controller waits for ~250 MB of growth between nudges. A 32 MB cap keeps the RSS delta bounded independent of live-set size. The proportional term still covers the "eden pause scales with live set" concern via the 1 s repeating timer.
  • In Pending, tracking the post-collect low-water mark (update gc_last_heap_size when this_heap_size < prev) means the threshold measures from the actual post-GC baseline rather than the pre-GC peak. Without it the effective threshold is "freed bytes + floor", which is fine but a bit conservative.

The node:http-server test in #35431 (test/js/bun/http/serve-gc-storm.test.ts) exercises the path through on_request_complete specifically; happy to fold it in here if useful alongside the setInterval one.

…timer only

The controller sampled blockBytesAllocated+extraMemorySize on every event-loop
tick and armed a 16 ms one-shot whenever that value changed at all. The
collection's own perturbation of those counters re-armed the timer, so any
active event loop ran ~62 stop-the-world eden collections per second
regardless of allocation volume.

JSC already paces eden/full collections against allocation rate via
GCActivityCallback::didAllocate / Heap::collectIfNecessaryOrDefer (bridged
onto Bun's timer heap through WTFTimer). Remove the per-tick heap sampler,
the 16 ms one-shot, GCTimerState, and the per-request process_gc_timer calls
from Bun.serve / fetch / h2; keep only the 1 s / 30 s idle repeating timer
so a process that stops allocating still releases memory.

Also lower JSC::Options::largeHeapSize from 32 MB to 8 MB so the 1.24x
growth factor engages sooner on medium-sized heaps.

BUN_GC_TIMER_DISABLE / BUN_GC_TIMER_INTERVAL were read via the dotenv loader
before load_process() had run, so they were silently ignored; fall back to
the process environment via bun_core::getenv_z. Extract Loader::is_truthy
so both Loader::has and the fallback share one definition.
@robobun robobun changed the title GarbageCollectionController: gate the 16ms one-shot on real heap growth, fix BUN_GC_TIMER_DISABLE GarbageCollectionController: replace per-tick heap sampler with idle timer only Jul 24, 2026
@robobun
robobun force-pushed the farm/627d5b17/gc-controller-eden-cadence branch from dbd2821 to 1bd336b Compare July 24, 2026 14:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/gc/gc-controller-cadence.test.ts`:
- Line 63: Run the cadence measurements sequentially by replacing
test.concurrent with test in both affected tests in
test/js/bun/gc/gc-controller-cadence.test.ts: lines 63-63 and 76-76. No other
changes are needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4fecba7-9c88-49fe-8082-3516495ab847

📥 Commits

Reviewing files that changed from the base of the PR and between dbd2821 and 1bd336b.

📒 Files selected for processing (12)
  • src/dotenv/env_loader.rs
  • src/event_loop/EventLoopTimer.rs
  • src/jsc/GarbageCollectionController.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/event_loop.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • src/runtime/dispatch.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/server/mod.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/bun/gc/gc-controller-cadence.test.ts
  • test/regression/issue/28632.test.ts
💤 Files with no reviewable changes (5)
  • src/runtime/server/mod.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/jsc/event_loop.rs
  • src/runtime/dispatch.rs

Comment thread test/js/bun/gc/gc-controller-cadence.test.ts
Comment thread src/runtime/server/mod.rs
Comment thread src/jsc/GarbageCollectionController.rs Outdated
…er-GC drain request an async collect

Review nits: the fast/slow comment block was spliced with a stray empty
doc line; convert it to a proper doc block. on_request_complete and
on_static_request_complete became byte-identical after the GC hint was
removed; delegate the static path through the dynamic one.

bun-server handler-GC-tracing tests failed on release+ASAN (reproduced
locally on first run post-build): the server wrapper is not collected by
30 iterations of Bun.gc(true)+fullGC() alone on that lane. The old
per-tick sampler's collect_async() made it collect; request one
explicitly in the drain loop so the test does not depend on controller
cadence.
@robobun
robobun force-pushed the farm/627d5b17/gc-controller-eden-cadence branch from eab7308 to b0cf7fa Compare July 24, 2026 15:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/GarbageCollectionController.rs (1)

84-101: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Prevent slow mode from becoming more aggressive than fast mode.

BUN_GC_TIMER_INTERVAL=60000 makes fast mode fire every 60 seconds, but slow mode is hard-coded to 30 seconds. After 30 unchanged ticks, the controller therefore increases GC frequency. Either cap the configured interval at 30 seconds or make slow mode use max(configured_interval, 30s). Also document fast mode as the configured interval, defaulting to one second, and add a boundary test.

Suggested cap
 if parsed > 0 {
-    gc_timer_interval = parsed;
+    gc_timer_interval = parsed.min(SLOW_REPEAT_INTERVAL_MS);
 }

Also applies to: 164-168, 179-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/GarbageCollectionController.rs` around lines 84 - 101, Update the
GarbageCollectionController logic around gc_timer_interval so slow mode uses at
least the 30-second interval, preventing it from becoming more aggressive than
fast mode; either cap the configured value at 30 seconds or derive slow mode
with the maximum of the configured interval and 30 seconds. Document that fast
mode uses the configured BUN_GC_TIMER_INTERVAL, defaulting to one second, and
add a boundary test covering the 30-second threshold.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/GarbageCollectionController.rs`:
- Around line 84-101: Update the GarbageCollectionController logic around
gc_timer_interval so slow mode uses at least the 30-second interval, preventing
it from becoming more aggressive than fast mode; either cap the configured value
at 30 seconds or derive slow mode with the maximum of the configured interval
and 30 seconds. Document that fast mode uses the configured
BUN_GC_TIMER_INTERVAL, defaulting to one second, and add a boundary test
covering the 30-second threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 16b671bb-438b-4c58-b005-c878415e61b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1bd336b and b0cf7fa.

📒 Files selected for processing (3)
  • src/jsc/GarbageCollectionController.rs
  • src/runtime/server/mod.rs
  • test/js/bun/http/bun-server.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/perf/tracy.rs:1-3 — This PR adds two new files — src/perf/tracy.rs (490 lines) and src/analytics/error.rs (29 lines) — that are unrelated to the GC controller change and are not wired into the build: neither src/perf/lib.rs nor src/analytics/lib.rs declares them as modules, and nothing in the tree references them. The PR description says "Net -50 lines across 12 files", but the diff touches 15 files and these two alone add +519 lines of orphaned uncompiled code — they look like accidentally-staged profiling scaffolding and should be dropped from this PR.

    Extended reasoning...

    What's wrong

    Two brand-new files ride along on this PR that have nothing to do with replacing the per-tick heap sampler:

    • src/perf/tracy.rs — 490 lines of Tracy profiler bindings (dlopen/dlsym libtracy, tracy_trace! / tracy_trace_named! macros, zone/frame emit wrappers, a BUN_TRACY_PATH env var, a #[cfg(test)] block).
    • src/analytics/error.rs — 29 lines defining an analytics::Error enum + Result alias.

    Neither is reachable from the crate roots:

    • src/perf/lib.rs declares only pub mod generated_perf_trace_events; and pub mod system_timer; — there is no pub mod tracy;.
    • src/analytics/lib.rs declares features, generate_header, and schema — there is no pub mod error;.
    • rg tracy src/ returns only src/perf/tracy.rs itself; rg 'analytics::error' src/ returns nothing.

    Rust ignores .rs files that aren't reachable via a mod declaration, so these 519 lines are not compiled, not type-checked, not tested — they're inert bytes in the tree.

    Why this is flagged

    The PR description explicitly scopes the change as "Net -50 lines across 12 files", but the diff touches 15 files and these two account for +519 of the discrepancy. That's a strong signal they were accidentally staged — most likely the Tracy integration was scaffolded to gather the benchmark numbers in the PR description and got swept up in git add.

    REVIEW.md → Correctness: "Every line you add must be demonstrably live." REVIEW.md → Code style & idioms: "Delete dead code in the same PR that makes it dead." These files are dead on arrival — orphaned modules with zero callers.

    Additionally, if tracy.rs were wired in with a pub mod tracy;, its #[cfg(test)] block invokes $crate::tracy_trace!() and references $crate::tracy::___tracy_source_location_data, which resolve only if tracy is a module of the invoking crate — so cargo test -p bun_perf would need the mod declaration to compile it at all. The file also reads env_var::BUN_TRACY_PATH, which doesn't exist in bun_core::env_var (grep confirms no other reference), so wiring it in as-is would likely fail to compile.

    Step-by-step proof

    1. git diff --name-only on this PR lists 15 files, including src/perf/tracy.rs and src/analytics/error.rs as new files.
    2. head src/perf/lib.rs → module declarations are pub mod generated_perf_trace_events; and pub mod system_timer; only. No mod tracy.
    3. rg '^(pub )?mod ' src/analytics/lib.rs → pub mod features, pub mod generate_header, pub mod schema. No mod error.
    4. rg tracy src/ → the only hit is src/perf/tracy.rs itself.
    5. Therefore rustc never visits either file; cargo build / bun bd succeed with or without them.
    6. The PR description's "12 files" claim vs the actual 15-file diff, with the extras being unrelated infra, confirms accidental inclusion rather than intentional scope.

    Impact

    None at runtime or build time — orphaned modules are inert. The cost is 519 lines of unreviewed, uncompiled infrastructure landing in a focused perf PR, contradicting both the PR's own scope statement and the repo's dead-code rule. Hence nit: the author will obviously want these gone, but merging them causes no crash, incorrect behavior, or regression.

    Fix

    git rm src/perf/tracy.rs src/analytics/error.rs and re-push. If the Tracy integration is intended for a follow-up, it belongs in its own PR where pub mod tracy;, the BUN_TRACY_PATH env-var declaration, and callers all land together and get reviewed as a unit.

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: the per-tick sampler was masking a latent NewServer leak at VM teardown; with only the idle-timer GC the wrapper survives to lastChanceToFinalize more often and schedule_deinit()'s enqueued task never runs. Fixed in #36750.

robobun added a commit that referenced this pull request Aug 2, 2026
…during VM shutdown

When a server's JS wrapper survives to lastChanceToFinalize
(BUN_DESTRUCT_VM_ON_EXIT=1, set by the CI runner on ASAN lanes),
finalize() -> deinit_if_we_can() -> schedule_deinit() enqueued a
ManagedTask that the now-exiting event loop never ran, and
EventLoop::deinit dropped the task box without running it. The orphaned
NewServer Box and everything it owns (static_routes, html_bundle::Route,
route path strings) leaked; the Route.server back-pointer made it a
pointer cycle so LSan reported every allocation as an indirect leak.

Always reachable but rarely seen before #35356 removed the per-tick GC
sampler, which usually collected the wrapper before global_exit().

schedule_deinit() now sets DEINIT_SCHEDULED and returns without
enqueueing when is_shutting_down(); finalize() then frees the Box
synchronously via its Box::into_raw pointer (not through a &mut self
frame, whose FnEntry protector would make the dealloc Stacked-Borrows
UB). Other callers reaching schedule_deinit past shutdown (a last
request draining inside close_all_socket_groups) only set the flag and
leave the Box, since NewApp::destroy there would free the socket group
mid-iteration.
robobun added a commit that referenced this pull request Aug 2, 2026
…during VM shutdown

When a server's JS wrapper survives to lastChanceToFinalize
(BUN_DESTRUCT_VM_ON_EXIT=1, set by the CI runner on ASAN lanes),
finalize() -> deinit_if_we_can() -> schedule_deinit() enqueued a
ManagedTask that the now-exiting event loop never ran, and
EventLoop::deinit dropped the task box without running it. The orphaned
NewServer Box and everything it owns (static_routes, html_bundle::Route,
route path strings) leaked; the Route.server back-pointer made it a
pointer cycle so LSan reported every allocation as an indirect leak.

Always reachable but rarely seen before #35356 removed the per-tick GC
sampler, which usually collected the wrapper before global_exit().

schedule_deinit() now sets DEINIT_SCHEDULED and returns without
enqueueing when is_shutting_down(); finalize() then frees the Box
synchronously via its Box::into_raw pointer (not through a &mut self
frame, whose FnEntry protector would make the dealloc Stacked-Borrows
UB). Other callers reaching schedule_deinit past shutdown (a last
request draining inside close_all_socket_groups) only set the flag and
leave the Box, since NewApp::destroy there would free the socket group
mid-iteration.
robobun added a commit that referenced this pull request Aug 2, 2026
…during VM shutdown

When a server's JS wrapper survives to lastChanceToFinalize
(BUN_DESTRUCT_VM_ON_EXIT=1, set by the CI runner on ASAN lanes),
finalize() -> deinit_if_we_can() -> schedule_deinit() enqueued a
ManagedTask that the now-exiting event loop never ran, and
EventLoop::deinit dropped the task box without running it. The orphaned
NewServer Box and everything it owns (static_routes, html_bundle::Route,
route path strings) leaked; the Route.server back-pointer made it a
pointer cycle so LSan reported every allocation as an indirect leak.

Always reachable but rarely seen before #35356 removed the per-tick GC
sampler, which usually collected the wrapper before global_exit().

schedule_deinit() now sets DEINIT_SCHEDULED and returns without
enqueueing when is_shutting_down(); finalize() then frees the Box
synchronously via its Box::into_raw pointer (not through a &mut self
frame, whose FnEntry protector would make the dealloc Stacked-Borrows
UB). Other callers reaching schedule_deinit past shutdown (a last
request draining inside close_all_socket_groups) only set the flag and
leave the Box, since NewApp::destroy there would free the socket group
mid-iteration.
Jarred-Sumner pushed a commit that referenced this pull request Aug 2, 2026
…during VM shutdown (#36750)

Fixes `test/js/bun/http/bun-serve-html-405.test.ts` going red on
x64-asan (build [87498](https://buildkite.com/bun/bun/builds/87498) and
several unrelated PR builds since ~87000).

## Repro

```
BUN_DESTRUCT_VM_ON_EXIT=1 ASAN_OPTIONS=detect_leaks=1 \
LSAN_OPTIONS=suppressions=test/leaksan.supp \
bun-debug test test/js/bun/http/bun-serve-html-405.test.ts
```

```
Indirect leak of 2104 byte(s) in 1 object(s) allocated from:
    ...
    #11 new<bun_runtime::server::NewServer<false, true>>
    #12 init<false, true> src/runtime/server/mod.rs:2009:47
    #13 bun_runtime::api::bun_object::serve src/runtime/api/BunObject.rs:1564:26
    ...
    #21 BunObject_callback_serve src/runtime/api/BunObject.rs:230:25
SUMMARY: AddressSanitizer: 2942 byte(s) leaked in 7 allocation(s).
```

10/10 without this change, 0/10 with it (local debug+ASAN).

## Cause

`using server = Bun.serve({ development: true, routes: { "/": html } })`
disposes via `stop(true)`, which makes `deinit_if_we_can()` downgrade
`js_value` to `Weak` and return. The `NewServer` Box is only freed once
the JS wrapper's `finalize()` fires and `schedule_deinit()` enqueues the
actual `deinit()` as a `ManagedTask`.

When the wrapper survives to `lastChanceToFinalize`
(`BUN_DESTRUCT_VM_ON_EXIT=1`, which the CI runner sets on ASAN lanes),
`global_exit()` has already had its last event-loop tick. The enqueued
task never runs, and `EventLoop::deinit()` drops the task box without a
cleanup (`ManagedTask::new` sets `cleanup: None`). The 2104-byte
`NewServer<false, true>` Box, its `config.static_routes` Vec, the
`html_bundle::Route` it refcounts, and the route's path strings are all
orphaned. `Route.server: Cell<Option<AnyServer>>` points back at the
server so LSan sees a pointer cycle and reports every allocation as
indirect.

The path has always existed, but before #35356 the per-tick GC sampler
usually collected the wrapper during the handful of event-loop ticks
between the test body and `global_exit()`, so `schedule_deinit()` ran
while the loop was still live. With only the 1s idle-timer GC, a single
fast test like this one reaches shutdown with the wrapper still alive
more often (about half the PR builds since the merge).

## Fix

`schedule_deinit()` now sets `DEINIT_SCHEDULED` and returns without
enqueueing when `is_shutting_down()`. `finalize()` then frees the Box
synchronously when the server has been fully drained: it unboxes via
`Box::into_raw` first so the dealloc goes through the raw owning pointer
rather than a `&mut self` frame (whose FnEntry protector would make the
dealloc Stacked-Borrows UB, same pattern as `Listener::finalize` /
`UDPSocket::finalize`). Every JSC handle on the Drop chain
(`JSPromiseStrong`, `JsRef`, `UserRouteBuilder.callback: Strong`)
funnels through `Strong::Impl::destroy`, which is a no-op past
`is_shutting_down()`, so freeing here is safe.

The inline free is gated on `TERMINATED`: `NewApp::destroy` runs
`us_socket_group_deinit`, which unlinks the socket group from the loop's
list without closing any sockets still in it. A graceful `stop()` only
closes the listener and leaves keep-alive sockets open in the group;
destroying the app there would orphan them (seen as a 280-byte
`us_poll_t` direct leak on
`vendor/elysia/test/core/before-handle-arrow.test.ts` with an earlier
revision of this PR, and a `US_ASSERT(head_sockets==NULL)` abort on the
debug build). `TERMINATED` is set only once `app.close()` has run, so
the inline free is taken for abruptly-stopped servers (what `using
server` does) and skipped for gracefully-stopped ones, which is
identical to `main`'s behaviour for them.

Other callers that can reach `schedule_deinit()` past shutdown (a last
request draining inside `close_all_socket_groups`, which runs before
`lastChanceToFinalize`) only set the flag and leave the Box, since
`NewApp::destroy` there would delete the uws socket group mid-iteration.

## Verification

Two ASAN-only subprocess tests added:
- abrupt `stop(true)` of a dev server with an HTML route under
`BUN_DESTRUCT_VM_ON_EXIT=1` + `detect_leaks=1`: fails on `main` with the
7-allocation LSan report above; passes with this change.
- graceful `stop()` of a plain server with a keep-alive client
connection: passes on both `main` and this change (asserts
`us_socket_group_deinit`'s `head_sockets==NULL` precondition, which an
earlier revision of this change violated).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/bun-serve-html-405.test.ts

<!-- robobun:evidence:end -->
robobun added a commit that referenced this pull request Aug 5, 2026
…ctors

Review of the WebKit bump surfaced two gaps:

Bun__StrongRef__new/set/delete mutate the StrongRootBlock list that the
Srb marking constraint scans, with the same API-lock requirement as
JSC's HandleSet but no assertion. Add the mirror asserts, checking the
owner VM (the block's), so a thread holding some other VM's lock still
trips them. Strong.rs's existing TLS check covers only the delete path
and only proves the thread has a VM, not the owner VM's lock; the C++
Bun::StrongRef deleter bypasses it entirely.

Neither detector had a liveness test, which is how the previous
probabilistic guard for #30185 died silently (#35356, measured in
#36952). jscInternals gains a debug-only crossThreadStrongHandleMutation
hook that violates the contract from a spawned thread, and the new test
asserts the child aborts with each guard's message. If a future WebKit
bump or binding refactor drops an assertion, the test fails instead of
the coverage silently disappearing.
Jarred-Sumner pushed a commit that referenced this pull request Sep 16, 2026
### Problem
- `node:zlib` one-shot calls got up to 2x slower for small inputs in
1.4.0: `gunzipSync` of 1 KB went from 3.1 us to 5.0 us. #35356 lowered
`largeHeapSize` to 8 MB, so these calls collect 4 times as often.
- A third of that load comes from `buffer.slice()` in `processChunkSync`
and `processCallback` (`src/js/node/zlib.ts`). The 16 KB output chunk
has no `ArrayBuffer` until the slice creates one. JSC then counts the
chunk as allocated again, and only a full collection corrects the heap
size. A 1 KB result also keeps its 16 KB chunk alive.

### Fix
- Both paths call `takeOutput`. It hands over a full chunk as it is and
copies a part of at most 16 KB with `Buffer.copyBytesFrom`. A larger
part of a custom `chunkSize` chunk, or any part with a fractional
`chunkSize`, is still a slice.
- Correct because the consumer gets the same bytes. Sync output of
several chunks still goes through `Buffer.concat`, which minizlib (used
by `tar`) replaces.
- Verified: `test/js/node/zlib/zlib.test.js` ("one-shot results"), where
main gives a 1 KB result a 16384-byte backing store. Also all of
`test/js/node/zlib/`, the 61 Node `test-zlib-*.js` files, and a minizlib
round trip. Self-reviewed: 3 concerns raised, 3 addressed (see Notes).
- Release build, main to this PR: `gunzipSync` 1 KB 4.99 to 2.97 us,
callback `gunzip` 39.0 to 34.0 us, 64 MB through gzip streams 166 to 131
ms. 120k `gunzipSync` calls: 410 eden + 136 full collections to 318 + 0.

### Background
- A typed array above 1000 bytes owns a malloc'ed vector and gets an
`ArrayBuffer` only when JS needs one (`.buffer`, `subarray`, Buffer's
`slice`). `JSArrayBufferView::slowDownAndWasteMemory` then reports the
vector to the GC again. It has a FIXME for this.
- JSC starts an eden collection when the bytes allocated since the last
one pass a budget. On a small heap that budget is `largeHeapSize`.
- `nativeDecodePullResult` in `BunStreamSource.cpp` already uses this
rule.

<details><summary>Notes</summary>

- Found from a perf ledger entry: node:zlib sync one-shot calls on 1 KB
inputs got 1.5x to 3.4x slower at 1.4.0 while `Bun.gunzipSync` got
faster. The JS wrapper and the native handle did not get slower.
`BUN_JSC_largeHeapSize=33554432` on main gives the 1.3.14 collection
counts exactly (105 eden + 34 full per 120k `gunzipSync`) and the 1.3.14
speed. The zstd rows have a second cause, fixed in #42914.
- GC-visible bytes for one `gunzipSync` call, from collections per 100k
calls: `Buffer.allocUnsafe(16384)` 16.4 KB, the slice 16.4 KB more, the
native handle 3.6 KB, the stream objects about 1 KB.
`Buffer.allocUnsafe(16384).subarray(0, 10)` alone runs 262 eden + 130
full collections per 100k, against 195 + 0 without the `subarray`. The
JSC side of this has its own follow-up. oven-sh/WebKit#273 fixes the
same double charge for transferred contents.
- All rows, ns per call, 1.3.14 / main / this PR, machine under load:
`gunzipSync` 1 KB 3056 / 4994 / 2974. `inflateSync` 1 KB 2837 / 4633 /
3450. `brotliDecompressSync` 1 KB 5308 / 8680 / 6170. `gzipSync` 1 KB
8267 / 10211 / 8347. `zstdDecompressSync` 1 KB 5021 / 15193 / 13701.
`gunzip` callback 1 KB 33154 / 38970 / 34016. `gzip` callback 1 KB 39866
/ 49531 / 45815. `gunzip` callback 1 MB 1.78 / 2.06 / 1.74 ms. 20k
flushed `createDeflate` writes 506 / 570 / 442 ms.
- A focused run of 8 alternating rounds for the sizes between the rows
above, main to this PR: `gunzipSync` 4 KB 8.12 to 7.26 us, 12 KB 12.27
to 12.38 us (CPU time 15.66 to 14.19 us), 1 MB 804 to 716 us, 600 KB
with `chunkSize: 1 << 20` 395 to 399 us (that one still takes the slice
path).
- Callback path: 30k `gunzip` calls of 1 KB run 129 eden + 43 full
collections on main and 86 + 0 with this PR.
- The 16 KB bound: with `chunkSize: 1 << 20` and 600 KB of output, a
copy of the whole part measured 5 to 10% slower than the slice and
raised peak RSS, so a large part stays a slice.
- A fractional `chunkSize` (valid in Node and in Bun) makes `offset` and
`have` fractional. `slice` truncates them and `Buffer.copyBytesFrom`
throws `ERR_OUT_OF_RANGE`, so the helper only copies integer ranges. The
new tests cover `chunkSize: 100.5` for the sync, callback, and
`_processChunk` paths.
- Self-review, all three addressed: a fractional `chunkSize` made
`Buffer.copyBytesFrom` throw (now an integer guard, with tests). The
callback path still sliced while the sync path did not (now one helper
for both, with tests for both). The code comment said a full collection
frees the chunk, when it only corrects the heap size (reworded).
- Not part of this PR: `src/js/internal/streams/iter/transform.ts`
already hands over a full chunk and copies a partial fill. It still uses
`subarray` for the tail of a chunk that it replaces, which is at most
once for each chunk.
- Node 26 returns a view into a 64 KB slab (`result.buffer.byteLength`
is 65536, `byteOffset` 16384). The size of the backing store is not a
compat contract.
- minizlib calls the sync `_processChunk` on a long-lived engine with
`Buffer.concat` replaced and the handle's `close` stubbed. It copies
`result[0]` because that one can be `_outBuffer`, which the next call
writes again. That still holds: a full first chunk is `_outBuffer`
itself, a small first part is now a fresh copy. pngjs reads `_outBuffer`
and `_outOffset` once in its constructor. Both fields keep their values
and meaning.
- `leak.test.ts` exceeds its `beforeAll` hook timeout on a debug ASAN
build with and without this change. It passes on the release build.
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 12 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/zlib/zlib.test.js
bun test v1.4.3 (c6b7fcb)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [1.98ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.92ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.15ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [2.74ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.47ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.27ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip [0.29ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.30ms]
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__proto
... (truncated)

release without fix: 2 skipped
bun test v1.4.3-canary.1 (a8e4e90)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [0.02ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [0.01ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [0.02ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__proto__
(pass) prototype and name and constructor > Deflate > Deflate.prototype.constructor should be Deflate
(pass) prototype and name and constructor > Deflate > Deflate.name should be Deflate
(pass) pr
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/zlib/zlib.test.js
bun test v1.4.3 (c6b7fcb)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [1.83ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.63ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [0.98ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.81ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.44ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.26ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip [0.27ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.31ms]
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__proto
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 786ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/142] gen JS modules (bundle-modules)
Preprocess modules (7327ms)
Bundle modules (114ms)
Postprocesss modules (21ms)
Bundle Functions (478ms)
Generate Code (52ms)

[8.00s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/141] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/node/zlib.ts            | 20 +++++++++-
 test/js/node/zlib/zlib.test.js | 83 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 101 insertions(+), 2 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                            reads  edits  tests
src/js/node/zlib.ts                 3      3     18
test/js/node/zlib/zlib.test.js      2      4     15
```

</details>

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun uses 2-10% idle CPU with large module registry + timers (Node.js uses ~0%)

3 participants