Skip to content

test(net): check upgradeTLS wrapper retention from the GC roots, not from a live-object count - #42877

Open
robobun wants to merge 2 commits into
mainfrom
robobun/8e8d8516/socket-retention-exact-roots
Open

robobun wants to merge 2 commits into
mainfrom
robobun/8e8d8516/socket-retention-exact-roots

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The test reads heapStats().protectedObjectTypeCounts: the Strong moves on upgrade (TLSSocket +2, TCPSocket -1) and is gone after close. These counts are exact.
  • A debugging heap snapshot shows that no recorded GC root reaches a TLSSocket wrapper. It must reach the prototype, which proves the walk works.
  • The connections run in a subprocess, where the snapshot is small. No GC timing is left, so the Windows skip is removed.
  • Verified on a release-asan build of the red commit 71c3a0a: old test fails 5 of 5, new passes 10 of 10. Without the raw twin's downgrade the new test fails. Also passes with bun bd, release, Windows x64 and aarch64.

Background

  • TCPSocket and TLSSocket hold their JS wrapper through a JsRef: a Strong while the socket is active, a weak pointer after close. upgradeTLS returns two wrappers for one connection.
  • JSC scans the machine stack conservatively. A word that looks like a cell pointer keeps that cell alive, also a stale word in a live frame.
  • generateHeapSnapshotForDebugging() runs a full GC and records its roots and edges, except conservative roots.
Notes

Heap snapshot. Release-asan build of 71c3a0a (the commit of build 116272), after the five connections and two Bun.gc(true): objectTypeCounts.TLSSocket is 3 and protectedObjectTypeCounts.TLSSocket is 0. generateHeapSnapshotForDebugging() lists two TLSSocket wrappers, cells 0x712f75c24180 and 0x712f75c24200 (adjacent, the last pair). Neither has an incoming edge or a roots entry. The third cell is the prototype.

Debugger. gdb on the same binary, breakpoint on JSC::ConservativeRoots::add(void*, void*, JITStubRoutineSet&, CodeBlockSet&) during the next Bun.gc(true). The scanned span is the main thread stack, 42,328 bytes. A search of that span for the two cell addresses finds 6 words. I walked the frame-pointer chain by hand, so JIT frames do not stop it:

0x7fffaf9971a8 (rbp-2952) -> ...180   JSC::runInternalMicrotask, frame 3872 bytes
0x7fffaf9972f8 (rbp-2616) -> ...180   JSC::runInternalMicrotask
0x7fffaf997360 (rbp-2512) -> ...180   JSC::runInternalMicrotask
0x7fffaf997378 (rbp-2488) -> ...200   JSC::runInternalMicrotask
0x7fffaf9973e0 (rbp-2384) -> ...200   JSC::runInternalMicrotask
0x7fffaf996a78 (rbp-920)  -> ...180   JSC::asyncFunctionGeneratorBodyCall, frame 1408 bytes

The chain at that point is auto_tick > drain_timers > __bun_fire_timer > EventLoop::exit > Zig::GlobalObject::drainMicrotasks > VM::drainMicrotasks > runInternalMicrotask > asyncFunctionGeneratorBodyCall > the test function > Bun.gc. This is the resume of await Bun.sleep(10) in gcUntilCountAtMost. Each pass of the loop runs through the same frames at the same addresses.

With bun bd the same search finds 3 words for one wrapper, all in runInternalMicrotask (a 6320-byte frame there). Hardware watchpoints on those 3 words show that the last writer of each is NewSocket<true>::on_data (+4037, +4045, +4510), called through us_internal_ssl_on_data > us_dispatch_data. That frame is popped long before the GC loop starts. runInternalMicrotask is one large switch. On the async-function resume path it does not write the slots of its other cases.

Why it depends on the binary. Release-asan builds (--profile=release-asan --ci=on), BUN_GC_TIMER_DISABLE=1, 60 passes of Bun.gc(true) + Bun.sleep(10) after the five connections:

