Skip to content

test: speed up the AbortSignal.timeout + util.aborted leak test and assert its numbers - #38197

Open
robobun wants to merge 1 commit into
mainfrom
farm/03997ce8/fast-28756-leak-test
Open

robobun wants to merge 1 commit into
mainfrom
farm/03997ce8/fast-28756-leak-test

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • test/regression/issue/28756.test.ts (leak test for AbortSignal.timeout() + util.aborted() causes unbounded memory growth #28756, fixed in Fix memory leak in AbortSignal.timeout() when listeners are removed #28761) takes 13s on the x64 ASAN lane and 95s under a local bun bd (debug+ASAN), versus 0.6-1.7s on the release lanes. The child churns 10 x 8000 signals and sleeps 50-100ms between GCs.
  • The parent only looks at the child's exit code; the child decides pass/fail itself and its baselineMB/finalMB/growthMB line is ignored.
  • On ASAN builds the test cannot fail: the bound is 256 MB while the leak is ~60 MB. With ASAN's quarantine on, RSS also stops measuring retention at all: at 4 x 1000 signals the fixed build grew 9.1 MB and a simulated leak grew 7.0 MB (freed memory stays resident in the quarantine, leaked memory is never freed).
  • Under ASAN the RSS of the fixed build and of a leaking build only separate after several thousand signals, and a debug+ASAN build spends ~0.3ms per signal plus ~0.5s just loading node:util, so on those builds a signal count that fits in the default test timeout is too small for an RSS bound to tell the two apart.

Fix

  • The child churns batches of signals (listener added, aborted() called, listener removed) after one warmup batch, settles with gc, one event loop turn, gc, and prints JSON with the number of AbortSignal wrappers that survived relative to the post-warmup count (bun:jsc heapStats()) and the RSS growth after every batch.
  • The parent asserts stderr === "", that every batch reported, leakedSignals < batchSize (a leak keeps every signal alive, the fixed build keeps 0), RSS growth below 12 MB with the per-batch trend in the failure message, and the exit code last.
  • Release builds churn 80 x 500 signals; ASAN or debug builds churn 4 x 250. The RSS floor of the fixed build (JIT code, allocator slack) is a few MB and does not grow with the count, so the release count leaves the leak well clear of it; on slow builds the wrapper count is the assertion that detects the leak and the RSS bound is a backstop. The comment in the file records the measurements.
  • The child runs with ASAN_OPTIONS=...:quarantine_size_mb=0, the same thing the other RSS based leak tests do, so freed memory is reused and RSS growth means retention on ASAN builds too.
  • The remaining event loop turn (setImmediate) is required, not a delay: the {} passed to aborted() dies on the first GC, its FinalizationRegistry callback (a task) removes the listener aborted() registered, and only the second GC can collect the signal. Verified by counting wrappers: gc, microtask, gc leaves all 1000 alive, gc, setImmediate, gc leaves 0. The 50/100ms sleeps and the 300s per test timeout are gone.
  • Why this is the right measurement: a signal that leaks the way AbortSignal.timeout() + util.aborted() causes unbounded memory growth #28756 did, or any other way, either keeps its wrapper alive (counted directly) or only its native object and timer alive (shows in RSS at the release count, 0.6-0.9 KB per signal, 23-34 MB at 40k signals against the 12 MB bound).

Verification

  • bun bd test test/regression/issue/28756.test.ts (debug+ASAN): 94.8s before, 2.5-2.8s after (20 runs: RSS growth 0-3.1 MB, leakedSignals 0 every time, slowest run 3.6s).
  • USE_SYSTEM_BUN=1 bun test ... (release 1.4.0): 1.1s before, ~0.3s after (30 runs: RSS growth 2.0-3.1 MB, leakedSignals 0 every time).
  • Leak still detected, by temporarily simulating it in the child and reverting:
    • listener never removed (signal stays observed): release 40000 of 40000 AbortSignal wrappers survived GC, debug+ASAN 1000 of 1000 ....
    • every signal pushed into an array: same two failures.
    • same two simulations with the wrapper assertion deleted, to exercise the RSS bound alone: release fails with RSS grew 32.0-34.0 MB and RSS grew 23.0 MB over 40000 signals (after batch 10: 3.0 MB, 20: 8.0 MB, ... 80: 23.0 MB) against the 12 MB bound; on debug+ASAN at 1000 signals both grow 0-3 MB and pass, which is the documented reason the wrapper count carries those builds.
  • The x64 ASAN lane churns 1250 signals instead of 80k and no longer sleeps; its time should come down to process startup.

Background

  • util.aborted(signal, resource) registers an abort listener and a FinalizationRegistry entry keyed on resource; when resource is collected the callback removes the listener again. FinalizationRegistry callbacks run as event loop tasks after the GC that found the dead target, so a microtask checkpoint is not enough to run them.
  • A timeout signal's wrapper is kept alive by the GC only while something observes it (JSAbortSignalOwner::isReachableFromOpaqueRoots); once the last listener is gone the wrapper is collectible and its destructor frees the native signal and its timer (Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849, AbortSignal.timeout: keep the timer armed when the signal loses its observers #37666). AbortSignal.timeout() + util.aborted() causes unbounded memory growth #28756 was the older variant of this: the wrapper died but the timer held a reference on the native signal.
  • heapStats().objectTypeCounts.AbortSignal from bun:jsc is the number of live AbortSignal wrappers in the JS heap after the last collection.
  • ASAN's allocator keeps freed allocations in a quarantine (256 MB by default) to detect use after free, so under ASAN RSS tracks the volume allocated rather than the volume retained unless the quarantine is disabled.

…assert its numbers

The child churned 80k signals with fixed sleeps between GCs and only its
exit code was checked, which took 13s on the ASAN lane and 95s on a local
debug build. The ASAN threshold (256 MB) was also above what the leak
produces (~60 MB), and with ASAN's quarantine enabled RSS grows by
everything the child allocates, so that lane could not fail.

Now the child churns small batches after a warmup batch and reports how
many AbortSignal wrappers survive a full GC plus the RSS growth per batch.
The parent asserts both (every signal survives when the leak is back;
RSS grows 23-34 MB at the release count against a 12 MB bound), checks
stderr and the exit code, and the child runs with quarantine_size_mb=0 so
RSS means the same thing under ASAN. Slow builds churn 1000 signals,
release builds 40k; no sleeps and no per-test timeout.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f306d91e-4507-41b5-801c-f95f52aa7ae1

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 2871321.

📒 Files selected for processing (1)
  • test/regression/issue/28756.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Test-only change, no src/ touched.

Verified locally on linux x64:

  • bun bd test test/regression/issue/28756.test.ts (debug+ASAN): 94.8s before, 2.5-2.8s after, 0 leaked wrappers and 0-3 MB RSS growth over 20 runs.
  • release bun: 1.1s before, ~0.3s after, 0 leaked wrappers and 2-3 MB RSS growth over 30 runs.
  • Simulated leaks (listener kept / signals kept in an array) fail with 40000 of 40000 AbortSignal wrappers survived GC on release and 1000 of 1000 on debug+ASAN; with that assertion removed the RSS bound alone fails on release at 23-34 MB vs. 12 MB.

Self-review is running; will update here if it turns anything up. Waiting on CI for the ASAN lane timing.

@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-only rewrite that makes the #28756 leak test faster and its assertions meaningful.

What was reviewed:

  • Harness/idiom conformance: isDebug/isASAN branching, ASAN_OPTIONS=...:quarantine_size_mb=0 join, and heapStats().objectTypeCounts.AbortSignal all match existing leak-test patterns in the repo.
  • Assertion strength: previously the ASAN bound (256 MB vs ~60 MB leak) was vacuous; now wrapper-count carries slow builds and RSS carries release, with the exit code asserted last.
  • The remaining setImmediate between GCs is not a sleep — it's the FinalizationRegistry callback turn, and the 300s timeout / 50–100ms sleeps are gone.
Extended reasoning...

Overview

This PR rewrites a single regression test file, test/regression/issue/28756.test.ts, which guards against the AbortSignal.timeout + util.aborted memory leak. No production code is touched. The rewrite (a) drops the workload on debug/ASAN builds from 80k signals + sleeps (~95s locally) to 1k signals with no sleeps (~2.5s), (b) moves pass/fail from the child's exit code to structured JSON that the parent asserts on, and (c) adds a direct heapStats().objectTypeCounts.AbortSignal wrapper-count assertion so slow builds can detect the leak without needing enough signals for RSS to separate.

Security risks

None. Test-only change; the child is spawned with bunExe() and bunEnv, no network, no filesystem writes, no untrusted input.

Level of scrutiny

Low-to-moderate. It's a leak test rewrite, so the main risks are (1) making the test vacuous, (2) making it flaky, or (3) breaking harness conventions. I checked each against REVIEW.md's "Tests reviewers reject" section:

  • Not vacuous: the old ASAN bound of 256 MB could not fail (leak is ~60 MB). The new test asserts leakedSignals < batchSize (leak produces batchSize * batches) and RSS < 12 MB (leak produces 23–34 MB on release). The PR description documents that both assertions were exercised by simulating the leak and confirming failure.
  • Not a sleep-for-condition: the only remaining async wait is setImmediate between two GCs, which the comment and PR description explain is the required event-loop turn for FinalizationRegistry callbacks — not a timing hack. The 50/100ms setTimeouts and 300s per-test timeout are removed.
  • RSS threshold branches on build type: isASAN || isDebug gates the workload size, and ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":") matches the exact pattern in json5.test.ts, archive.test.ts, and others.
  • Pipes drained concurrently, stderr asserted empty, exit code asserted last.

Other factors

All helpers used (isDebug, isASAN, bunEnv.ASAN_OPTIONS join, Bun.unsafe.memoryFootprint on darwin, objectTypeCounts.AbortSignal) are already in use across multiple other leak tests, so there's no novel pattern here. The PR description records 20 debug+ASAN runs and 30 release runs with observed ranges, plus negative verification by re-introducing the leak two different ways. The comment block in the file records the measured numbers so future readers can see why 12 MB and < batchSize were chosen.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:13 PM PT - Aug 13th, 2026

❌ @robobun, your commit 2871321 has 1 failures in Build #94735 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38197

That installs a local version of the PR into your bun-38197 executable, so you can run:

bun-38197 --bun

This branch has not been deployed

No deployments
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.

1 participant