Skip to content

test: make common/gc.js onGC() honour upstream's setImmediate contract - #34176

Closed
cirospaciari wants to merge 4 commits into
mainfrom
claude/fix-ongc-harness-race
Closed

cirospaciari wants to merge 4 commits into
mainfrom
claude/fix-ongc-harness-race

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 14, 2026 •

Copy link
Copy Markdown
Member

What this does

Reimplements onGC() in the vendored-Node test harness (test/js/node/test/common/gc.js) so it honours the contract the upstream tests are written against, and corrects the recorded root cause of the test-tls-connect-memleak.js musl quarantine, which was wrong.

No runtime code changes. No vendored test file is touched. No expectations entry is added or removed.

This does not fix the alpine flake. I opened it believing it would; CI disproved that. See "What CI proved" below. What survives is a real harness hardening plus a corrected diagnosis. Retitled and rewritten accordingly.

The contract

test-net-connect-memleak.js and test-tls-connect-memleak.js are verbatim upstream Node v26.3.0 files. They call globalThis.gc() and then assert, inside the very next setImmediate(), that their onGC() listener has already fired.

Upstream can rely on that because its onGC() uses an async_hooks destroy hook, and documents the result: "A full setImmediate() invocation passes between a global.gc() call and the listener being invoked."

Bun's common/gc.js is an adapted harness, not a verbatim port, and implements onGC() with a FinalizationRegistry — because Bun's async_hooks.createHook only emits init (src/js/node/async_hooks.ts:362: "hooks can still be created but will never be called"). Upstream's mechanism simply isn't available.

FinalizationRegistry cannot honour that contract:

  • JSC schedules cleanup callbacks via DeferredWorkTimer.
  • Bun routes those to Bun__queueJSCDeferredWorkTaskConcurrently (src/jsc/bindings/JSCTaskScheduler.cpp:54) — the event loop's concurrent task queue.
  • setImmediate() has its own two separate queues (src/jsc/event_loop.rs:55).

Nothing orders those against each other, so a cleanup callback is free to land after the setImmediate() observing it. That is a real latent hazard for all 9 onGC() callers, independent of the bug I set out to fix.

What changed

onGC() now delivers off WeakRef, which gc() clears synchronously (verified: deref() returns undefined immediately after gc()). A sweep run right after an explicit gc() observes the collection with no queue in between.

The FinalizationRegistry is kept, because two callers rely on a natural GC and never call gc() at all (test-http-client-leaky-with-double-response, test-http-server-keepalive-req-gc). Whichever path arrives first wins; a pending-set guard fires each listener exactly once — required, as these tests use mustCall().

Rejected alternatives, after reading the implementations: JSC has no FinalizationRegistry.prototype.cleanupSome; bun:jsc's releaseWeakRefs is not an FR drain (bindings.cpp:4541 — it's vm->finalizeSynchronousJSExecution(), which only bumps the weak-ref version); drainMicrotasks doesn't touch the concurrent queue.

What CI proved (and disproved)

I originally claimed this fixed the alpine flake and dropped the quarantine. Build 73000 failed on alpine 3.23 x64 and x64-baseline with test-tls-connect-memleak.js still asserting collected === false. So I restored the quarantine (second commit).

That result is worth more than the original hypothesis. Because onGC() now fires synchronously inside gc() — verified live on the CI path, not just locally — a late cleanup callback can no longer be an explanation. collected === false now means one thing only: the object is genuinely still reachable when gc() returns on musl x64.

So main's existing comment ("JSC FinalizationRegistry callback delivery vs setImmediate timing on musl x64") is wrong, and this PR replaces it with what's now provable, including what rules the old theory out. Best remaining explanation for the actual failure: JSC's conservative stack scanning retaining the connect closure under musl's stack layout — which neither the harness nor the test can control. Marked unconfirmed in the comment; it has never reproduced off musl.

Notably the net twin passes on alpine while the TLS one fails, despite being structurally identical.

How we know the harness change is sound

