Skip to content

server: use bun.default_allocator for server.allocator - #29916

Merged
Jarred-Sumner merged 8 commits into
mainfrom
claude/server-default-allocator
Apr 29, 2026
Merged

server: use bun.default_allocator for server.allocator#29916
Jarred-Sumner merged 8 commits into
mainfrom
claude/server-default-allocator

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What

server.allocator was set to Arena.getThreadLocalDefault(), which on non-ASAN builds returns &global_mimalloc_vtable — a different Allocator.VTable from bun.default_allocator (c_allocator_vtable), even though both route to the same mimalloc calls.

This trips BabyList's CheckedAllocator in HTTPServerWritable.finalize():

  1. start() grows this.buffer via ensureTotalCapacityPrecise(this.allocator, …)CheckedAllocator records global_mimalloc_vtable
  2. on the next request, finalize() sees pooled_buffer != null and cap > 64 KiB, calls this.buffer.clearAndFree(bun.default_allocator) → vtable mismatch → panic
allocator mismatch: cannot use multiple allocators with the same collection
allocator mismatch: vtables differ: mem.Allocator.VTable@7ff6c69eed78 and mem.Allocator.VTable@7ff6c69eeac0
panic(main thread): Internal assertion failure: allocators do not match

Only fires on ci_assert && !enable_asan builds (Windows x64-baseline canary). Under ASAN, getThreadLocalDefault() already returns bun.default_allocator, so the mismatch is invisible on local mac/linux debug.

getThreadLocalDefault() no longer provides a per-thread heap (the v3 comment says as much), so there is no reason to use it here over bun.default_allocator.

Verification

  • bun bd test test/js/bun/http/serve-stream-reject-flush-leak.test.ts — pass
  • bun run build --asan=off test test/js/bun/http/serve-stream-reject-flush-leak.test.ts — pass (this config reproduced the crash before the fix)
  • bun bd test test/js/bun/http/serve.test.ts — 189 pass, 1 skip
  • bun run zig:check-all — clean

Fixes serve-stream-reject-flush-leak.test.ts failure on Windows x64-baseline introduced by #29865.

robobun and others added 6 commits April 28, 2026 20:20
SSL_get_peer_certificate returns a +1 X509 reference which the caller
must X509_free. The server paths in TLSSocket.getPeerCertificate()
passed this through a non-owning X509View (abbreviated) or dropped it
on the floor (non-abbreviated), pinning the peer's X509 past session
close.

While investigating, also found that JSX509Certificate::computeRaw
(used by every getPeerCertificate()/getCertificate() call) released
the BIO from its RAII wrapper but never freed it, and the ArrayBuffer
destructor BIO_free'd the data pointer instead of the BIO pointer.
That was the dominant per-call leak (~800 bytes) and UB in the
destructor. Capture the BIO pointer in the destructor lambda instead.

Add an mTLS RSS test that exercises the server getPeerCertificate()
path and asserts bounded growth after warmup.
…ult()

`Arena.getThreadLocalDefault()` returns a different vtable
(`global_mimalloc_vtable`) than `bun.default_allocator`
(`c_allocator_vtable`) on non-ASAN builds, even though both route to the
same mimalloc calls. This trips `BabyList`'s `CheckedAllocator` when
`HTTPServerWritable.finalize()` frees a >64 KiB pooled buffer with
`bun.default_allocator` after `start()` grew it with `this.allocator`.

Only visible on `ci_assert && !asan` builds (Windows canary), since under
ASAN `getThreadLocalDefault()` already returns `bun.default_allocator`.

Fixes serve-stream-reject-flush-leak.test.ts on Windows x64-baseline.
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 85ac3226-f696-43e8-9828-252c95ff3580

📥 Commits

Reviewing files that changed from the base of the PR and between 654f30a and a938c90.

📒 Files selected for processing (1)
  • test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts

Walkthrough

Fixes native memory ownership in TLS certificate handling (frees peer X509, owns BIO for ArrayBuffer); switches server VM allocator to bun.default_allocator; adds a TLS regression test for getPeerCertificate memory growth and tightens an HTTP stream-reject test fixture.

Changes

