Skip to content

Return freed memory to the OS on a background thread instead of the JS thread - #34181

Merged
Jarred-Sumner merged 14 commits into
mainfrom
claude/idle-theap-handoff
Jul 16, 2026
Merged

Return freed memory to the OS on a background thread instead of the JS thread#34181
Jarred-Sumner merged 14 commits into
mainfrom
claude/idle-theap-handoff

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Bun now returns freed memory to the operating system faster, and off the main thread.

When your JavaScript frees objects, that memory doesn't go back to the OS immediately — the allocator (mimalloc) has to walk its internal free lists and tell the kernel which pages it no longer needs (via madvise). Until now, Bun did that walk on the JavaScript thread, right before it went idle to wait for I/O. Two problems with that:

  1. Your JS thread paid the cost. On workloads that constantly wake up (an agent loop over fetch, a chatty dev server), it barely got the chance to do it at all — so memory piled up.
  2. A busy server almost never went "idle enough" to trigger it, so its memory was never given back.

This PR moves that work to mimalloc's background scavenger thread. Right before Bun's event loop blocks waiting for I/O, the JS thread hands its heaps to the scavenger and says "I won't touch these until I wake up." The scavenger does the cleanup while your thread is asleep in the kernel — in parallel, at no cost to your code. When Bun wakes up, it takes the heaps back. If the scavenger is mid-cleanup at that moment, it stops immediately (one page's worth of work), so waking up is never blocked on it.

The upshot: memory comes back faster and more often, your JS thread stops doing allocator housekeeping, and — because the cleanup now runs continuously in the background — busy servers finally get cleaned up too. The trade is a little extra background CPU and a small throughput cost on the very fastest, most synthetic serving path (details below).

Pairs with oven-sh/mimalloc#8, which adds the underlying mi_on_thread_idle_start() / mi_on_thread_idle_end() API to Bun's mimalloc fork.


Benchmarks

Linux x64, 64-core, idle machine. Each row compares three binaries:

  • 1.3.14 — the last release
  • canary — current main
  • this PR

Δ is this PR vs canary. Memory is peak RSS. Full harness: bun-perf-tester.

One note on reading the RSS column. PR builds are missing a linker size optimization (the symbol orderfile) that release/canary builds have, which makes every PR-build binary read ~6.5 MB higher than it "really" is. You can see it directly: hello-world, which this PR cannot possibly affect, reads +6.5 MB. So mentally subtract ~6.5 MB from every RSS number in the "this PR" column. The wins below are real; the small "+" rows on the servers are just this artifact.

🧠 Memory: back to the OS faster — the point of this PR

Peak RSS in MB. Lower is better.

Workload 1.3.14 canary this PR Δ vs canary ≈ true Δ¹
next dev (create-next, lint) 391 317 275 −41 MB ≈ −48 MB
Vite dev server under load 297 232 200 −32 MB ≈ −39 MB
elysia test suite 329 302 −27 MB ≈ −33 MB
Vite project lint (eslint) 366 212 187 −26 MB ≈ −32 MB
Vue project lint (oxc + eslint) 241 219 −22 MB ≈ −28 MB
next start serving requests 373 245 228 −17 MB ≈ −23 MB
Vue project build (vite) 306 295 −11 MB ≈ −18 MB
startup, hello world (control) 32 24 30 +6.5 MB ≈ 0 — this is the artifact

¹ after subtracting the ~6.5 MB orderfile artifact measured on the control row.

The biggest wins are exactly where the old design failed: long-lived processes with lots of allocation churn that rarely sat fully idle (dev servers, next, linting large trees). That's the same shape as an agent loop.

⚡ Throughput: parity where you have a JS handler, one measured cost where you don't

Requests/sec (higher is better) and mean latency. 1M requests per run except where noted.

Server rps: canary rps: this PR Δ mean latency (can → PR)
node:http 70,126 70,691 +0.8% 1.80 → 1.78 ms
Fastify 65,977 65,546 −0.7% 1.91 → 1.91 ms
Express 48,827 48,079 −1.5% 2.57 → 2.60 ms
Elysia 101,053 99,421 −1.6% 1.24 → 1.27 ms
Bun.serve with a fetch() handler 100,906 100,622 −0.3% 1.25 → 1.25 ms
next start (100k reqs) 5,175 5,255 +1.5% 24.21 → 23.79 ms
Vite dev server (20k reqs) 7,627 7,697 +0.9% 16.75 → 16.57 ms
Vite preview (100k reqs) 24,833 25,243 +1.7% 5.11 → 5.11 ms
Bun.serve static route (no JS at all) 129,476 123,932 −4.3% 0.97 → 1.00 ms

Every server that runs JavaScript is within ±2% — a wash. Latency is unchanged across the board.

The one real cost is the static route: a URL served with no JS handler at all, straight from native code. It's the fastest thing Bun can do, so it's the one place where the small per-tick cost of the handoff isn't hidden behind any actual work. It measured −4 to −6% across runs. Real apps don't serve their hot paths this way (there's no JS to run), and it's a deliberate, accepted trade for the memory above — but it's the honest number, so it's here.

⚙️ CPU: where the work went

You'd expect this to raise CPU slightly — the same cleanup work still happens, just on another thread, and it now happens more often. That's exactly what shows up where the mechanism is exposed:

Workload kernel (sys) time: canary → PR what it means
Bun.serve static route 6.25 → 6.37 s the extra madvise work you're paying for — matches the −4% rps
Bun.serve fetch, Express, Fastify, node:http flat no change on real servers
Vite dev server 1.06 → 0.91 s less CPU and −32 MB — reclaiming keeps the heap smaller and cheaper to walk
next lint 0.35 → 0.27 s same

So the CPU cost is small, appears only where there's nothing else going on, and on the memory-heavy workloads the total actually goes down.

📝 Observed, not claimed

CLI wall-clock time (eslint, prettier, tsc) was 10–30% faster on this PR than on canary in both runs, with tight variance. We are not claiming this as a benefit of this PR — those processes exit quickly and the mechanism here shouldn't produce a user-time speedup that large, and canary is ~23 commits ahead of this branch's base, so the comparison isn't isolated. Recorded here for honesty; it needs a same-base build to attribute.


How it works (for the curious)

Two new calls in the event loop, bracketing the epoll / kqueue wait:

mi_on_thread_idle_start();   // hand heaps to the scavenger, wake it, return immediately
epoll_pwait2(...);           // block waiting for I/O — scavenger cleans up meanwhile
mi_on_thread_idle_end();     // take heaps back; scavenger stops at its next page if mid-sweep

Guarantees the design relies on, all upheld in the mimalloc side (oven-sh/mimalloc#8):

  • The JS thread does not allocate between those two calls. It's only in the kernel waiting on epoll, and JS signal handlers (process.on('SIGINT', …)) are deferred to the event loop rather than run in the signal — so no allocation can sneak into the window.
  • Waking up is bounded: an owner never waits for a whole sweep, only for the scavenger to reach its next page or phase.
  • fork() and thread exit while a sweep is in flight are both handled — the thread reclaims its heaps first, so a child process never inherits a half-modified heap.
  • Windows keeps the previous inline behaviour (the libuv loop has no equivalent handoff point yet).

Testing

  • Bun's test suite passes, including its allocation/concurrency stress tests.
  • The mimalloc change ships with its own test suite (test-park-handoff) covering the handoff, cross-thread free, fork while parked, and thread exit while a sweep is running — checking not just for crashes but that live memory is byte-for-byte intact afterwards. It runs clean under Debug, Release, ThreadSanitizer, and Release + AddressSanitizer.

… it parks

The idle sweep punches free-block holes out of pages that are still in use, and it is
almost entirely madvise: on a 100MB churn ~99% of its cost is the syscalls. It ran inline
from Bun__JSC_onBeforeWait, so the JS thread paid all of it right before blocking.

mimalloc can now be told a thread is about to block and is not going to touch its heaps
until it wakes, which is the only condition the sweep actually needs. Hand the heaps over
across the poll and the scavenger does the syscalls while we sit in the kernel. Measured
in isolation the owner goes from 20.0ms to 0.005ms per park for the same work.

The handoff goes after Bun__JSC_onBeforeWait, which allocates, and before dispatch, which
allocates: nothing between it and the matching take-back may touch the heaps.

Windows still runs the libuv loop, which has no handoff, so it keeps sweeping inline.

Pins mimalloc to oven-sh/mimalloc#8.
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator
Updated 7:25 PM PT - Jul 15th, 2026

@Jarred-Sumner, your commit d940463 is building: #73585

The handoff is a compare-and-swap, so there is nothing to gate it on: mimalloc paces the
sweep itself (purge_holes_min_interval), which is where that policy belongs now that the
work is not on this thread. Drops the inherited 100ms timer and the will-idle gate.

mimalloc.h declares both entry points, so the local externs are gone.

Bumps mimalloc for the teardown, fork and subproc fixes in oven-sh/mimalloc#8.
mi_on_thread_idle_start now reports whether it handed the heaps over instead of quietly sweeping
inline when it could not. Without a scavenger the loop keeps what it did before: sweep on the JS
thread, but only on a tick that really parks, and no more than every 100ms -- sweeping between
ticks is the cost this is avoiding.

Bumps mimalloc for that and for the reclaim and fork fixes in oven-sh/mimalloc#8.
… moves

Nothing returns them under sustained load, and no arena-side knob can: the pages
sit in the theap's page queues until someone collects the theap, and until then
the arena scavenger cannot see them. A REPL replay grew +229MiB inside the arena
VMA while madvise flowed healthily for other memory, and purge_delay 1000/100/10
all climbed in lockstep -- the memory had never reached the arena.

JSC's own hook cannot stand in. `Heap::didFinishCollection` fires
`scavengeThisThread` -> `mi_theap_collect`, but it runs under whichever
GCConductor holds the conn, and `collectInCollectorThread` conducts async
collections -- `mi_theap_get_default()` there returns the collector's near-empty
theap. It is also gated on CollectionScope::Full, and sustained load runs eden
for minutes. Forcing a synchronous collection every second cured the ratchet
precisely because a sync collection is conducted by the mutator; that was the
tell.

The GC controller runs on the JS thread by construction and already knows when
the heap moved, so do it here. `mi_theap_collect` walks the page queues and frees
the empty pages, which schedules the arena purge and wakes the scavenger to
madvise off this thread; it does not scan free lists for holes, which is the
expensive part that cost vite-preview 12.9% in #34009.

libpas needed no hook here at all: its scavenger polled at ~10Hz and shrank
thread caches autonomously.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Reverted my mi_theap_collect hook (c8abd3ed96a908c8ab2a). A fan-out of independent agents refuted the mechanism it was built on, and the root premise turns out to be a misread.

The load-bearing premise was false. The chain was: heapStats() cures the repl-turn ratchet → because it forces a sync full GC → which runs didFinishCollection on the mutator → so the bug is scavengeThisThread collecting the collector thread's theap. But heapStats is functionMemoryUsageStatistics (BunJSCModule.h:231):

if (vm.heap.size() == 0) vm.heap.collectNow(...);

It collects only when the heap is already empty — on a live process it never collects at all. So whatever cures the bench, it is not a forced collection, and it is not evidence for the thread-affinity or CollectionScope::Full theories.

Every other clause fell independently:

claim verdict
mi_theap_collect is the only thing returning pages to the arena false_mi_page_retire_mi_page_free → arena on every local free that empties a page; mi_collect moved RSS 0 kB across 6 runs
starving mi_on_thread_idle traps the memory false — purge_calls driven to 0 across 40 turns, starved arm 6MB lower; another agent got 58–75 calls over 2000 turns and RSS sat flat
PTY traffic starves the gate false and backwards — Terminal.rs:991 dups the master fd for independent epoll registration, so the PTY is polled on the JS thread and cannot touch pending_wakeups; 14MB/s of PTY traffic raised arena_purges 4×
JSC retains MarkedBlocks until a full GC falseIncrementalSweeper::sweepNextBlock frees them on the timer

And the finding that reframes everything: a plain sync churn loop ratchets 74→125MB over 60 turns with heapSize flat — and libpas ratchets identically (78→127, indistinguishable at 150 turns). That shape is JSC heapCapacity growth under async-GC lag, allocator-independent. Any repro without a libpas control on the identical script is worthless; at least one agent would have shipped a false positive without it.

One real thing did reproduce in C: ~50MB stranded in un-full pages whose remaining blocks are freed cross-thread, recoverable only by mi_theap_collect. But it's a bounded plateau (flat at 15488 kB ±60 kB over 20 turns), not a monotone climb — it cannot produce 256→582MB.

Next action (from the synthesis, and it's cheap): A/B the probe on the real bench four ways — no probe / per-second heapStats / per-second eden-only Bun.gc(false) / per-second heapStats on 1.3.14. If eden-only cures it, the theap story is dead. If only heapStats cures it, the cure is stopAllocating() (which objectTypeCounts()HeapIterationScope actually does — it drains every LocalAllocator's FreeList back into its MarkedBlock) and the fix is idle stopAllocating/resumeAllocating, not mi_theap_collect. If the libpas arm also cures, this was never a mimalloc bug.

Jarred-Sumner and others added 7 commits July 14, 2026 19:23
Sampling `bun:jsc` heapStats() once a second cures the sustained-load memory
ratchet on the repl replay (plateaus at 393MB against 563-584 un-instrumented).
The reason is not a forced GC, which is what the earlier analysis assumed and
built on: heapStats' `collectNow` is guarded by `if (vm.heap.size() == 0)` and
never fires on a live process. What it actually does is call `mi_collect(false)`
unconditionally (BunJSCModule.h), and `mi_collect` is exactly
`mi_theap_collect(_mi_theap_default(), force)`.

So the cure is a theap collect on the JS thread, obtained by accident from a
statistics call. Do it deliberately.

`mi_theap_collect` walks the theap's page queues and frees the empty pages, which
schedules the arena purge and wakes the scavenger to madvise off this thread. It
does not scan free lists for holes -- that is `purge_holes`, the expensive one
that cost vite-preview 12.9% in #34009.

Gated on the heap having SHRUNK, not merely moved: `process_gc_timer` is also
called from `Server::on_request_complete`, and a busy server's heap is almost
always moving, so `!=` fired the page walk on essentially every request -- -4.5%
rps on fastify (66.1k -> 63.1k, n=6) buying nothing, since no collection had run.
A shrink means a collection actually reclaimed and pages may now be empty.
…never parks

`mi_theap_collect` is the only demonstrated cure for the sustained-load memory
ratchet. Sampling `bun:jsc` heapStats() once a second fixes it, and the reason is
not a forced GC as the earlier analysis assumed: heapStats' `collectNow` is
guarded by `if (vm.heap.size() == 0)` and never fires on a live process. What it
does unconditionally is `mi_collect(false)` (BunJSCModule.h), and `mi_collect` is
exactly `mi_theap_collect(_mi_theap_default(), force)`. On the replay workload
that call moves ~170MB. So do it deliberately instead of as a side effect of
asking for statistics.

Two gates. A loop that parks already gets its theap swept at the park, so doing
it here too is pure cost -- an earlier revision that ignored this measured -4.5%
rps on fastify (66.1k -> 63.1k, n=6) for no reclaim. And a collection has to have
actually finished, which is the one thing the controller could not previously
tell: `perform_gc` merely requests one via `collect_async`. That also bounds this
to once per collection.

`GCCycleObserver` lives on `JSVMClientData`, not in a process global: every worker
has its own heap and theap, and a shared counter would let one worker's collection
convince another to walk its page queues. It only counts --
`Heap::didFinishCollection` runs under whichever GCConductor holds the conn, and
`collectInCollectorThread` conducts async collections, so the work has to happen
on the JS thread where `mi_theap_get_default()` returns the right theap. The
observer is removed in `~JSVMClientData`, which is safe because `~VM` deletes
clientData in its body, before the `Heap` member is destroyed.

The hook goes in `WTFTimer::fire`, not `run`: `update` only publishes to
`imminent_gc_timer` for a delay <= 0, and `GCActivityCallback::didAllocate`
schedules with a positive delay, so JSC's GC callbacks come through the timer heap.

Not yet validated against the workload it targets -- nothing reproduces the
ratchet outside the repl replay harness.
Benchmark numbers and the history of earlier revisions belong in the commit
message, which has them.
The premise was that `mi_theap_collect` on the JS thread is the cure, since
sampling heapStats() once a second fixes the ratchet and its one relevant action
is `mi_collect(false)` == `mi_theap_collect(default theap)`.

Measured against the real 100-turn replay, it is not. The park path already runs
that exact call every 100ms -- `mi_on_thread_idle` is `mi_theap_collect(theap0,
false)` plus a hole punch -- and the footprint still climbs 264 -> 369MB. Forcing
the park sweep to fire on every park (BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS=1e6)
changes it to +101MB from +105MB. This hook measured +108MB. All three are the
same number.

So the theap collect is already happening at 10Hz and is not what heapStats does
differently. What is left is `objectTypeCounts()` ->  `HeapIterationScope` ->
`MarkedSpace::stopAllocating()`, which heapStats calls unconditionally and
nothing else on the JS thread does.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/idle-theap-handoff branch from 376f25a to 3d716a2 Compare July 16, 2026 01:10
@Jarred-Sumner Jarred-Sumner changed the title usockets: sweep mimalloc's heaps while the loop is parked, not before it parks Return freed memory to the OS on a background thread instead of the JS thread Jul 16, 2026
Picks up oven-sh/mimalloc#8 (theap: let a parked thread hand its heaps to
the scavenger), now merged into the fork's bun-dev3-v2 branch alongside the
upstream/dev3 sync.

No-Verification-Needed: dependency pin bump only, no product source
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/idle-theap-handoff branch from 3d716a2 to d940463 Compare July 16, 2026 02:25
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review July 16, 2026 02:36
@Jarred-Sumner
Jarred-Sumner merged commit c4d6713 into main Jul 16, 2026
44 of 62 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/idle-theap-handoff branch July 16, 2026 02:37
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 605b557c-b3c1-4780-9899-8cebab042fdf

📥 Commits

Reviewing files that changed from the base of the PR and between d93b4cc and d940463.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • scripts/build/deps/mimalloc.ts
  • src/jsc/bindings/BunJSCEventLoop.cpp

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

Changes

Mimalloc idle handling

Layer / File(s) Summary
Event-loop idle handoff and scavenging
packages/bun-usockets/src/eventing/epoll_kqueue.c, scripts/build/deps/mimalloc.ts
The tick loop adds mimalloc idle start/end calls and rate-limited scavenging, while the pinned mimalloc revision is updated.
Windows JSC idle-sweep guard
src/jsc/bindings/BunJSCEventLoop.cpp
Idle-sweep bookkeeping is limited to Windows MIMALLOC builds, removing the non-Windows timestamp fallback.

Possibly related PRs

  • oven-sh/bun#34009: Updates the mimalloc commit pin while changing related WebKit mimalloc build selection.
  • oven-sh/bun#34217: Also updates the pinned mimalloc upstream commit.

Suggested reviewers: robobun


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

Comment on lines +85 to +92
#if USE(MIMALLOC) && OS(WINDOWS)
// Collect retired pages, punch free-block holes, hand the arena purge to
// the scavenger. Rate-limited; nowNs is the tick's shared reading (0 = take
// one), compared by addition so an out-of-order reading cannot underflow.
//
// Windows only: everywhere else `us_loop_run_bun_tick` hands the heaps to the
// scavenger across the poll instead, so this thread never does the sweep itself.
// The libuv loop has no handoff yet, so it keeps paying for it here.

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.

🟡 Gating this block to OS(WINDOWS) and deleting the if (nowNs == 0) nowNs = us_internal_monotonic_ns(); fallback leaves stale artifacts: the comment's "(0 = take one)" clause now describes code that no longer exists (Windows always passes a reading, and if it passed 0 the sweep would be skipped, not "taken"), and the us_internal_monotonic_ns extern + its 3-line comment at the top of the file is now unreferenced (as is mi_on_thread_idle on non-Windows, and the nowNs parameter itself). Per the landing rules, dead code should be deleted in the same PR that makes it dead — drop the parenthetical, remove the #if !OS(WINDOWS) extern block, and gate the mi_on_thread_idle extern to OS(WINDOWS).

Extended reasoning...

What's stale

This PR made two changes to the #if USE(MIMALLOC) block in Bun__JSC_onBeforeWait:

  1. Tightened the guard from #if USE(MIMALLOC) to #if USE(MIMALLOC) && OS(WINDOWS).
  2. Deleted the fallback that used to live inside it:
    #if !OS(WINDOWS)
    if (nowNs == 0)
        nowNs = us_internal_monotonic_ns();
    #endif

Two pieces of surrounding text/code described that fallback and were not updated.

(a) The "(0 = take one)" comment clause

The comment above the rate-limit check still reads:

Rate-limited; nowNs is the tick's shared reading (0 = take one), compared by addition so an out-of-order reading cannot underflow.

"0 = take one" was accurate before this PR: on POSIX, nowNs == 0 triggered nowNs = us_internal_monotonic_ns(). That line is now deleted, and the block only compiles on Windows — where the fallback never existed in the first place (the extern comment at the top of the file explicitly says "Windows always passes a reading, so it needs no fallback", and libuv.c:226 confirms it: Bun__JSC_onBeforeWait(loop->data.jsc_vm, (uint64_t) uv_now(loop->uv_loop) * 1000000ULL)).

Step-by-step: if Windows ever did pass nowNs = 0, the check would be 0 >= lastIdleSweepNs + 100000000. With lastIdleSweepNs starting at 0, that's 0 >= 100000000 → false → sweep skipped. It would not "take one". The parenthetical now describes behavior that exists nowhere in the codebase. The PR touched this exact comment (added the "Windows only:" paragraph right below it), so this isn't pre-existing drift — the stale clause was left in a block the author edited.

(b) Dead extern declarations at the top of the file

#if USE(MIMALLOC)
extern "C" void mi_on_thread_idle(void) noexcept;
#if !OS(WINDOWS)
// uSockets' CLOCK_MONOTONIC reading (packages/bun-usockets/src/loop.c). Must be
// the same clock the caller's `nowNs` came from, or the rate limit below
// compares two epochs. Windows always passes a reading, so it needs no fallback.
extern "C" uint64_t us_internal_monotonic_ns(void);
#endif
#endif
  • us_internal_monotonic_ns is guarded #if !OS(WINDOWS). Its only call site in this TU was the deleted fallback. The declaration and its 3-line explanatory comment are now completely unreferenced — the symbol is never mentioned again in this file.
  • mi_on_thread_idle is declared under #if USE(MIMALLOC) (all platforms) but is now only called under #if USE(MIMALLOC) && OS(WINDOWS), so on Linux/macOS it's an unused extern declaration.
  • The nowNs function parameter is now unread on non-Windows builds.

Impact

None at runtime. Unused extern declarations emit no code and create no link dependency; a stale comment doesn't execute. This is purely about the repo's landing rule (CLAUDE.md → "Delete dead code in the same PR that makes it dead (required scope)" and "Comments carry only durable non-obvious content") — the PR itself made these dead, so cleanup belongs in the same change.

Fix

  • Drop "(0 = take one)" from the comment (e.g. "Rate-limited; nowNs is the tick's shared reading, compared by addition so an out-of-order reading cannot underflow.").
  • Delete the #if !OS(WINDOWS) extern block for us_internal_monotonic_ns entirely (declaration + 3-line comment).
  • Tighten the mi_on_thread_idle extern guard to #if USE(MIMALLOC) && OS(WINDOWS) to match its only remaining call site.
  • Optionally (void)nowNs; on non-Windows if the build treats unused parameters as errors here.

hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (57 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (70 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (52 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...
hughescr added a commit to hughescr/bun that referenced this pull request Jul 16, 2026
* upstream/main: (52 commits)
  node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488)
  expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266)
  lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253)
  Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289)
  test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297)
  worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278)
  buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273)
  fs.promises.watch: yield events with a null prototype (oven-sh#34279)
  child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268)
  Fix asString assertion when passing String objects as signals (oven-sh#34265)
  Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274)
  test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294)
  tty: track raw mode per handle instead of per process (oven-sh#33527)
  test: expect the bumped mimalloc SHA in process.versions
  Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181)
  Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131)
  test: update block-scoped enum lowering expectations to let (oven-sh#34287)
  Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259)
  js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246)
  js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245)
  ...