build TLSSocket wrappers that survive all 60 passes old test
main ca82a34 (before #42822) 1 passes
main 494b4e0 (#42822) 1 passes
main acb2be0 (#42775) 1 passes
main b841a68 (merge base of the red PR builds) 1 passes
71c3a0a (build 116272, an unrelated bake change) 2 fails 5 of 5, Expected: <= 2, Received: 3
71c3a0a with #42775 reverted 1 passes

The stale words are always in runInternalMicrotask. Which of them line up with a wrapper address depends on the frame sizes of the two call chains, and a change to any Rust code in bun_runtime moves them. #42775 is correct. It made the ASAN frames static, and that moved the words to where a second wrapper can be pinned. The ASAN lane runs on PR builds only, so main's own builds never showed it.

Why every retry fails in CI and a local run can pass. The survivors stay until something writes over those words. The GC controller's 1 s repeating timer does: its callback runs at the same stack depth. The old loop is 50 passes. In CI the whole test took 778 ms, so the loop ended before the timer fired. On my machine a pass takes about 20 ms, and in most runs the timer fires inside the loop (the count drops from 3 to 1 at pass 33 to 45). With BUN_GC_TIMER_DISABLE=1 or BUN_GC_TIMER_INTERVAL=60000 the old test fails every time.

Same frame as #41607. That PR found a stale runInternalMicrotask slot behind a fetch-body test on a darwin release build, so this is not specific to ASAN.

Checks on the new assertions.

  • Raw twin's downgrade() removed in mark_inactive: the test fails with protectedAfterClose: 5 and wrappers: 5 (expected 0 and 0).
  • Wrappers kept in a global array (a heap edge, no Strong): the test fails with wrappers: 5. Same result on Windows.
  • The continuation of await done can run inside the close dispatch, before CloseTeardown downgrades the tls wrapper. The script waits one setImmediate turn before it reads the counts.

Subprocess. The first version of this PR ran the same assertions inside the test runner. On the red binary, where the two pinned wrappers exist, that version passed 10 of 10 with the CI environment and 10 of 10 with BUN_GC_TIMER_DISABLE=1, so the snapshot walk does ignore them. The snapshot of the test runner's heap (about 12,000 cells) took 2.5 s in a debug build, and the test needed a timeout. A fresh bun -e process has about 2,800 cells. In that process the red binary pins no wrapper at all (other frames are on the stack), which shows again that the old count depended on the stack layout and not on Bun's references.

Windows. The test was skipped there because the residual count varied. With canary 1.4.3-canary.1+f5649a7ed the new test passes 20 of 20 on Windows Server 2019 x64 and 20 of 20 on Windows 11 aarch64. The whole file passes 3 of 3 on each.

Time. The test takes 1.8 to 2.2 s in a debug build, 0.3 s on release-asan and 60 ms on release, with no timeout of its own. The old loop took 0.8 to 0.9 s on release-asan.

Runs. bun bd test test/js/bun/net/socket-retention.test.ts: 5 pass, 1 skip. Release-asan build of 71c3a0a with the CI environment (BUN_JSC_validateExceptionChecks=1, BUN_DESTRUCT_VM_ON_EXIT=1, LSAN options): 10 of 10. Same binary with BUN_GC_TIMER_DISABLE=1: 10 of 10. Release: 3 of 3.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/net/socket-retention.test.ts

…ntion

The upgradeTLS test counted live TLSSocket cells after a GC loop. JSC scans
the machine stack conservatively, so stale words that NewSocket::on_data left
in stack memory kept the last [raw, tls] pair alive for as long as the live
JSC::runInternalMicrotask frame that resumes the loop covered them. On some
ASAN binaries the count stayed at 3 for the whole loop.

Assert the retention model directly. The protected object counts show the
Strong move from the TCP wrapper to the two TLS wrappers on upgrade and its
release on close. A debugging heap snapshot shows that no recorded GC root
reaches a wrapper after close. Neither depends on the conservative scan.

The test now also runs on Windows.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: ca2814f7-c1d8-4d3b-bbc7-c9af90c5b4c7

📥 Commits

Reviewing files that changed from the base of the PR and between 55c1106 and fdf1aa0.

📒 Files selected for processing (1)
  • test/js/bun/net/socket-retention.test.ts

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


Walkthrough

The upgradeTLS retention test now runs on all platforms. It performs five upgrades in a subprocess, checks wrapper and socket reference counts, and verifies that closed TLS wrappers are absent from protected handles and recorded heap roots.

Changes

TLS socket retention validation

Layer / File(s) Summary
Subprocess retention checks
test/js/bun/net/socket-retention.test.ts
The test replaces GC object counting with protected-handle and heap-root inspection. It validates five TLS upgrades, socket reference changes, and cleanup after closure. Windows no longer skips the test.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to fdf1a

The updated regression test has no supported merge-blocking issue.

🚥 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 clearly identifies the main change: validating upgradeTLS wrapper retention from GC roots instead of live-object counts.
Description check ✅ Passed The description explains the problem, the fix, implementation details, platform impact, and verification results. It does not use the exact template headings, but it provides the required information …

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for a maintainer to merge. This is a test-only change, and it is green on every lane.

Build 116387 ran test/js/bun/net/socket-retention.test.ts in a passing job on each lane: debian 13 x64-asan (the lane that was red), debian and ubuntu x64 and aarch64, alpine x64 and aarch64, darwin x64 and aarch64, Windows 2019 x64 and Windows 11 aarch64 (the test is no longer skipped there).

The build is red for one test that this diff does not touch: test/js/bun/http/serve-pending-promise-abort-leak.test.ts on x64-asan (expect(alive).toBe(0), Received: 1 at line 498). It is red on main too and is reported as a main break. The other entries passed on a retry or alone (no-orphans, compile-node-compile-cache, structuredClone-classes, fs.watch.rewrite, css/color).

How I reproduced the red test:

  1. Build the red commit of build 116272 the way the ASAN lane does: git checkout 71c3a0a2a4 && bun scripts/build.ts --profile=release-asan --ci=on --build-dir=build/release-asan-ci.
  2. Run the old test with the GC controller's 1 s timer off, so that nothing writes over the stale stack words during the loop: BUN_GC_TIMER_DISABLE=1 build/release-asan-ci/bun-asan test test/js/bun/net/socket-retention.test.ts -t upgradeTLS. It fails 5 of 5 with the CI output (Expected: <= 2, Received: 3). With the timer on it fails in about 1 of 3 runs on my machine.
  3. The same binary passes the new test 10 of 10, with and without the timer.

A release-asan build of main at the merge base (b841a68) keeps one wrapper alive the same way and passes the old test. The heap snapshot and the gdb stack search that show what holds the survivors are in the Notes of the PR body.

@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/net/socket-retention.test.ts Outdated
A heap snapshot of the test runner's own heap takes seconds in a debug
build, and the test needed a timeout for it. In a fresh process the heap is
small: the test takes about 2 s in a debug build and 60 ms in a release
build, so the timeout is gone.
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:14 AM PT - Sep 16th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 42877

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

bun-42877 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new blocking issues. 2 optional suggestions (nits or notes on pre-existing code) were found and not posted. Nothing in this review needs a push before merging.

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