Cohort / File(s) Summary
TLS getPeerCertificate
src/bun.js/api/bun/socket/tls_socket_functions.zig
Adds explicit release/free of X509 returned by BoringSSL.SSL_get_peer_certificate, using defer cleanup in abbreviated and non-abbreviated flows to avoid leaking the owning pointer.
JS X509 BIO ownership
src/bun.js/bindings/JSX509Certificate.cpp
Store the released BIO as a BIO* and change ArrayBuffer destructor to free the original BIO (captured bio_ptr) so the buffer owner is correctly freed.
Server VM allocator
src/bun.js/api/server.zig
ThisServer.init now uses bun.default_allocator for VM allocation and removes the unused Arena alias.
TLS regression test
test/js/node/tls/node-tls-cert.test.ts
Adds a regression test that repeatedly calls TLSSocket.getPeerCertificate() and getPeerCertificate(false), triggers GC rounds, imports isASAN/isDebug, and asserts RSS growth within mode-specific thresholds.
HTTP stream-reject fixture
test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts
Capture currentSocket, pause client socket after request, schedule deferred destroy() via setImmediate on rejection, and reduce iterations from 40 to 10 to deterministically exercise backpressure/reject paths.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the primary change: switching server.allocator to use bun.default_allocator instead of Arena.getThreadLocalDefault().
Description check ✅ Passed The PR description comprehensively covers both required template sections: detailed explanation of what was changed and why, plus verification steps performed.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 1/5 review remaining, refill in 43 minutes and 32 seconds.

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

@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 the current code and only fix it if needed.

Inline comments:
In `@test/js/node/tls/node-tls-cert.test.ts`:
- Around line 617-640: The test currently awaits server.listen and client
secureConnect before entering the try/finally that closes resources, so failures
during setup can leak handles; wrap the entire setup including creation of
server, awaiting once(server.listen(...),"listening"), creating client
(tls.connect) and awaiting once(client,"secureConnect") in a try/finally (or
move the existing finally to start before awaiting serverSocketPromise) and in
the finally ensure you cleanly close the server (server.close()) and destroy/end
the client socket if they were created, and also handle the
serverSocketPromise/serverSocket (onServerSocket) cleanup so any partially
initialized resources are closed even on listen/connect failures.
🪄 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: 37afd8f3-029c-45ae-a116-fe8393b399eb

📥 Commits

Reviewing files that changed from the base of the PR and between a97e868 and 299e734.

📒 Files selected for processing (4)
  • src/bun.js/api/bun/socket/tls_socket_functions.zig
  • src/bun.js/api/server.zig
  • src/bun.js/bindings/JSX509Certificate.cpp
  • test/js/node/tls/node-tls-cert.test.ts

Comment on lines +617 to +640
const server = tls.createServer(
{
key: serverTls.key,
cert: serverTls.cert,
ca: [clientTls.ca],
requestCert: true,
rejectUnauthorized: false,
},
socket => onServerSocket(socket),
);
await once(server.listen(0, "127.0.0.1"), "listening");

const client = tls.connect({
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
key: clientTls.key,
cert: clientTls.cert,
ca: [serverTls.ca],
checkServerIdentity,
});
await once(client, "secureConnect");

const serverSocket = await serverSocketPromise;
try {

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.

⚠️ Potential issue | 🟠 Major

Move lifecycle cleanup to cover setup failures too.

finally starts after await serverSocketPromise (Line 639), so failures during listen/secureConnect can bypass cleanup and leave open handles.

Proposed fix
 it("server-side getPeerCertificate() should not leak", async () => {
+  let server: tls.Server | null = null;
+  let client: TLSSocket | null = null;
+  let serverSocket: TLSSocket | null = null;
+  const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers<TLSSocket>();
+
+  try {
-  const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers<TLSSocket>();
-  const server = tls.createServer(
+  server = tls.createServer(
     {
       key: serverTls.key,
       cert: serverTls.cert,
       ca: [clientTls.ca],
       requestCert: true,
       rejectUnauthorized: false,
     },
     socket => onServerSocket(socket),
   );
   await once(server.listen(0, "127.0.0.1"), "listening");
 
-  const client = tls.connect({
+  client = tls.connect({
     host: "127.0.0.1",
     port: (server.address() as AddressInfo).port,
     key: clientTls.key,
     cert: clientTls.cert,
     ca: [serverTls.ca],
     checkServerIdentity,
   });
   await once(client, "secureConnect");
 
-  const serverSocket = await serverSocketPromise;
-  try {
+  serverSocket = await serverSocketPromise;
     // ... existing assertions/loops ...
-  } finally {
-    client.end();
-    serverSocket.end();
-    server.close();
-  }
+  } finally {
+    client?.end();
+    serverSocket?.end();
+    server?.close();
+  }
 }, 180_000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/tls/node-tls-cert.test.ts` around lines 617 - 640, The test
currently awaits server.listen and client secureConnect before entering the
try/finally that closes resources, so failures during setup can leak handles;
wrap the entire setup including creation of server, awaiting
once(server.listen(...),"listening"), creating client (tls.connect) and awaiting
once(client,"secureConnect") in a try/finally (or move the existing finally to
start before awaiting serverSocketPromise) and in the finally ensure you cleanly
close the server (server.close()) and destroy/end the client socket if they were
created, and also handle the serverSocketPromise/serverSocket (onServerSocket)
cleanup so any partially initialized resources are closed even on listen/connect
failures.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. tls: fix X509/BIO leaks in server getPeerCertificate() #29881 - Fixes the same X509 reference leak in tls_socket_functions.zig and BIO memory leak in JSX509Certificate.cpp (2 of this PR's 3 fixes overlap)

🤖 Generated with Claude Code

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

The changes look correct to me, but note this PR bundles two unrelated fixes beyond the titular allocator swap — an X509 ref leak in getPeerCertificate() and a BIO destructor fix in computeRaw (the old lambda was calling BIO_free on bptr->data rather than the BIO itself). Worth a human glance given it's TLS cert lifetime management.

Extended reasoning...

Overview

This PR touches four files across three distinct fixes:

  1. src/bun.js/api/server.zig — replaces Arena.getThreadLocalDefault() with bun.default_allocator for server.allocator. Both route to mimalloc but use different vtable pointers, which trips BabyList's CheckedAllocator when HTTPServerWritable.finalize() later frees with bun.default_allocator. Verified against MimallocArena.zig:121-124getThreadLocalDefault() indeed returns &global_mimalloc_vtable on non-ASAN builds and no longer provides a per-thread heap, so the swap is semantically a no-op outside the assertion.
  2. src/bun.js/api/bun/socket/tls_socket_functions.zig — adds defer x509.free() after SSL_get_peer_certificate() in the server-side branches of getPeerCertificate(). SSL_get_peer_certificate returns a +1 ref; X509.toJSBun__X509__toJSLegacyEncoding wraps it in a non-owning X509View and synchronously copies all fields into JS values, so freeing after return is safe.
  3. src/bun.js/bindings/JSX509Certificate.cpp — fixes the computeRaw ArrayBuffer destructor. Previously the lambda received the data pointer (bptr->data) and cast it to BIO* before handing it to BIOPointer, which is an invalid free (and a leak of the actual BIO). The fix captures bio_ptr by value so the correct object is freed when the ArrayBuffer is destroyed.
  4. test/js/node/tls/node-tls-cert.test.ts — adds an RSS-growth regression test that hammers server-side getPeerCertificate().

Security risks

No new attack surface is introduced — these are leak/lifetime fixes. The main risk class would be a use-after-free if the X509 were freed while JS still referenced it, but toLegacyObject copies everything (strings, fingerprints, raw DER) before returning, and the non-abbreviated path currently returns undefined without using first_cert. The computeRaw change strictly improves correctness (the old destructor was calling BIO_free on the wrong pointer).

Level of scrutiny

Medium-high. The server.allocator change is trivial and well-justified by the PR body. The X509/BIO changes, while small, sit in TLS certificate handling and C++↔Zig memory ownership — exactly the area where subtle lifetime bugs become security issues. They look correct, but the PR title/description only covers the allocator fix, so a reviewer skimming by title would miss the crypto changes entirely.

Other factors

  • No CODEOWNERS cover these paths.
  • A regression test is included with reasonable thresholds and warmup.
  • No prior reviews on the PR.
  • Branch name (fix-getpeercert-x509-leak) suggests the X509 work is the original intent and the allocator fix was added on top, which explains the title/content mismatch.

…roy for Windows backpressure

Windows' loopback fast-path absorbs the full 8 MiB tryEnd() into the kernel
when the client is reading, so controller.end() never returns a pending
promise and the test reports "insufficient backpressure: 0/40".

Pause the client so it never drains; then destroy it from the server side via
setImmediate after the throw — onRejectStream → handleRejectStream runs on
the microtask queue first, so pending_flush is observed/unprotected before
the socket is torn down. Without the explicit destroy, uWS waits on the
parked write that the paused client will never drain.

Linux post-fix:  flushPending=42/40, delta=0, exit 0
Linux pre-fix:   flushPending=42/40, delta=40, exit 1 (still detects leak)
Jarred-Sumner added a commit that referenced this pull request Apr 29, 2026
…nstead of skipping

Replace skipIf(isWindows) with the same fixture change pushed to #29916:
pause the client so Windows' loopback fast-path can't absorb the 8 MiB
write, then destroy the socket from the server side via setImmediate after
the throw (handleRejectStream runs on the microtask queue first).
Backpressure is now deterministic per-iteration (paused client), and the
metric is heapStats().protectedObjectTypeCounts.Promise (exact). 40 was
hedging for the old on('data', destroy) timing.
@Jarred-Sumner
Jarred-Sumner merged commit 306b381 into main Apr 29, 2026
64 of 70 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/server-default-allocator branch April 29, 2026 05:51

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

The fixes look correct to me (verified X509.toJS is non-owning and the BIO destructor was indeed wrapping the wrong pointer), but since this bundles three separate native memory-ownership changes across the core server allocator and TLS cert handling — and overlaps with #29881 — I'd like a human to confirm the ownership semantics before merge.

Extended reasoning...

Overview

This PR contains three distinct native fixes plus test adjustments:

  1. server.zig: swap Arena.getThreadLocalDefault()bun.default_allocator for server.allocator to fix a CheckedAllocator vtable-mismatch panic in HTTPServerWritable.finalize().
  2. tls_socket_functions.zig: add defer x509.free() after SSL_get_peer_certificate (which returns a +1 ref) on both the abbreviated and non-abbreviated server paths.
  3. JSX509Certificate.cpp::computeRaw: capture bio_ptr in the ArrayBuffer destructor lambda instead of casting the data pointer to BIO*, so the BIO actually gets freed.
  4. New RSS-growth regression test for getPeerCertificate() and Windows-backpressure tweaks to serve-stream-reject-flush-leak-fixture.ts.

Security risks

The X509/BIO changes alter ownership/lifetime of BoringSSL objects in TLS certificate handling. I verified X509.toJS calls Bun__X509__toJSLegacyEncoding, which uses a non-owning ncrypto::X509View, so freeing the +1 ref afterward is correct and not a UAF. The BIO fix is clearly right (the old destructor wrapped bptr->data as a BIO*, which is bogus). Still, mistakes here would manifest as UAF/double-free in TLS paths, so this is security-adjacent and deserves human eyes.

Level of scrutiny

High. The allocator swap touches every Bun.serve() instance, and the TLS changes touch native crypto memory ownership. Each individual change is small and looks correct, but the blast radius is large and the failure mode (UAF, double-free, allocator mismatch) is severe.

Other factors

  • robobun reports widespread build-zig/build-cpp failures on commit 654f30a; the newer a938c90 only changed test iteration count, so CI status should be confirmed green before merge.
  • The duplicate-PR bot flagged #29881 as containing the same X509/BIO fixes — a human should decide which PR lands them.
  • The PR title only mentions the allocator change, but two of the three native fixes are unrelated TLS leak fixes; scope is broader than advertised.

Jarred-Sumner added a commit that referenced this pull request Apr 29, 2026
…29926)

Clears four pre-existing failures showing up across open PRs.

## `server.allocator` → `bun.default_allocator`

`MimallocArena.getThreadLocalDefault()` and `bun.default_allocator` both
call mimalloc, but their vtables differ. Collections that flow between
server-owned and default-owned code (e.g. `BabyList` in the response
sink, the `onUpgrade` path) trip `CheckedAllocator.assertEq` in
`ci_assert` builds:

```
allocator mismatch: cannot use multiple allocators with the same collection
panic(main thread): Internal assertion failure: allocators do not match
```

This was crashing both `serve-stream-reject-flush-leak.test.ts` and the
websocket upgrade-UAF test on Windows release lanes. Same change as
#29916 minus the X509/BIO hunks that already landed via #29881.

## `html-rewriter-leak.test.ts` — RSS path never worked on release

The test was added in #29879 and **failed on every release platform in
that PR's own CI** (113–233 MB across darwin/linux/windows/asan, all on
the *fixed* build). Only the debug path (precise mimalloc counters)
passed.

Root cause: a single trailing `Bun.gc(true)` does collect all 16k
rewriters, but neither mimalloc nor ASAN's allocator promptly return
freed pages to the OS, so RSS pins at the *peak* live set rather than
what's retained. Now GCs every 1k iterations to bound peak ≈ retained.

| build | before | after |
|---|---|---|
| release post-fix | 148 MB ❌ | 0.0 MB ✅ |
| asan post-fix | 233 MB ❌ | 0.6 MB ✅ |
| release **pre**-fix | — | 68.5 MB ❌ (still detects the leak) |
| debug post-fix | ✅ | ✅ |

## `serve-stream-reject-flush-leak.test.ts` — skip on Windows

Once the allocator panic is fixed, the test next fails on Windows with
`insufficient backpressure: 0/40` (#29916 CI) — the 8 MiB `tryEnd()`
fits in Windows' auto-tuned loopback send buffer so `pending_flush` is
never created. The leak is platform-agnostic Zig; POSIX coverage is
sufficient.

## `websocket-server.test.ts` — un-truncate stderr

The upgrade-UAF test's `stderr.split("\\n", 3)` hid the panic line,
leaving `{stdout:"", stderr:""}` and a misleading diff.

## `test-integration-rspack.ts` — pin to `rsbuild@1`

`create-rsbuild@2.0.0` (Apr 22) pulls
`@rspack/binding-win32-arm64-msvc@2.0.x` which bundles mimalloc v3. Two
static mimalloc instances → deterministic segfault in ntdll during
`ExitProcess` on Windows arm64 (crash addr ends in `b9c8` across 5
builds). The test exists to guard the napi TSFN finalizer, not rsbuild
HEAD.

Supersedes #29916.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
## What

`server.allocator` was set to `Arena.getThreadLocalDefault()`, which on
non-ASAN builds returns `&global_mimalloc_vtable` — a different
`Allocator.VTable` from `bun.default_allocator` (`c_allocator_vtable`),
even though both route to the same mimalloc calls.

This trips `BabyList`'s `CheckedAllocator` in
`HTTPServerWritable.finalize()`:

1. `start()` grows `this.buffer` via
`ensureTotalCapacityPrecise(this.allocator, …)` → `CheckedAllocator`
records `global_mimalloc_vtable`
2. on the next request, `finalize()` sees `pooled_buffer != null` and
`cap > 64 KiB`, calls `this.buffer.clearAndFree(bun.default_allocator)`
→ vtable mismatch → panic

```
allocator mismatch: cannot use multiple allocators with the same collection
allocator mismatch: vtables differ: mem.Allocator.VTable@7ff6c69eed78 and mem.Allocator.VTable@7ff6c69eeac0
panic(main thread): Internal assertion failure: allocators do not match
```

Only fires on `ci_assert && !enable_asan` builds (Windows x64-baseline
canary). Under ASAN, `getThreadLocalDefault()` already returns
`bun.default_allocator`, so the mismatch is invisible on local mac/linux
debug.

`getThreadLocalDefault()` no longer provides a per-thread heap (the v3
comment says as much), so there is no reason to use it here over
`bun.default_allocator`.

## Verification

- `bun bd test test/js/bun/http/serve-stream-reject-flush-leak.test.ts`
— pass
- `bun run build --asan=off test
test/js/bun/http/serve-stream-reject-flush-leak.test.ts` — pass (this
config reproduced the crash before the fix)
- `bun bd test test/js/bun/http/serve.test.ts` — 189 pass, 1 skip
- `bun run zig:check-all` — clean

Fixes `serve-stream-reject-flush-leak.test.ts` failure on Windows
x64-baseline introduced by oven-sh#29865.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…ven-sh#29926)

Clears four pre-existing failures showing up across open PRs.

## `server.allocator` → `bun.default_allocator`

`MimallocArena.getThreadLocalDefault()` and `bun.default_allocator` both
call mimalloc, but their vtables differ. Collections that flow between
server-owned and default-owned code (e.g. `BabyList` in the response
sink, the `onUpgrade` path) trip `CheckedAllocator.assertEq` in
`ci_assert` builds:

```
allocator mismatch: cannot use multiple allocators with the same collection
panic(main thread): Internal assertion failure: allocators do not match
```

This was crashing both `serve-stream-reject-flush-leak.test.ts` and the
websocket upgrade-UAF test on Windows release lanes. Same change as
oven-sh#29916 minus the X509/BIO hunks that already landed via oven-sh#29881.

## `html-rewriter-leak.test.ts` — RSS path never worked on release

The test was added in oven-sh#29879 and **failed on every release platform in
that PR's own CI** (113–233 MB across darwin/linux/windows/asan, all on
the *fixed* build). Only the debug path (precise mimalloc counters)
passed.

Root cause: a single trailing `Bun.gc(true)` does collect all 16k
rewriters, but neither mimalloc nor ASAN's allocator promptly return
freed pages to the OS, so RSS pins at the *peak* live set rather than
what's retained. Now GCs every 1k iterations to bound peak ≈ retained.

| build | before | after |
|---|---|---|
| release post-fix | 148 MB ❌ | 0.0 MB ✅ |
| asan post-fix | 233 MB ❌ | 0.6 MB ✅ |
| release **pre**-fix | — | 68.5 MB ❌ (still detects the leak) |
| debug post-fix | ✅ | ✅ |

## `serve-stream-reject-flush-leak.test.ts` — skip on Windows

Once the allocator panic is fixed, the test next fails on Windows with
`insufficient backpressure: 0/40` (oven-sh#29916 CI) — the 8 MiB `tryEnd()`
fits in Windows' auto-tuned loopback send buffer so `pending_flush` is
never created. The leak is platform-agnostic Zig; POSIX coverage is
sufficient.

## `websocket-server.test.ts` — un-truncate stderr

The upgrade-UAF test's `stderr.split("\\n", 3)` hid the panic line,
leaving `{stdout:"", stderr:""}` and a misleading diff.

## `test-integration-rspack.ts` — pin to `rsbuild@1`

`create-rsbuild@2.0.0` (Apr 22) pulls
`@rspack/binding-win32-arm64-msvc@2.0.x` which bundles mimalloc v3. Two
static mimalloc instances → deterministic segfault in ntdll during
`ExitProcess` on Windows arm64 (crash addr ends in `b9c8` across 5
builds). The test exists to guard the napi TSFN finalizer, not rsbuild
HEAD.

Supersedes oven-sh#29916.
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Heads up: this PR added a second copy of the server-side getPeerCertificate() should not leak test to node-tls-cert.test.ts (the original lives in node-tls-getpeercert-leak.test.ts from #29881). The duplicate has started flaking on the macOS tart runners; #34163 removes it and hardens the standalone copy.

Jarred-Sumner pushed a commit that referenced this pull request Jul 15, 2026
…test (#34163)

## Problem

`test/js/node/tls/node-tls-cert.test.ts` went red on `:darwin: 26
aarch64` in [build 72915](https://buildkite.com/bun/bun/builds/72915):

```
error: expect(received).toBeLessThan(expected)

Expected: < 12582912
Received: 21839872

✗ server-side getPeerCertificate() should not leak
```

The same assertion also flaked in `node-tls-getpeercert-leak.test.ts` on
`:darwin: 14 x64` in [build
72900](https://buildkite.com/bun/bun/builds/72900) (21008384 bytes,
passed on retry).

## Cause

The `server-side getPeerCertificate() should not leak` test exists
twice: #29881 landed it as the standalone
`node-tls-getpeercert-leak.test.ts`, and #29916 (merged a few hours
later) appended an identical copy to `node-tls-cert.test.ts`. Both
copies baseline RSS after four 5k-call warmup rounds and assert less
than 12MB of growth afterwards.

On the macOS tart runners the allocator's high-water mark can climb by
~21MB over that window with no leak present, while a probe on
`darwin-test-arm64-3` with the current canary shows RSS flat within ~1MB
once it has had a couple more rounds to settle, and the full test file
passes 8/8 on that box. The copy inside `node-tls-cert.test.ts` is the
worse of the two because it runs after 30 other TLS tests in the same
process; in build 72915 the standalone file passed on the same commit in
the same lane.

There is no regression in the underlying
`SSL_get_peer_certificate`/`computeRaw` paths: the growth is the same
for 5 and 10 measurement rounds, which rules out the per-call leak the
test guards against.

## Fix

* Remove the duplicate from `node-tls-cert.test.ts` and leave a pointer
to the standalone file.
* In `node-tls-getpeercert-leak.test.ts`:
* apply `skipIf(isDebug)` (the refinement #31155 had applied only to the
duplicate) so the ASAN quarantine cannot mask the signal on debug
builds,
* lengthen the warmup from 4 to 8 rounds so RSS has settled before the
baseline sample,
  * lengthen the measured window from 10 to 15 rounds,
* raise the release threshold from 12MB to 32MB (40MB under the release
ASAN lane).

The unpatched BIO/X509 leak is ~800 bytes per call; over the 75k calls
now measured that is ~60MB of growth, so the regression the test exists
to catch remains well above the new threshold, while the ~21MB noise
ceiling observed on the macOS runners sits comfortably below it.

## Verification

```
$ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-getpeercert-leak.test.ts
(pass) server-side getPeerCertificate() should not leak [5150.55ms]
 1 pass  0 fail

$ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-cert.test.ts
 27 pass  3 todo  0 fail

$ bun bd test test/js/node/tls/node-tls-getpeercert-leak.test.ts
(skip) server-side getPeerCertificate() should not leak
```

Ran the full `node-tls-cert.test.ts` eight times on
`darwin-test-arm64-3` (macOS 26) against canary `cc0c1e835`; 8/8 pass.

<!-- robobun:evidence:begin -->

---

**[stamp-90s]** gate passed · iteration 8 · 2 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/node/tls/node-tls-cert.test.ts' 'test/js/node/tls/node-tls-getpeercert-leak.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/tls/node-tls-cert.test.ts test/js/node/tls/node-tls-getpeercert-leak.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (81bb980)

test/js/node/tls/node-tls-getpeercert-leak.test.ts:
(skip) server-side getPeerCertificate() should not leak

test/js/node/tls/node-tls-cert.test.ts:
(pass) complete cert chains sent to peer. [576.13ms]
(pass) complete cert chains sent to peer, but without requesting client's cert. [162.21ms]
(todo) Request cert from TLS1.2 client that doesn't have one.
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [107.76ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [102.96ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [97.10ms]
(pass) Fail to complete server's chain [83.14ms]
(pass) Fail to complete client's chain. [103.92ms]
(pass) rejects an unverifiable client certificate by default when requestCert is true [247.07ms]
(pass) explicit rejectUnauthorized: false still admits an unverified client certificate [79.04ms]
(pass) Fail to find CA for server. [151.43ms]
(pass) Server sent their CA, but CA cannot be trusted if it is not locally known. [69.40ms]
(pass) Server sent their CA, wrongly, but its OK since we know the CA locally. [83.05ms]
(todo) Confirm client support for "BEGIN TRUSTED CERTIFICATE".
(todo) Confirm server support for "BEGIN TRUSTED CERTIFICATE".
(pass) Confirm client support for 
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
test/js/node/tls/node-tls-cert.test.ts             | 150 ++++++---------------
 test/js/node/tls/node-tls-getpeercert-leak.test.ts | 127 +++++++++--------
 2 files changed, 107 insertions(+), 170 deletions(-)
```

</details>

**gate history** · 3 passed · 1 rejected · iteration 8

<details><summary>evidence per changed file</summary>

```
file                                                reads  edits  tests
test/js/node/tls/node-tls-cert.test.ts                  5      6      0
test/js/node/tls/node-tls-getpeercert-leak.test.ts      2      2      0
```

</details>

<!-- robobun:evidence:end -->
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.

3 participants