From f6838b661222971522df941b2981caa0b5beaf4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:04:58 +0000 Subject: [PATCH 1/3] test(serve): create the websocket client outside the scope that holds the server The client's close handler is the last function native code calls before the test measures collection. A pointer to it stays in a live native frame (on the Windows x64 release build it sits in uv_run's frame, inside uv__poll's uninitialized OVERLAPPED_ENTRY array) and the conservative scan keeps it alive. Declared next to the server, the handler shared the server's scope and kept the server alive with it. Declared outside that scope, it retains nothing the test measures. --- test/CLAUDE.md | 1 + test/js/bun/http/bun-server.test.ts | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/test/CLAUDE.md b/test/CLAUDE.md index cc72a234887c..46149a44d64f 100644 --- a/test/CLAUDE.md +++ b/test/CLAUDE.md @@ -20,6 +20,7 @@ Use `bun:test` with files that end in `*.test.{ts,js,jsx,tsx,mjs,cjs}`. If it's - **Do not write flaky tests**. Unless explicitly asked, **never wait for time to pass in tests**. Always wait for the condition to be met instead of waiting for an arbitrary amount of time. **Never use hardcoded port numbers**. Always use `port: 0` to get a random port. - **Prefer concurrent tests over sequential tests**: When multiple tests in the same file spawn processes or write files, make them concurrent with `test.concurrent` or `describe.concurrent` unless it's very difficult to make them concurrent. +- **In a test that expects an object to be collected, the callbacks that run last must not share a scope with that object.** JSC scans the native stack conservatively, and a pointer to the JS function that native code called most recently (an event handler, a timer callback, a promise reaction) can stay in a live native frame for many event loop turns. A function keeps its whole scope alive, so a callback declared next to the object keeps the object alive too. Declare such callbacks in a scope that does not contain the object (see "server stays alive while a websocket is connected" in `test/js/bun/http/bun-server.test.ts`). ### Spawning processes diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index fafa6814f8ea..7174cca58b89 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -3181,11 +3181,25 @@ describe("handler GC tracing (heapStats wrapper-count)", () => { const echoed = Promise.withResolvers(); const closed = Promise.withResolvers(); - // Scope server so the only post-stop root is the connected websocket. - // Assign client directly to the outer var rather than returning it — - // returning keeps the async frame's scope (which contains server) - // alive via the resolved-value chain in JSC. + // The client and its handlers are created here, outside the scope that + // holds server. The client's close handler is the last function native + // code calls before the measurement below, and a pointer to it stays on + // the native stack (conservatively scanned) for a while. Had it been + // created next to server, it would share server's scope and keep the + // server alive through that stale pointer, which is not what this test + // measures. let client; + function connect(url) { + client = new WebSocket(url); + client.onopen = () => clientOpen.resolve(); + client.onmessage = e => echoed.resolve(e.data); + client.onclose = () => closed.resolve(); + } + + // Scope server so the only post-stop root is the connected websocket. + // Nothing is returned from the arrow: a returned value would keep the + // async frame's scope (which contains server) alive via the + // resolved-value chain in JSC. await (async () => { const server = Bun.serve({ port: 0, @@ -3197,10 +3211,7 @@ describe("handler GC tracing (heapStats wrapper-count)", () => { message(ws, m) { ws.send(server.port + ":" + m); }, }, }); - client = new WebSocket(server.url.href.replace("http", "ws")); - client.onopen = () => clientOpen.resolve(); - client.onmessage = e => echoed.resolve(e.data); - client.onclose = () => closed.resolve(); + connect(server.url.href.replace("http", "ws")); await opened.promise; // server-side ws created (roots wrapper) await clientOpen.promise; // client ready to send (avoid InvalidStateError) server.stop(); // graceful — listener gone, ws stays From bec8dc64635d9df8ac1c7b350a5260b90fb754da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:13:23 +0000 Subject: [PATCH 2/3] test(serve): drop the test/CLAUDE.md addition --- test/CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/test/CLAUDE.md b/test/CLAUDE.md index 46149a44d64f..cc72a234887c 100644 --- a/test/CLAUDE.md +++ b/test/CLAUDE.md @@ -20,7 +20,6 @@ Use `bun:test` with files that end in `*.test.{ts,js,jsx,tsx,mjs,cjs}`. If it's - **Do not write flaky tests**. Unless explicitly asked, **never wait for time to pass in tests**. Always wait for the condition to be met instead of waiting for an arbitrary amount of time. **Never use hardcoded port numbers**. Always use `port: 0` to get a random port. - **Prefer concurrent tests over sequential tests**: When multiple tests in the same file spawn processes or write files, make them concurrent with `test.concurrent` or `describe.concurrent` unless it's very difficult to make them concurrent. -- **In a test that expects an object to be collected, the callbacks that run last must not share a scope with that object.** JSC scans the native stack conservatively, and a pointer to the JS function that native code called most recently (an event handler, a timer callback, a promise reaction) can stay in a live native frame for many event loop turns. A function keeps its whole scope alive, so a callback declared next to the object keeps the object alive too. Declare such callbacks in a scope that does not contain the object (see "server stays alive while a websocket is connected" in `test/js/bun/http/bun-server.test.ts`). ### Spawning processes From 3c1ac84f343b9cb0f5e8987c5f0690d5bd41b391 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:32:22 +0000 Subject: [PATCH 3/3] ci: retrigger