# Conflicts:
#	test/js/bun/websocket/websocket-server.test.ts
Jarred-Sumner pushed a commit that referenced this pull request Jul 16, 2026
… commit (#34335)

Fixes a build break on main when `vendor/mimalloc` is fetched fresh.

## Repro

```
rm -rf vendor/mimalloc && bun bd
```
```
[mimalloc] applying strnlen-oob-read.patch
[mimalloc] error: Patch failed: error: patch failed: src/libc.c:64
[mimalloc] error: src/libc.c: patch does not apply
```

## Cause

#34181 bumped `MIMALLOC_COMMIT` to `24211c6e7610`, and that revision
already has the `_mi_strnlen` operand-order fix (`while(len < max_len &&
s[len] != 0)`) at
[`src/libc.c:67`](https://github.com/oven-sh/mimalloc/blob/24211c6e7610ae7c4ec06040758ec90bd21a1c83/src/libc.c#L67).
#32488 merged right after with `patches/mimalloc/strnlen-oob-read.patch`
written against the old context line (`while(s[len] != 0 && len <
max_len)`), so `git apply` rejects it on a fresh fetch.

Developers with a cached `vendor/mimalloc` from before #34181 won't hit
this because the fetch step's `.ref` identity already matched and the
patch step never ran.

## Fix

Delete the patch and its `patches:` entry in
`scripts/build/deps/mimalloc.ts`. The change it carried is already in
the pinned upstream, so no behavior is lost (the comment on the entry
even said "Drop when upstream fixes it").

## Verification

`rm -rf vendor/mimalloc && bun bd --revision` now fetches, builds, and
runs cleanly. Confirmed the fetched `vendor/mimalloc/src/libc.c` has the
bound check before the dereference.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

The background sweep introduced here made the mimalloc-page-count leak check in test/js/node/net/node-net.test.ts timing-dependent on release lanes (build 73897). Updated the test to sample until the count plateaus in #34359.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Heads up: the off-thread page return here made the single-sample RSS baseline in node-tls-getpeercert-leak.test.ts land in scavenger dips on debian 13 x64 / win11 aarch64; #35322 switches that test to per-round peak RSS (same as #34012 did for spawn-pipe-leak).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants