Skip to content

test: judge the server of a disposed graph by roots in module-graph-isolation.test.ts - #46267

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/48726176/module-graph-pages-server-released
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/48726176/module-graph-pages-server-released

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

Background

  • JSC scans the machine stack conservatively. A stale word in a live frame keeps a cell alive.
  • A Bun.serve() wrapper is a GC root until its server has no listener, connection or pending request. A building HTML route holds one. dispose() does not cancel a build.
  • generateHeapSnapshotForDebugging() lists cells, edges and roots, not stack words. realmsARootReaches() in this file judges realms the same way.
  • Considered a higher turn limit. Frame layout decides how long the stale word stays.
Notes

What keeps the server (Linux x64, release build of 9b4284f)

The fixture of main, with a heap snapshot taken in turn 16 of its last loop. All builds are done by then.

cell HTTPServer#758 0x22a42a22ec8: NO ROOT REACHES IT; cells that can reach it: 9, of which a root reaches 0
  cell TCPSocket#640 0x22a42a1bb88 size 328 incoming 0 (NOTHING IN THE HEAP REFERS TO IT) in roots no
  cell SocketHandlers#642 incoming 1 (Property:handlers of TCPSocket#640)
  cell Function "open"#2112, "data"#2110, "close"#2111, "error"#644: incoming 1 each (Internal:0 of SocketHandlers#642)
  cell JSLexicalEnvironment#749 incoming 4 (the four functions)
  cell JSLexicalEnvironment#753 incoming 1 (Internal:0 of JSLexicalEnvironment#749)
  cell HTTPServer#758 incoming 1 (Variable:server of JSLexicalEnvironment#753)

gdb on the same process, stopped in JSC::MachineThreads::gatherConservativeRoots of the next collection. One word of the stack holds the address of that socket:

stack word at 0x7fffffffc508 holds 0x22a42a1bb88
   the frame record below it: rbp 0x7fffffffc4f0 (word is rbp+0x18 above it), whose function was called from: JSC::runInternalMicrotask(...)
     rbp 0x7fffffffc4f0 returns to JSC::runInternalMicrotask(...)
     rbp 0x7fffffffc600 returns to JSC::MicrotaskQueue::drainWithUseCallOnEachMicrotask(...) + 469
     rbp 0x7fffffffc880 returns to JSC::VM::drainMicrotasks() + 244

The word is in the outgoing argument area of runInternalMicrotask. An earlier microtask passed the socket there. The turns of the last loop run AsyncFunctionResume microtasks in a frame at the same address, and that path does not write the slot. The word changes in turn 20 or 21, and the count drops in that turn.

Why the test passes or fails with the same word in place

The fixture reads its baseline after the first use(), when two readings are equal. The first server is usually still being built then, so the baseline is 2 (the prototype and that server). With a baseline of 2 the fixture accepts one server that stays. With a baseline of 1 it does not.

The same tolerance hides a real leak. With the last server assigned to a global, the fixture of main prints collected in 3 of 3 runs. The new fixture prints kept 1: {root: ProtectedValues} GlobalObject -> .last HTTPServer.

The macOS x64 lane (build 129752, darwin-x64-14.8.9, 12 cores)

The first push of this PR ran copies of the main fixture on that lane, with counters.

  • One turn of the last loop takes about 1.1 ms there. 2000 turns are about 2 s, which is how long the failed tests took.
  • The builds are not what it waits for. After the last dispose() every build is done and every socket is closed within 27 ms (3 runs).
  • The fixture of main, 12 runs: the baseline is 2 in every run, and the last loop ends in its first turn. None printed kept.
  • The same fixture made to wait for a count of 1, 8 runs: the last server stays for 98 turns in 4 runs, for 14 turns in 1, and for 1 turn in 3.
  • dispose() comes earlier than on Linux: 1 to 6 of the six requests have started a build, and some of the six connects are refused.
  • The two tests of this PR passed at the first attempt.

No run on that lane reached 2000 turns, so there is no heap snapshot of the failure itself. Build 129752 has #46021, and builds with #46021 do not fail on that lane (next section). #46490 runs the report in turn 50 on the macOS and Windows lanes.