Debug build throughout.

  • All 10 files that load common/gc.js pass 20/20 each (200/200) in CI configuration (bun test --config=bunfig.node-test.toml <abs path>), including both natural-GC callers — the FinalizationRegistry path still works.

  • The ordering dependency is gone. With FinalizationRegistry stubbed so its callbacks never fire:

    net-connect-memleak tls-connect-memleak common-gc
    main FAIL FAIL FAIL
    this PR PASS PASS PASS

    On main the FR is the only delivery path. Here it is no longer load-bearing for any test that calls gc().

  • Wrapper confirmed installed under the real runner path, not just under a bare --expose-gc.

  • test-v8-serialize-leak fails 10/10 on this branch and 10/10 on main — pre-existing, uses gcUntil/RSS, never calls onGC(). Untouched.

Developed on glibc; the flake is musl-only and I could not reproduce it locally (30/30 clean on main, 40/40 under 8x CPU load, no musl container available).

After merge

No flake is fixed. What lands: onGC() stops depending on an undefined queue ordering for all 9 callers, and the quarantine explains the failure correctly instead of pointing at a mechanism that has now been ruled out — so the next person to look at test-tls-connect-memleak.js starts from the object not being collected, not from FinalizationRegistry timing.

…gistry timing

test-net-connect-memleak.js and test-tls-connect-memleak.js fail
intermittently on the alpine/musl x64 lanes. Both are verbatim upstream
Node tests that call globalThis.gc() and then assert, inside the very next
setImmediate(), that their onGC() listener has fired.

Upstream can rely on that because its onGC() is built on an async_hooks
destroy hook, and it documents the resulting contract: "A full
setImmediate() invocation passes between a global.gc() call and the
listener being invoked."

Bun's common/gc.js is an adapted harness, not a verbatim port, and it
implements onGC() with a FinalizationRegistry because Bun's
async_hooks.createHook only emits init events. FinalizationRegistry cannot
honour that contract: JSC schedules cleanup callbacks through
DeferredWorkTimer, which Bun drains via the event loop's concurrent task
queue, while setImmediate() has its own separate queues. Neither queue is
ordered against the other, so the callback can land after the observing
setImmediate(). Nothing leaks in these tests -- the listener is removed and
the object is collected; only the notification arrives too late.

Deliver listeners off WeakRef instead, which gc() clears synchronously, so
a sweep run right after an explicit gc() sees the collection with no queue
in between. The FinalizationRegistry stays for the two callers that rely on
a natural GC and never call gc() at all
(test-http-client-leaky-with-double-response, test-http-server-keepalive-req-gc);
a pending-set guard makes each listener fire exactly once no matter which
path gets there first.

Also drops the now-obsolete LINUX-X64-MUSL quarantine of
test-tls-connect-memleak.js, whose stated cause was exactly this ordering.

Verified with the debug build: all 10 files that load common/gc.js pass
20/20 each in CI configuration. To show the delivery no longer depends on
the deferred-work queue, the gc()-driven tests were also run with
FinalizationRegistry stubbed out so its callbacks never fire: they pass on
this change and fail on main.
@robobun

robobun commented Jul 14, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 6:35 PM PT - Aug 3rd, 2026

@robobun, your commit 101b122 is building: #88575

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. CI: test-net-connect-memleak.js fails on half of PR builds on linux-x64-musl since June 28 ~23:00 UTC #33044 - Directly fixes the flaky test-net-connect-memleak.js failure on linux-x64-musl caused by FinalizationRegistry vs setImmediate timing non-determinism

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

Fixes #33044

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Quarantine test-net-connect-memleak on linux-x64-musl #33045 - Quarantines the same flaky test-net-connect-memleak / test-tls-connect-memleak tests on musl via test/expectations.txt (workaround vs this PR's root-cause fix)
  2. ai slop #33225 - Alternative runtime fix for the same tests by zeroing the dead stack before JS dispatch to prevent conservative GC from keeping dead objects alive

🤖 Generated with Claude Code

Comment thread test/js/node/test/common/gc.js Outdated
var finalizationRegistry = new FinalizationRegistry(heldValue => {
heldValue.ongc();
})
// Upstream implements onGC() with an async_hooks destroy hook, which gives it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@robobun what node.js do here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Node's onGC() (test/common/gc.js @ v26.3.0) is built on async_hooks:

const gcTrackerMap = new WeakMap();
const gcTrackerTag = 'NODE_TEST_COMMON_GC_TRACKER';

function onGC(obj, gcListener) {
  const async_hooks = require('async_hooks');
  const onGcAsyncHook = async_hooks.createHook({
    init: common.mustCallAtLeast(function(id, type) {
      if (this.trackedId === undefined) {
        assert.strictEqual(type, gcTrackerTag);
        this.trackedId = id;
      }
    }),
    destroy(id) {
      if (id === this.trackedId) {
        this.gcListener.ongc();
        onGcAsyncHook.disable();
      }
    },
  }).enable();
  onGcAsyncHook.gcListener = gcListener;

  gcTrackerMap.set(obj, new async_hooks.AsyncResource(gcTrackerTag));
  obj = null;
}

It creates an AsyncResource held only by a WeakMap keyed on obj. When V8 collects obj the AsyncResource becomes unreachable, V8 collects it, and its native finalizer calls EmitDestroy, which Node drains through env->SetImmediate on the next loop iteration. That's where the documented guarantee in their JSDoc comes from: "A full setImmediate() invocation passes between a global.gc() call and the listener being invoked."

Bun can't use that path: async_hooks.createHook here only delivers init (for TickObject) and warns that destroy is never called (src/js/node/async_hooks.ts:362). So there's no destroy event to hook. The WeakRef sweep after an explicit gc() is the closest mechanism that gives the same "observable by the next setImmediate" ordering, and the FinalizationRegistry stays for the two callers that never call gc() at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@robobun check cirospaciari async hooks PR to see if it would solve this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked #31825. It wouldn't solve this on its own.

That PR's src/js/node/async_hooks.ts diff covers AsyncLocalStorage (run/disable restore semantics, defaultValue, name, withScope) and AsyncResource.bind() parity, but it doesn't touch createHook at all. The destroy hook still never fires; the PR's own vendored test-eventemitter-asyncresource.js adds a comment saying exactly that ("Bun does not implement createHook event [delivery]").

Node's onGC() needs two things that remain missing after #31825:

  1. createHook({ destroy }) actually delivering destroy events.
  2. AsyncResource having a native finalizer that calls emitDestroy when GC collects it. In Node the AsyncResource is a native AsyncWrap; V8 runs its C++ destructor, which queues EmitDestroy. In Bun AsyncResource is a plain JS class with no JSC finalizer, so nothing fires on collection even if (1) were wired up.

So even with #31825 merged, common/gc.js still needs a non-async_hooks mechanism, and the WeakRef sweep here is that mechanism. If/when Bun grows a native-backed AsyncResource with GC-driven destroy, this file could switch to upstream's verbatim implementation and drop the WeakRef path.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

GC listener timing and test expectations

Layer / File(s) Summary
Coordinate GC listener delivery
test/js/node/test/common/gc.js
GC listeners use WeakRef entries, de-duplicate callback delivery, flush after explicit global.gc(), and invoke holder.ongc.
Document TLS test timing
test/expectations.txt
The Linux musl TLS memleak test remains flaky, while its comment now describes closure collection and FinalizationRegistry timing.

Possibly related issues

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 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 accurately summarizes the main change: updating common/gc.js onGC behavior to match the upstream setImmediate contract.
Description check ✅ Passed The description covers what changed and how it was verified, including CI results and the restored quarantine, matching the template well.

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

