Skip to content

Let idle FTL code age out once the heap goes quiet (WebKit#557/#558) - #41297

Merged
Jarred-Sumner merged 5 commits into
mainfrom
claude/idle-ftl-code-aging
Sep 3, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
claude/idle-ftl-code-aging

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

WebKit → 40e43a82a755 (oven-sh/WebKit#557 + #558).

That change lets JSC age out FTL code (and DFG code compiled without tier-up checks) once the mutator has gone quiet. Such code has no execution counter, so since #41083 / WebKit#542 it never aged, and each block pinned its baseline alternative plus every baseline CodeBlock it inlined. Now a full collection lets it go once ≤ 1 MB has been allocated since the previous full collection looked at it for ≥ 30 s — which is exactly what the idle collections GarbageCollectionController already schedules (BUN_IDLE_GC_SECONDS=10,65,65) produce. Busy processes are unaffected: their full collections are allocation-driven and always renew the lease.

Bun side: GarbageCollectionController now issues those idle full collections through JSC__VM__collectAsyncIdle, which tags the request (GCRequest::isIdle, WebKit#558). JSC lets such code go only in a tagged collection, so a program that forces Bun.gc() on a timer while running hot, non-allocating code keeps its optimized code; allocation-paced and memory-pressure collections never drop it either.

Numbers

express / hono with a library-heavy handler (ajv, zod, markdown-it+highlight.js, js-yaml, protobufjs, graphql, handlebars, sucrase, …), 5,000-request burst at c=50, then idle. Same Bun commit, WebKit#557 option off vs on, 3 runs each:

idle +120 s off on
express anon RSS 148 MB 127 MB
hono anon RSS 137 MB 117 MB
surviving FunctionCodeBlocks (express) ~2,400 ~200

Nothing changes at +45 s (before the second idle collection) or under load; a 2-minute sustained-load run compiled the same number of FTL functions with the option on and off.

For context, idle +240 s RSS across versions on the same apps (5 runs, medians): express 1.3.14 245 / 1.4.0 224 / canary 192 → ~171 with this; hono 228 / 207 / 173 → ~152; elysia 230 / 203 / 170 → ~149; next 339 / 296 / 244 → ~223.

Tests

test/js/bun/gc/gc-controller-cadence.test.ts gains two cases (eager JIT TTLs so it runs in seconds): the idle collections take a 40-function FTL workload from ~90 FunctionCodeBlocks to a handful; BUN_IDLE_GC_SECONDS=0 plus Bun.gc(true) every 0.7 s leaves them in place. Both fail on current bun. The engine side is JSTests/stress/codeblock-aging-ftl-idle*.js in WebKit#557/#558.

No-Verification-Needed: dependency version bump only
… age out idle FTL code in them, and only in them

The full collections issued after BUN_IDLE_GC_SECONDS of heap quiet now go through JSC__VM__collectAsyncIdle,
which sets GCRequest::isIdle (oven-sh/WebKit#558). JSC lets FTL code and counter-less DFG code go only in such a
collection, so a program that forces Bun.gc() on a timer while running hot code keeps its optimized code.

test: with eager JIT TTLs, the idle collections take a 40-function FTL workload from ~90 FunctionCodeBlocks to a
handful; BUN_IDLE_GC_SECONDS=0 plus Bun.gc(true) every 0.7 s leaves them in place.
No-Verification-Needed: dependency version bump only
@Jarred-Sumner Jarred-Sumner changed the title Bump WebKit: let idle FTL code age out once the heap goes quiet Let idle FTL code age out once the heap goes quiet (WebKit#557/#558) Sep 3, 2026
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review September 3, 2026 22:28
@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a96a622e-438c-4f26-a269-1f28b94a2ebd

📥 Commits

Reviewing files that changed from the base of the PR and between 11f2c92 and 14969f2.

📒 Files selected for processing (1)
  • test/js/bun/gc/gc-controller-cadence.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

The PR updates the WebKit revision and adds an idle-specific asynchronous garbage collection path. The garbage collection controller uses this path for idle collections. New tests compare FTL code aging after idle and explicit collections.

Changes

Idle garbage collection

Layer / File(s) Summary
WebKit revision update
scripts/build/deps/webkit.ts
The default WEBKIT_VERSION commit changes.
Idle collection binding
src/jsc/VM.rs, src/jsc/bindings/headers.h, src/jsc/bindings/bindings.cpp
Rust and C++ bindings add JSC__VM__collectAsyncIdle, which schedules a full asynchronous collection marked as idle.
Collection routing and cadence tests
src/jsc/GarbageCollectionController.rs, test/js/bun/gc/gc-controller-cadence.test.ts
perform_gc routes idle collections through collect_async_idle(). Tests compare FTL code-block aging after idle and explicit collections.

Possibly related PRs

  • oven-sh/bun#41083: Both PRs change garbage collection routing, VM async-GC bindings, and cadence tests.

Merge Risk: ⚪ Minimal · up to 14969

Idle garbage collection can age eligible optimized code after quiet periods to reduce retained memory, while explicitly forced collections preserve that code. No unresolved merge-readiness risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: idle garbage collection allows FTL code to age out after the heap becomes quiet. It is concise and specific.
Description check ✅ Passed The description explains the WebKit update, Bun changes, expected behavior, measured memory improvements, and verification tests. It uses different headings from the template, but it provides the requ…
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.
Full details: Description check

Explanation

The description explains the WebKit update, Bun changes, expected behavior, measured memory improvements, and verification tests. It uses different headings from the template, but it provides the required change and verification information in sufficient detail.


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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/gc/gc-controller-cadence.test.ts Outdated
No-Verification-Needed: test-only assertion order
@Jarred-Sumner
Jarred-Sumner merged commit 6c06195 into main Sep 3, 2026
9 of 10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/idle-ftl-code-aging branch September 3, 2026 23:43

@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.

Code review found no issues

No high-confidence issues detected in this change.

jx-bit pushed a commit to jx-bit/bun that referenced this pull request Sep 24, 2026
… leak

Problem
=======

JSC's internal allocator (libpas) leaks ~7KB per Bun.$ invocation on the
1.4.0 line. Repro (`for (i<500000) await $`echo hello``) grows RSS
~69MB per 10k iterations, linear, never converging — ~3.5GB after 500k
iterations, OOM around 430k under a 3GB limit. Control loops (plain
await, Bun.spawn) hold flat RSS on the same unfixed binary, so the leak
is Bun.$-specific.

Root cause
==========

Upstream fixed this in the 1.4.0 → 1.4.1 window by building WebKit with
USE_MIMALLOC=ON / USE_EXTERNAL_MIMALLOC=ON ("JSC allocates through Bun's
mimalloc, not libpas") and bumping the mimalloc pin. This PR ports that
allocator state, the OHOS CI wiring for it, and the engine adaptations
the new WebKit source requires.

Changes
=======

Allocator state (matches the upstream 1.4.1 pins):
- scripts/build/deps/webkit.ts: WEBKIT_VERSION → 6119947592b6; local
  WebKit cmake gains USE_MIMALLOC / USE_EXTERNAL_MIMALLOC (non-ASAN)
- scripts/build/deps/mimalloc.ts: MIMALLOC_COMMIT → 6a64e1ba

OHOS CI gates (build-github, full-test, container-test, build-rust,
release): WEBKIT_REF → the same pin. The gates build WebKit from source
via --webkit=local; the old pins predate the USE_MIMALLOC cmake option,
which made the switch a silent no-op there. ohos-release.yml's old
default ref named a branch that does not exist upstream, so its checkout
silently fell back to a runner-local WebKit checkout.

Engine adaptations required by the new WebKit source (ported from the
upstream 1.4.0..1.4.1 upgrade commits 11fb730 / a92d84e /
561641b):
- EncodeURIComponent.cpp includes <wtf/HexNumber.h> itself (the engine
  no longer provides it transitively)
- GlobalObjectMethodTable::moduleLoaderFetch gains a trailing
  `const String& referrer` parameter; ZigGlobalObject and the bake
  implementation/call sites updated
- the @newPromiseCapability private builtin was removed upstream: the
  six call sites in node:events / node:util / node:dgram /
  node:_http_server switch to $newPromise plus the resolve/reject
  WithFirstResolvingFunctionCallCheck variants (the old form fails at
  module-load time, which compilation does not catch)
- relaxAdoptionRequirement was removed from the engine: drop all 10 call
  sites (FetchHeaders create paths, ScriptExecutionContext constructors)
- VM::heap.collectAsync takes a CollectionScope::Full parameter: the
  JSC__VM__collectAsync FFI (C++ wrapper, header decl, Rust decl) gains
  `bool full`; the Rust wrapper passes false, preserving incremental
  collection behavior

OHOS gate build fixes (from the OHOS branches' post-1.4.1 experience):
- FindThreads' pthread_cancel probe does not compile on OHOS musl and
  its fallback invents a nonexistent -lpthreads that kills the final jsc
  link: define pthread_cancel to nothing for cfg.ohos
- ICU_INCLUDE_DIR passed explicitly (the new engine's cmake needs it)
- nested WebKit build serialized via a new NestedCmakeBuild.parallel
  option (generated-header copy rules race on mounted filesystems)

Verification
============

- Same-codebase release binaries reproduce both sides: 1.4.0-line builds
  (this fork's linux-x64/windows-x64 and upstream 1.4.0) grow to ~3.5GB
  RSS after 500k spawns; upstream 1.4.1/1.4.2 hold flat at ~47MB (100k
  iterations). The repro fails on unfixed builds.
- Ported regression tests pin the migrated promise paths and the
  JS-visible engine changes.

Follow-ups
==========

- The bun-webkit formula must be rebuilt at 6119947592b6 for the
  OHOS_WEBKIT_ROOT prebuilt path to pick up USE_MIMALLOC.
- The GC-cadence commits (oven-sh#41083/oven-sh#41297) ride with the next routine
  upstream merge.
jx-bit pushed a commit to jx-bit/bun that referenced this pull request Sep 24, 2026
… leak

Problem
=======

JSC's internal allocator (libpas) leaks ~7KB per Bun.$ invocation on the
1.4.0 line. Repro (`for (i<500000) await $`echo hello``) grows RSS
~69MB per 10k iterations, linear, never converging — ~3.5GB after 500k
iterations, OOM around 430k under a 3GB limit. Control loops (plain
await, Bun.spawn) hold flat RSS on the same unfixed binary, so the leak
is Bun.$-specific.

Root cause
==========

Upstream fixed this in the 1.4.0 → 1.4.1 window by building WebKit with
USE_MIMALLOC=ON / USE_EXTERNAL_MIMALLOC=ON ("JSC allocates through Bun's
mimalloc, not libpas") and bumping the mimalloc pin. This PR ports that
allocator state, the OHOS CI wiring for it, and the engine adaptations
the new WebKit source requires.

Changes
=======

Allocator state (matches the upstream 1.4.1 pins):
- scripts/build/deps/webkit.ts: WEBKIT_VERSION → 6119947592b6; local
  WebKit cmake gains USE_MIMALLOC / USE_EXTERNAL_MIMALLOC (non-ASAN)
- scripts/build/deps/mimalloc.ts: MIMALLOC_COMMIT → 6a64e1ba

OHOS CI gates (build-github, full-test, container-test, build-rust,
release): WEBKIT_REF → the same pin. The gates build WebKit from source
via --webkit=local; the old pins predate the USE_MIMALLOC cmake option,
which made the switch a silent no-op there. ohos-release.yml's old
default ref named a branch that does not exist upstream, so its checkout
silently fell back to a runner-local WebKit checkout.

Engine adaptations required by the new WebKit source (ported from the
upstream 1.4.0..1.4.1 upgrade commits 11fb730 / a92d84e /
561641b):
- EncodeURIComponent.cpp includes <wtf/HexNumber.h> itself (the engine
  no longer provides it transitively)
- GlobalObjectMethodTable::moduleLoaderFetch gains a trailing
  `const String& referrer` parameter; ZigGlobalObject and the bake
  implementation/call sites updated
- the @newPromiseCapability private builtin was removed upstream: the
  six call sites in node:events / node:util / node:dgram /
  node:_http_server switch to $newPromise plus the resolve/reject
  WithFirstResolvingFunctionCallCheck variants (the old form fails at
  module-load time, which compilation does not catch)
- relaxAdoptionRequirement was removed from the engine: drop all 10 call
  sites (FetchHeaders create paths, ScriptExecutionContext constructors)
- VM::heap.collectAsync takes a CollectionScope::Full parameter: the
  JSC__VM__collectAsync FFI (C++ wrapper, header decl, Rust decl) gains
  `bool full`; the Rust wrapper passes false, preserving incremental
  collection behavior

OHOS gate build fixes (from the OHOS branches' post-1.4.1 experience):
- FindThreads' pthread_cancel probe does not compile on OHOS musl and
  its fallback invents a nonexistent -lpthreads that kills the final jsc
  link: define pthread_cancel to nothing for cfg.ohos
- ICU_INCLUDE_DIR passed explicitly (the new engine's cmake needs it)
- nested WebKit build serialized via a new NestedCmakeBuild.parallel
  option (generated-header copy rules race on mounted filesystems)

Verification
============

- Same-codebase release binaries reproduce both sides: 1.4.0-line builds
  (this fork's linux-x64/windows-x64 and upstream 1.4.0) grow to ~3.5GB
  RSS after 500k spawns; upstream 1.4.1/1.4.2 hold flat at ~47MB (100k
  iterations). The repro fails on unfixed builds.
- Ported regression tests pin the migrated promise paths and the
  JS-visible engine changes.

Follow-ups
==========

- The bun-webkit formula must be rebuilt at 6119947592b6 for the
  OHOS_WEBKIT_ROOT prebuilt path to pick up USE_MIMALLOC.
- The GC-cadence commits (oven-sh#41083/oven-sh#41297) ride with the next routine
  upstream merge.
jx-bit pushed a commit to jx-bit/bun that referenced this pull request Sep 24, 2026
… leak

Problem
=======

JSC's internal allocator (libpas) leaks ~7KB per Bun.$ invocation on the
1.4.0 line. Repro (`for (i<500000) await $`echo hello``) grows RSS
~69MB per 10k iterations, linear, never converging — ~3.5GB after 500k
iterations, OOM around 430k under a 3GB limit. Control loops (plain
await, Bun.spawn) hold flat RSS on the same unfixed binary, so the leak
is Bun.$-specific.

Root cause
==========

Upstream fixed this in the 1.4.0 → 1.4.1 window by building WebKit with
USE_MIMALLOC=ON / USE_EXTERNAL_MIMALLOC=ON ("JSC allocates through Bun's
mimalloc, not libpas") and bumping the mimalloc pin. This PR ports that
allocator state, the OHOS CI wiring for it, and the engine adaptations
the new WebKit source requires.

Changes
=======

Allocator state (matches the upstream 1.4.1 pins):
- scripts/build/deps/webkit.ts: WEBKIT_VERSION → 6119947592b6; local
  WebKit cmake gains USE_MIMALLOC / USE_EXTERNAL_MIMALLOC (non-ASAN)
- scripts/build/deps/mimalloc.ts: MIMALLOC_COMMIT → 6a64e1ba

OHOS CI gates (build-github, full-test, container-test, build-rust,
release): WEBKIT_REF → the same pin. The gates build WebKit from source
via --webkit=local; the old pins predate the USE_MIMALLOC cmake option,
which made the switch a silent no-op there. ohos-release.yml's old
default ref named a branch that does not exist upstream, so its checkout
silently fell back to a runner-local WebKit checkout.

Engine adaptations required by the new WebKit source (ported from the
upstream 1.4.0..1.4.1 upgrade commits 11fb730 / a92d84e /
561641b):
- EncodeURIComponent.cpp includes <wtf/HexNumber.h> itself (the engine
  no longer provides it transitively)
- GlobalObjectMethodTable::moduleLoaderFetch gains a trailing
  `const String& referrer` parameter; ZigGlobalObject and the bake
  implementation/call sites updated
- the @newPromiseCapability private builtin was removed upstream: the
  six call sites in node:events / node:util / node:dgram /
  node:_http_server switch to $newPromise plus the resolve/reject
  WithFirstResolvingFunctionCallCheck variants (the old form fails at
  module-load time, which compilation does not catch)
- relaxAdoptionRequirement was removed from the engine: drop all 10 call
  sites (FetchHeaders create paths, ScriptExecutionContext constructors)
- VM::heap.collectAsync takes a CollectionScope::Full parameter: the
  JSC__VM__collectAsync FFI (C++ wrapper, header decl, Rust decl) gains
  `bool full`; the Rust wrapper passes false, preserving incremental
  collection behavior

OHOS gate build fixes (from the OHOS branches' post-1.4.1 experience):
- FindThreads' pthread_cancel probe does not compile on OHOS musl and
  its fallback invents a nonexistent -lpthreads that kills the final jsc
  link: define pthread_cancel to nothing for cfg.ohos
- ICU_INCLUDE_DIR passed explicitly (the new engine's cmake needs it)
- nested WebKit build serialized via a new NestedCmakeBuild.parallel
  option (generated-header copy rules race on mounted filesystems)

Verification
============

- Same-codebase release binaries reproduce both sides: 1.4.0-line builds
  (this fork's linux-x64/windows-x64 and upstream 1.4.0) grow to ~3.5GB
  RSS after 500k spawns; upstream 1.4.1/1.4.2 hold flat at ~47MB (100k
  iterations). The repro fails on unfixed builds.
- Ported regression tests pin the migrated promise paths and the
  JS-visible engine changes.

Follow-ups
==========

- The bun-webkit formula must be rebuilt at 6119947592b6 for the
  OHOS_WEBKIT_ROOT prebuilt path to pick up USE_MIMALLOC.
- The GC-cadence commits (oven-sh#41083/oven-sh#41297) ride with the next routine
  upstream merge.

CI round 3 (infra): the pre-sign step failed writing bun-signed
(EACCES) - a stale root-owned signed output from an earlier run blocks
the runner-user write on the persistent self-hosted workspace. Remove
the stale signed file before signing.
jx-bit pushed a commit to jx-bit/bun that referenced this pull request Sep 24, 2026
… leak

Problem
=======

JSC's internal allocator (libpas) leaks ~7KB per Bun.$ invocation on the
1.4.0 line. Repro (`for (i<500000) await $`echo hello``) grows RSS
~69MB per 10k iterations, linear, never converging — ~3.5GB after 500k
iterations, OOM around 430k under a 3GB limit. Control loops (plain
await, Bun.spawn) hold flat RSS on the same unfixed binary, so the leak
is Bun.$-specific.

Root cause
==========

Upstream fixed this in the 1.4.0 → 1.4.1 window by building WebKit with
USE_MIMALLOC=ON / USE_EXTERNAL_MIMALLOC=ON ("JSC allocates through Bun's
mimalloc, not libpas") and bumping the mimalloc pin. This PR ports that
allocator state, the OHOS CI wiring for it, and the engine adaptations
the new WebKit source requires.

Changes
=======

Allocator state (matches the upstream 1.4.1 pins):
- scripts/build/deps/webkit.ts: WEBKIT_VERSION → 6119947592b6; local
  WebKit cmake gains USE_MIMALLOC / USE_EXTERNAL_MIMALLOC (non-ASAN)
- scripts/build/deps/mimalloc.ts: MIMALLOC_COMMIT → 6a64e1ba

OHOS CI gates (build-github, full-test, container-test, build-rust,
release): WEBKIT_REF → the same pin. The gates build WebKit from source
via --webkit=local; the old pins predate the USE_MIMALLOC cmake option,
which made the switch a silent no-op there. ohos-release.yml's old
default ref named a branch that does not exist upstream, so its checkout
silently fell back to a runner-local WebKit checkout.

Engine adaptations required by the new WebKit source (ported from the
upstream 1.4.0..1.4.1 upgrade commits 11fb730 / a92d84e /
561641b):
- EncodeURIComponent.cpp includes <wtf/HexNumber.h> itself (the engine
  no longer provides it transitively)
- GlobalObjectMethodTable::moduleLoaderFetch gains a trailing
  `const String& referrer` parameter; ZigGlobalObject and the bake
  implementation/call sites updated
- the @newPromiseCapability private builtin was removed upstream: the
  six call sites in node:events / node:util / node:dgram /
  node:_http_server switch to $newPromise plus the resolve/reject
  WithFirstResolvingFunctionCallCheck variants (the old form fails at
  module-load time, which compilation does not catch)
- relaxAdoptionRequirement was removed from the engine: drop all 10 call
  sites (FetchHeaders create paths, ScriptExecutionContext constructors)
- VM::heap.collectAsync takes a CollectionScope::Full parameter: the
  JSC__VM__collectAsync FFI (C++ wrapper, header decl, Rust decl) gains
  `bool full`; the Rust wrapper passes false, preserving incremental
  collection behavior

OHOS gate build fixes (from the OHOS branches' post-1.4.1 experience):
- FindThreads' pthread_cancel probe does not compile on OHOS musl and
  its fallback invents a nonexistent -lpthreads that kills the final jsc
  link: define pthread_cancel to nothing for cfg.ohos
- ICU_INCLUDE_DIR passed explicitly (the new engine's cmake needs it)
- nested WebKit build serialized via a new NestedCmakeBuild.parallel
  option (generated-header copy rules race on mounted filesystems)

Verification
============

- Same-codebase release binaries reproduce both sides: 1.4.0-line builds
  (this fork's linux-x64/windows-x64 and upstream 1.4.0) grow to ~3.5GB
  RSS after 500k spawns; upstream 1.4.1/1.4.2 hold flat at ~47MB (100k
  iterations). The repro fails on unfixed builds.
- Ported regression tests pin the migrated promise paths and the
  JS-visible engine changes.

Follow-ups
==========

- The bun-webkit formula must be rebuilt at 6119947592b6 for the
  OHOS_WEBKIT_ROOT prebuilt path to pick up USE_MIMALLOC.
- The GC-cadence commits (oven-sh#41083/oven-sh#41297) ride with the next routine
  upstream merge.

CI round 3 (infra): the pre-sign step failed writing bun-signed
(EACCES) - docker cp restores container ownership (root), and on the
persistent self-hosted workspace the build/release-ohos directory ends
up root-owned, so the runner-user pre-sign cannot create the signed
output. Reclaim ownership (sudo chown, chmod fallback) right after the
docker cp.
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.

1 participant