The macOS arm64 lane (build 130840 of #46490, 9634520)

#46490 runs the main fixture, made to wait for a count of 1, and prints the report in turn 50 when a server is still there. In 19 of 22 runs on this lane the last server goes in turn 21. In one run it is still there in turn 50:

old: baseline 1, waiting for 1, count 2, 50 turns in 155 ms; counts by turn [[0,3],[1,2]]
cell HTTPServer#592 0x5e42f307108: NO ROOT REACHES IT; cells that can reach it: 8, of which a root reaches 0
  cell SocketHandlers#1076 0x5e42f305948 size 128 incoming 0 (NOTHING IN THE HEAP REFERS TO IT) in roots no
  cell Function "open"#573 0x5e430133640 incoming 1 (Internal:0 of SocketHandlers#1076)
  cell Function "error"#1077, "data"#1078, "close"#1084: incoming 1 each (Internal:0 of SocketHandlers#1076)
  cell JSLexicalEnvironment#577 incoming 4 (the four functions)
  cell JSLexicalEnvironment#582 incoming 1 (Internal:0 of JSLexicalEnvironment#577)
  cell HTTPServer#592 incoming 1 (Variable:server of JSLexicalEnvironment#582)
words that point into those cells: 4
  0x16b67d8f8: 0x5e430133640 = Function "open"#573+0: frame #12, fp-0xe8, frame size 0x140,
     its callee returns to image 0x100434430 (JSC::asyncFunctionGeneratorBodyCall), it returns to image 0x10017a888 (JSC::runInternalMicrotask)
old: COLLECTED LATER (count 1) at turn 135, 890 ms after the report; counts by turn [[0,3],[1,2],[134,1]]
old: the word at 0x16b67d8f8 by turn: [[51,"0x5e430133640"],[134,"0x1098a0d46"]]

The fixture reads the word from its own stack with bun:ffi. The count drops in the turn in which the word changes. This run has a baseline of 1: with a word that stays past 2000 turns it is the failure.

The two function names come from the canary bun-profile of that commit. The test binary has no symbols, and its layout is close to the canary's but not the same (other frames of the chain resolve a few kilobytes beside the microtask drain functions). So read the names as the neighbourhood: a native frame of the microtask dispatch, as on Linux.

On the two Windows lanes of that build every run reaches a count of 1 within 11 turns. The macOS x64 jobs of that build have not run yet.

Which builds have it

I read the annotations of the builds of all branches in two windows, and took the merge base of each build with main. A build counts when a test job of the lane ran (in the first window: after 10-16 08:00). kept is always kept 1.

builds created (UTC) merge base macOS 14.8.9 x64: builds, with kept Windows 2019 x64: builds, with kept
10-16 04:00 to 10-17 10:00 older than e25d24c (#44496) 74, 0 not counted
has e25d24c 298, 43 not counted
10-20 18:00 to 10-21 14:00 older than 9b4284f (#46021) 82, 11 115, 0
9b4284f or newer 168, 0 345, 8

(In the first window the Windows lanes still failed in another way, which #45821 fixed.)

Runs of the new fixture (Linux x64)

  • The two tests: pass in 6 of 6 runs, 0.2 s each (release builds of 9b4284f and 9634520).
  • The fixture alone, 12 at a time: released in 360 of 360 runs (median 0.4 to 0.6 s). With the serve plugin: 216 of 216.
  • A server held by a global: kept 1 with the path, after 16 s.

The other failure of this file on the same lane

dials-after-it-was-disposed.mjs prints {"arrivedFromTheGraph":-1}. That is another fixture and another cause. #45930 covers it.


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

robobun and others added 2 commits October 21, 2026 01:40
…ges.mjs is released

The fixture counted the server cells that a collection leaves, with a limit of
2000 turns. A server that nothing refers to any more can still be in the heap:
the collector scans the machine stack conservatively. The last server stays
that way through a closed socket of the host, whose handlers close over the
scope that has the server.

- The host's sockets no longer close over the server.
- The count is compared with what the class itself has, not with a reading
  that can include the first server.
- A server that stays is looked up in a heap snapshot. It is kept only if a
  root reaches it. The fixture then prints the path from that root.
- The wait has no limit in turns. A server whose builds are not done is
  reached by a root, so the fixture waits on.
Not for merging. Runs the fixture as it is on main on the macOS lanes, next to
the two tests that use it. When that fixture would print "kept", the
diagnostic prints the path from a root to each server, or, for a server that no
root reaches, the cells that reach it and the words of the stack that point at
them. The report is adapted from #45816.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Oct 22nd, 2026

@robobun, your commit 208af89 is building: #130839

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft, waiting for the macOS x64 job of #46490

How the failure was reproduced:

  • Linux x64, release build of 9b4284f. The fixture of main, with a heap snapshot taken in turn 16 of its last loop, shows the last server in the heap with no root reaching it. A closed TCPSocket of the host reaches it, and nothing in the heap refers to that socket.
  • gdb on the same process, stopped in JSC::MachineThreads::gatherConservativeRoots: one stack word holds the address of that socket. It is in the frame of JSC::runInternalMicrotask. On Linux x64 that word goes after 20 turns, so the test passes there.
  • macOS arm64 lane, build 130840 of diagnostic, not for merging: what holds the last server in module-graph-isolation.test.ts on macOS x64 #46490: the same picture in one of 22 runs. No root reaches the last server in turn 50, a stack word holds the open handler of a closed host socket, and the server goes in the turn in which that word changes (turn 134).
  • macOS 14 x64 lane, build 129752: copies of the main fixture that wait for the exact count see the last server stay for 98 turns in 4 of 8 runs. The failure itself (2000 turns) does not show on that lane in builds that have Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021. It shows on Windows 2019 x64 in those builds (8 of 345).

The temporary diagnostic is out of this PR. #46490 (not for merging) prints the heap snapshot and the stack words on the macOS and Windows lanes. I add the output of its macOS x64 job to the notes here and close it.

The second failure of this file on those lanes ({"arrivedFromTheGraph":-1}) has another cause. #45930 covers it.

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