@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/node/test/common/gc.js`:
- Around line 159-167: Update flushGCListeners to isolate each
fireGCListener(entry) invocation so an exception from one ongc callback does not
abort the loop. Catch the per-entry exception, continue processing all remaining
collected entries from the snapshot, and preserve the existing pending-listener
cleanup behavior handled by fireGCListener.
🪄 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: 5b835af0-7395-4d80-a657-e05c6ceadd36

📥 Commits

Reviewing files that changed from the base of the PR and between cc0c1e8 and d41aa5a.

📒 Files selected for processing (2)
  • test/expectations.txt
  • test/js/node/test/common/gc.js
💤 Files with no reviewable changes (1)
  • test/expectations.txt

Comment thread test/js/node/test/common/gc.js Outdated

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

I didn't find any issues with the implementation, but since the author explicitly flags the alpine 3.23 x64 / x64-baseline lanes as the real verification (and the fix un-quarantines test-tls-connect-memleak.js on musl), a human should confirm those lanes are green before merging.

What was reviewed:

  • Exactly-once delivery guard via pendingListeners.delete() return value — both the WeakRef sweep and the FinalizationRegistry callback route through fireGCListener, and unregister prevents a late FR callback.
  • entry holds only a WeakRef to the tracked value, so pendingListeners doesn't keep targets alive; flushGCListeners iterates a snapshot so mutation during iteration is safe.
  • All 9 onGC call sites in test/js/node/test/parallel/ — the two natural-GC callers still get delivery via the retained FinalizationRegistry path; the gc() wrapper preserves args/return and only installs when gc exists.
Extended reasoning...

Overview

This PR modifies only test infrastructure: test/js/node/test/common/gc.js (Bun's adapted — not verbatim — Node test harness) and removes one [ LINUX-X64-MUSL ] quarantine entry from test/expectations.txt. No runtime code is touched. The change makes onGC() deliver notifications via a WeakRef sweep triggered synchronously after every explicit gc() call, in addition to the existing FinalizationRegistry path, so that vendored Node tests asserting collected === true inside the very next setImmediate() after gc() no longer depend on JSC's DeferredWorkTimer / concurrent-task-queue ordering.

Security risks

None. This is test-harness JavaScript that only runs inside vendored Node compatibility tests. It wraps globalThis.gc at module load, but only for the ~10 test files that require('../common/gc'), each of which runs as its own process.

Level of scrutiny

Medium. The logic is small and self-contained, and the exactly-once guard (Set.delete returning false short-circuits the second delivery path, plus finalizationRegistry.unregister(entry)) is straightforward to verify. I traced all 9 onGC callers and confirmed the entry object never strongly references the tracked value, flushGCListeners iterates a snapshot copy, and the gc wrapper is a transparent pass-through. The mustNotCall case in test-common-gc.js (onGC(process, ...)) is handled correctly since deref() stays non-undefined for a live object.

Other factors

The reason I'm deferring rather than approving is the PR description's own caveat: the flake reproduces only on musl, the author could not reproduce it locally on glibc, and states that "the alpine 3.23 x64 + x64-baseline lanes on this PR are the real verification." The PR also removes the musl quarantine for test-tls-connect-memleak.js. Given the build was still in progress at review time and the correctness claim explicitly hinges on those specific CI lanes, a human should eyeball the alpine results before merging — if they're green, this looks ready to go.

…reason

CI (build 73000) shows the alpine x64 and x64-baseline failure survives the
onGC() change, so the FinalizationRegistry delivery ordering was not its
cause. Put the quarantine back.

The run is still informative. onGC() now fires listeners synchronously from
inside gc(), so a late cleanup callback can no longer be the explanation:
collected === false means the object is genuinely still reachable when gc()
returns on musl x64. Rewrite the comment to say that instead of the
FinalizationRegistry theory it used to assert, and record what rules that
theory out.
@cirospaciari cirospaciari changed the title test: make common/gc.js onGC() delivery independent of FinalizationRegistry timing test: make common/gc.js onGC() honour upstream's setImmediate contract Jul 14, 2026

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

LGTM — test-harness-only hardening with no runtime impact.

Checked that the pendingListeners Set + fireGCListener guard delivers each listener exactly once whichever path (WeakRef sweep or FinalizationRegistry) arrives first, and that entries hold only a WeakRef so tracked values remain collectable.
Checked the globalThis.gc wrapper is scoped to processes that load common/gc.js, is idempotent via the module cache, and that gcUntil in the same file picking up the wrapped gc() is harmless.
Confirmed the expectations.txt change is comment-only — the [ LINUX-X64-MUSL ] … [ FLAKY ] entry is preserved, so runner behaviour is unchanged.
The stale "Returns false" comment in fireGCListener was noted and treated as a nit.

Extended reasoning...

Overview

Two files touched, both test infrastructure:

  • test/js/node/test/common/gc.js — reimplements onGC() (Bun's adapted, non-verbatim harness helper) to add a WeakRef-based synchronous sweep inside a wrapped globalThis.gc(), alongside the existing FinalizationRegistry path. A pendingListeners Set provides exactly-once delivery. ~70 lines, mostly explanatory comment.
  • test/expectations.txt — rewrites the comment block for the existing test-tls-connect-memleak.js musl quarantine to reflect what CI build 73000 proved (object genuinely uncollected on musl, not FR-callback timing). The quarantine entry itself — modifier, path, expectation — is unchanged.

No runtime, native, or bundled-JS code is touched. Blast radius is the ~11 vendored Node tests that require('../common/gc').

Security risks

None. Test-harness JavaScript only; no user-facing surface, no untrusted input, no auth/crypto/permissions paths.

Level of scrutiny

Low-to-moderate. This is shared test infrastructure, so a mistake could quietly break or false-pass ~11 vendored Node tests, but it cannot affect the shipped binary. The mechanism was traced end-to-end:

  • entry = { ref: new WeakRef(value), ongc } — no strong reference to the tracked value; pendingListeners keeps only the entry (WeakRef + callback) alive.
  • finalizationRegistry.register(value, entry, entry) uses entry as both held-value and unregister token, so unregister(entry) in fireGCListener cleanly removes the FR registration when the WeakRef path wins.
  • pendingListeners.delete(entry) returning false is the exactly-once guard for whichever path arrives second — correct for both orderings.
  • flushGCListeners snapshots [...pendingListeners] before iterating, so the in-loop delete is safe.
  • The globalThis.gc wrapper is installed once at module load (module cache), only when gc already exists (i.e., --expose-gc), forwards this/args and return value, and its flushGCListeners() is a no-op when nothing is registered — so other gc() callers in the same process (including gcUntil in this file) are unaffected beyond the intended flush.

The PR description documents 200/200 passes across all consumers under the CI config, plus a differential test with FR callbacks stubbed out showing the WeakRef path is now sufficient for explicit-gc() callers.

Other factors

  • The one raised concern (CodeRabbit: a throwing ongc() aborts the flush loop) was rebutted and resolved — cleanup happens before the call, upstream's contract is that ongc must not throw, and no current caller does.
  • The reviewer question ("what node.js do here?") was answered in-thread; no outstanding requests.
  • The stale "Returns false" comment on fireGCListener is a cosmetic nit already surfaced this run; not blocking.
  • expectations.txt change is documentation-only for the runner (only the trailing # comment differs on the entry line), so no coverage is added or removed.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Build #73002: alpine 3.23 x64 and x64-baseline are green (the stated verification for this harness change). All common/gc.js consumers pass on every lane.

The one red is test-worker-message-port-transfer-terminate.js on debian 13 x64-asan, which is pre-existing on main (native JSC !scope.exception() || !hasSlot assertion) and unrelated to this test-harness-only diff. Everything else is retry-passed flake that doesn't touch common/gc.js.

…ss-race

# Conflicts:
#	test/expectations.txt
#	test/js/node/test/common/gc.js
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

This PR is now a no-op (git diff origin/main...HEAD is empty after the merge in 8b45b5a).

#32630 landed on main at cc40d78 with a more complete version of the same common/gc.js fix: onGCSweepSync driven from the --expose-gc shim in common/index.js, with releaseWeakRefs() to clear [[KeptAlive]] and a nextTick retry pass that handles the TLS secureConnect-listener retention case (the one b400607 here had to keep quarantined). Main's expectations.txt no longer carries the test-tls-connect-memleak.js musl entry.

Nothing left to land here; can be closed.

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closing: superseded by #32630 (see comment above). The branch has no diff against main after cc40d78 landed the more complete common/gc.js onGC fix. Reopen if there's more to do here.

@robobun robobun closed this Aug 4, 2026
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