Skip to content

node: fix the genuine-hang class — MessagePort loop starvation, pinned sockets, https handshakeTimeout, TLS alert errors (+2 tests) - #35538

Draft
cirospaciari wants to merge 20 commits into
claude/node-v26-fix-tlsfrom
claude/node-v26-hang-fixes
Draft

cirospaciari wants to merge 20 commits into
claude/node-v26-fix-tlsfrom
claude/node-v26-hang-fixes

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 25, 2026 •

Copy link
Copy Markdown
Member

Fixes from the "genuine hangs" slice of the true-gap triage — each of these was a test (and a user program shape) that never exits. Base: claude/node-v26-fix-tls. Two upstream v26.3.0 tests vendored byte-verbatim, one bun-side regression test added. Now two upstream files: test-worker-message-port-infinite-message-loop landed on main separately in #37075, so it is no longer in this diff.

Base-branch repairs (blocking everything else)

The mega branch did not compile, and bun -e was entirely broken on it. Two commits repair evil-merge damage:

  • de14ef6099 (merge of combined-34660) resolved src/runtime/cli/Arguments.rs and src/js/node/util.ts wholesale to the pre-merge side. That reverted the whole -e/-p value-binding rewrite — every eval.provided = true write vanished while its consumer in mod.rs survived, so bun -e code printed the help text and exited 0. Every subprocess-spawning test on the branch failed from this. Also dropped: --check/-c, --input-type, --inspect-port/--debug-port, NODE_OPTIONS validation, --no-<flag> negation errors, and util.diff() (its myers_diff backend survived unreferenced). Restored via a proper 3-way merge against the original merge base, preserving the later permission-wave2 hunks.
  • The permission-wave2 merge dropped reject_bad_negations from a ParseOptions initializer (build break) and regressed three node_fs.rs call sites to pre-parity generic error messages, orphaning PathOrFdExt::from_js_required. Restored; deleted BUFFER_EXPECTED_TYPES, genuinely superseded by throw_invalid_argument_type_list at its only former call site.

Fix 1: MessagePort/Worker message drains starve timers (vendored: test-worker-message-port-infinite-message-loop)

A same-thread postMessage ping-pong never let timers run: the drain task's batch-limit reschedule used an ordinary postTask, and EventLoop::tick() drains the task queue until empty including tasks enqueued by the tasks it runs — so the rescheduled drain executed in the same tick, forever. Node yields here via uv_async_send in MessagePort::OnMessage: the continuation runs on the next loop iteration, after due timers.

The fix adds that primitive: EventLoop::next_loop_iteration_tasks, promoted into the task queue only at tick() entry, folded into the poll-timeout and loop-liveness checks, and reclaimed by the existing per-tag shutdown path. Exposed to C++ as ScriptExecutionContext::postTaskNextLoopIteration, used by the batch-limit reschedules in MessagePortPipe::drainAndDispatch and Worker::drainToWorker/drainToParent (same class).

Fix 2: never-connected sockets pinned forever (vendored: test-gc-net-timeout)

newDetachedSocket (node:net's pre-connect handle) materialized its wrapper through get_this_value, which unconditionally took a JSC Strong ref "until closed / marked inactive" — but a socket destroyed before its connect starts (DNS still resolving) never runs either, so the wrapper (and its timeout timer, via the callback closure) was pinned for process lifetime. The test stalled at 65/193 collected; heap-snapshot roots showed 128 TCPSockets held by StrongHandles. get_this_value now takes a Weak ref when the handler is fully detached — the JS-side socket._handle reference keeps the wrapper alive, matching the reconnect-reuse path — and the existing connect-time upgrades (connect_finish, mark_active) still pin it once native events are possible. 193/193 collected after the fix.

Fix 3: handshakeTimeout on native https servers (vendored: test-https-timeout-server)

https.createServer delegates to the native node:http server, which only notified JS of a connection after the TLS handshake — a raw TCP client that never handshakes was invisible, options.handshakeTimeout was ignored, and no clientError ever fired. Now uws's HttpContext::onOpen runs the connection filters with a distinct value at raw TCP accept for node-compat TLS contexts; onServerConnection creates the socket at accept (matching node's pre-handshake 'connection'), arms an unref'd handshake timer (default 120s, validateNumber, per lib/internal/tls/wrap.js), emits 'secureConnection' and clears the timer on the completion notification, and on deadline emits 'tlsClientError' with ERR_TLS_HANDSHAKE_TIMEOUT. TLS servers install node's https.Server tlsClientError → clientError mapping (destroy when unhandled).

Fix 4: post-handshake fatal TLS alerts swallowed (new test in node-tls-server.test.ts)

A fatal alert received after the handshake completed (e.g. a TLS1.3 server's certificate_required when the client sent no certificate) surfaced as a bare close / synthesized ECONNRESET; Node reports ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED on the socket 'error' event. The parked-OpenSSL-reason pipeline only existed for handshake failures; it now parks unconditionally, ssl_on_data's fatal branch hands the reason to on_close (closing with CONNECTION_RESET so the graceful-shutdown path can't defer and drop it), the Rust on_close copies it into a JS Error, and net.ts decomposes it into node's library/function/reason/ERR_SSL_<REASON> shape via one shared helper. New test fails on the unfixed build.

This makes all four stanzas of upstream test-tls-client-auth produce the correct error codes, but that test still hangs at exit on a separate native loop-ref leak (all sockets/servers closed, getActiveResourcesInfo() empty, loop refcount nonzero — reproducible with its stanza 3+4 pairs running concurrently), so it is not vendored.

Slow-list disposition (rest of the slice)

Verification

Each vendored test fails (times out) on the unmodified base binary and passes 3/3 runs on this branch, CI-style (debug build, bunfig.node-test.toml, flagless). Preflight OK (byte-verbatim, expectations.txt untouched, no silent passes).

Regression suites on the final debug build: test/js/node/tls 245/245; test/js/node/http failure set identical to the base binary (6, pre-existing); workers structured-clone 231/231; test/js/node/net per-file sweep clean (three files that tripped a shared-machine-load timeout pass standalone); test/js/web/workers 372/373 — the one failure (worker-terminate-lifetime "terminate() while dns.lookup() is in flight", an ASan leak report for the worker's fs Binding) reproduces identically on a build of the base plus only the merge-repair commits, i.e. it is pre-existing on this base under debug+ASan and untouched by the feature commits here.

…or messages

The merge of claude/node-v26-permission-wave2 dropped the
reject_bad_negations field from the ParseOptions initializer in
Arguments.rs (added by the cli negation-errors commit), breaking the
build, and resolved three node_fs.rs call sites back to the pre-parity
generic 'path must be a string' errors, orphaning
PathOrFdExt::from_js_required. Restores the field and the
from_js_required calls; deletes BUFFER_EXPECTED_TYPES, superseded by
throw_invalid_argument_type_list at its only former call site.
The 'Merge origin/claude/node-v26-combined-34660' merge (de14ef6)
resolved src/runtime/cli/Arguments.rs and src/js/node/util.ts wholesale
to the pre-merge side, silently reverting the combined branch's work:

- Arguments.rs lost the -e/-p/--print value-binding rewrite (including
  every 'eval.provided = true' write, whose consumer in mod.rs survived),
  so 'bun -e code' printed the help text and exited 0 — breaking every
  subprocess spawned with -e. Also lost: --check/-c, --input-type,
  --inspect-port/--debug-port parsing, NODE_OPTIONS validation, and the
  --no-<flag> negation errors. Restored via a proper 3-way merge against
  the original merge base, keeping the later permission-wave2 hunks.
- util.ts lost util.diff() (its myers_diff backend survived unreferenced).

readline.ts was also resolved to one side, but its dropped hunks are
superseded by the newer promises rework already on this branch; no
change needed there.
A same-thread postMessage ping-pong (port1.on('message') re-posting to
port2) never let timers run: the drain task reschedules itself with an
ordinary postTask, and EventLoop::tick() drains the task queue until
empty including tasks enqueued by the tasks it runs, so the rescheduled
drain executed in the same tick forever. Node yields here via
uv_async_send in MessagePort::OnMessage — the continuation runs on the
next loop iteration, after due timers.

Adds that primitive: EventLoop::next_loop_iteration_tasks, promoted into
the task queue only at tick() entry, folded into the poll-timeout and
loop-liveness checks, and reclaimed by the existing per-tag shutdown
path. Exposed to C++ as ScriptExecutionContext::postTaskNextLoopIteration.

The batch-limit reschedules in MessagePortPipe::drainAndDispatch and
Worker::drainToWorker/drainToParent now use it. Fixes the infinite
re-post loop in test-worker-message-port-infinite-message-loop (the
setTimeout that closes the port now fires).
newDetachedSocket (node:net's pre-connect handle) materialized its JS
wrapper through get_this_value, which unconditionally took a Strong ref
'until the socket is closed / marked inactive' — but a handle destroyed
before its connect starts (DNS still resolving, or aborted between
attempts) never runs either, so the wrapper was pinned forever. A
net.connect()+destroy() churn leaked every such socket (and its timeout
timer via the callback closure); test-gc-net-timeout stalled at 65/193
collected.

get_this_value now takes only a Weak ref when the handler is fully
detached — the JS side's socket._handle reference keeps the wrapper
alive, matching the reuse-prev reconnect path — and the existing connect
upgrades (connect_finish, mark_active) still pin it once native events
become possible.

Vendors test-gc-net-timeout (upstream v26.3.0), which now passes:
193/193 sockets collected.
https.createServer delegates to the native node:http server, whose
connection notification only fired after the TLS handshake completed —
a raw TCP client that never handshakes was invisible to JS, so
options.handshakeTimeout was ignored and no 'clientError' ever fired;
test-https-timeout-server hung until the generic idle timeout.

- uws HttpContext::onOpen now runs the connection filters with value 2
  at raw TCP accept for node-compat TLS contexts (value 1 still fires at
  handshake completion), and the server's filter thunk forwards both.
- node:http's onServerConnection creates the socket at accept (matching
  node, where 'connection' fires pre-handshake and 'secureConnection'
  after), arms an unref'd handshake timer (default 120s, validateNumber,
  like lib/internal/tls/wrap.js), and on the second notification emits
  'secureConnection' and clears the timer.
- On deadline the server emits 'tlsClientError' with
  ERR_TLS_HANDSHAKE_TIMEOUT; TLS servers install node's https.Server
  'tlsClientError' -> 'clientError' mapping (destroying the connection
  when unhandled), so conn._secureEstablished is observable as false in
  the handler.

Vendors test-https-timeout-server (upstream v26.3.0), passing 3/3 runs.
A fatal TLS alert received after the handshake completed (e.g. a TLS1.3
server's certificate_required when the client sent no certificate) was
swallowed: the parked-OpenSSL-reason pipeline only existed for handshake
failures, so the socket just closed and node:tls clients saw a bare
'close' (or a synthesized ECONNRESET) where Node reports
ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED via the socket 'error' event.

- openssl.c: ssl_park_fatal_reason now parks unconditionally; ssl_on_data's
  fatal branch hands the reason string to on_close when the handshake had
  completed, closing with CONNECTION_RESET so the graceful shutdown path
  cannot defer the close and drop it. Handshake-time reasons keep flowing
  through ssl_trigger_handshake as before.
- NewSocket::on_close builds a JS Error from the handed reason (copied
  before entering JS; the pointer is the loop's scratch).
- net.ts decomposes the OpenSSL string into node's
  library/function/reason/ERR_SSL_<REASON> shape in one shared helper
  (decorateOpenSSLError, extracted from tlsHandshakeError) applied in
  both close sinks, so the error keeps its identity instead of the
  ECONNRESET synthesis.

Covered by the new node-tls-server.test.ts case (fails on the unfixed
build with the client reporting nothing / ECONNRESET). The upstream
test-tls-client-auth now gets the correct codes in all four stanzas but
still hangs at exit on a separate native loop-ref leak under concurrent
TLS pairs, so it is not vendored here.
@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:21 AM PT - Aug 22nd, 2026

❌ @robobun, your commit 5b60b1f has 4 failures in Build #103320 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35538

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

bun-35538 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Add Node's util.diff and show Bun support in Bun's Node reference #20396 - PR restores util.diff() (Myers diff) whose cjs_exports entry was dropped during a merge, which is exactly what this issue requests
  2. top-level await prevent message events until resolved #21101 - PR fixes MessagePort/Worker event loop starvation where EventLoop::tick() drains re-enqueued tasks in the same tick, preventing message events from firing until monopolizing work completes

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20396
Fixes #21101

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:https: enforce server handshakeTimeout #33541 - Independently implements handshakeTimeout enforcement for HTTPS servers
  2. node:https: emit tlsClientError on server handshake failures #33531 - Independently implements tlsClientError emission on HTTPS server handshake failures
  3. tls: report a fatal TLS alert received after the handshake completes #33294 - Independently implements post-handshake fatal TLS alert reporting via ssl_park_fatal_reason in uSockets
  4. cli: parse NODE_OPTIONS for preload flags and validate against Node's allowlist #34101 - Independently implements NODE_OPTIONS env var parsing and validation against Node's allowlist

🤖 Generated with Claude Code

robobun and others added 8 commits August 3, 2026 20:48
…aude/node-v26-hang-fixes

# Conflicts:
#	src/runtime/cli/Arguments.rs
…b_worker.rs

The merge of origin/claude/callback-throw-uncaught dropped the
`parent_ref` local binding in WebWorker::create but left one caller;
switch it to the surrounding `unsafe { (*parent) }` pattern.

The same merge took main's deletion of BunHeapProfiler.h (503b01c)
without noticing this branch's v8.ts now $newCppFunction()s into
BunHeapProfiler.cpp, so the JS2Native codegen #includes a header that no
longer exists. Reinstate the header with the three host-function decls.
…aude/node-v26-hang-fixes

# Conflicts:
#	src/jsc/bindings/BunHeapProfiler.h
@robobun robobun changed the title node: fix the genuine-hang class — MessagePort loop starvation, pinned sockets, https handshakeTimeout, TLS alert errors (+3 tests) node: fix the genuine-hang class — MessagePort loop starvation, pinned sockets, https handshakeTimeout, TLS alert errors (+2 tests) Aug 21, 2026
dylan-conway added a commit that referenced this pull request Sep 18, 2026
…e it connects (#42295)

### Problem
- A `node:net`/`node:tls` socket destroyed before its connect starts
keeps its `TCPSocket`/`TLSSocket` wrapper (and its `net.Socket`) alive
forever. 1000 `net.connect()` + `destroy()` per round leave 1001, 2001,
3001 `TCPSocket` objects after full GC. `destroy()` during the DNS
lookup, an early `AbortSignal`, and a `connect()` that throws leak the
same way.
- So does every failed name lookup: 100 `net.connect({ host:
"does-not-exist.invalid" })` per round leave 101 then 201 `TCPSocket` on
stock bun, 1 then 1 with this change. A reconnect loop during a DNS
outage grows without bound.
- Cause: `new_detached_socket` (`src/runtime/node/node_net_binding.rs`)
creates the wrapper through `get_this_value`, which holds it strong.
Only a connect attempt's teardown downgrades that reference. A handle
that never connects reaches no teardown: `_handle.close()` on a detached
socket is a no-op.

### Fix
- The fixing line: `new_detached_socket` now creates the idle handle
with `JsRef::init_weak`. `net.Socket._handle` owns it until a connect
starts.
- `connect_finish` already upgraded the reference for each attempt. That
moves into `NewSocket::this_value_for_connect`, and the Windows
named-pipe connect arms now call it too (they did not upgrade before).
- Correct because the reference is now strong exactly while native work
is pending, like other `JsRef` users (`Bun.spawn`, Valkey) that start
weak.
- Verified: new test in `test/js/bun/net/socket-retention.test.ts` fails
on stock bun 1.4.3-canary (101 `TCPSocket` / 26 `TLSSocket` retained,
`protectedObjectTypeCounts` 100 / 25) and passes on the debug ASAN build
(1 / 1, protected 0 / 0). It asserts both the object counts and that no
`TCPSocket`/`TLSSocket` is held by a strong handle. Other suites in the
notes, Linux and Windows.

### Related
- Supersedes "Fix 2: never-connected sockets pinned forever"
(`a812bee39a`) in draft #35538. Same root cause; that commit makes
`get_this_value` take a weak reference whenever the socket is detached.
This PR instead makes the weak reference explicit at the one creation
site and leaves `get_this_value` unchanged, and `this_value_for_connect`
also pins the two Windows named-pipe connect arms, which an
`is_detached()` gate in `get_this_value` would leave weak during
`uv_pipe_connect`. That hunk can be dropped from #35538 once this lands.
- #34441 recorded the symptom: upstream `test-gc-net-timeout.js` not
collecting all of its sockets.
- Upstream `test-gc-net-timeout.js` (the test `a812bee39a` vendors) is
not added here. It passes with this change on every lane of build 114199
except alpine 3.23 x64, where it timed out in 4 of 4 attempts. It passes
here on glibc x64 with a release build of this branch: 5 of 5 runs, 16
of 16 pinned to 1, 2, 4 and 8 CPUs, and with `localhost` forced to two
addresses where the first one fails. It observes collection through
timing (a socket leaks only when its 10 ms timeout wins the race with
the connect), like the tests #35182 deleted. The guard that fails on
stock bun every time is the `socket-retention.test.ts` test above.

### Not covered
- `destroy()` from inside a `'connectionAttempt'` listener (#42307).
net.ts nulls `_handle` before `kConnectTcp` runs, so `doConnect(null,
...)` allocates a fresh native socket that nothing owns, and it stays
strong-rooted and connected. Separate root cause in
`src/js/node/net.ts`, reproduces the same on stock bun, not changed
here.
- Windows named pipes: a `destroy()` before the pipe open completes
(traced statically only). The second named-pipe path found here, the
attempt ref that a synchronous `WindowsNamedPipeContext::connect`/`open`
failure never released (#42309), is fixed on main by #42317. This branch
has main merged in, and both changes are in the two named-pipe arms of
`Listener::connect_inner`.
- No test fails if the two named-pipe arms in `Listener::connect_inner`
go back to `get_this_value`. The upgrade there is what keeps a first
`node:net` pipe connect alive across `uv_pipe_connect` when JS drops the
socket; the Windows leak tests listed below check the opposite direction
(nothing stays pinned).
- Nothing asserts that `this_value` is strong where a native attempt
starts (`do_connect`, `WindowsNamedPipeContext::connect`/`open`). A
`debug_assert!` there would make every debug net test check it; left for
a follow-up.

### Background
- `NewSocket<SSL>` (`socket_body.rs`) is the native side of a
`TCPSocket`/`TLSSocket`. Its `this_value: JsRef` is `Strong` (a
`StrongRootBlock` root) or `Weak`. The wrapper's finalizer derefs the
native socket.
- `Socket.prototype.connect` calls `newDetachedSocket()` and stores it
in `_handle` before the lookup and the `process.nextTick` that starts
the connect. `doConnect(_handle)` later reaches `connect_finish` with it
as `prev`.
- A detached handle has no `us_socket_t`, so closing it dispatches
nothing native.

<details><summary>Notes</summary>

- Heap snapshot (`generateHeapSnapshotForDebugging`) of the retained
cells before the fix: each `TCPSocket` has exactly one incoming edge,
`Internal` from `StrongRootBlock`, which is a GC root. No JS retainer.
`heapStats().protectedObjectTypeCounts` counts the same strong handles,
which is what the test asserts on.
- Scenarios measured on stock bun 1.4.3-canary (200 sockets each, count
after GC): immediate destroy 201, `tls.connect` + destroy 201
`TLSSocket`, destroy during lookup of `localhost` 201, destroy in the
`'lookup'` event 201, `AbortSignal` abort 201, `connect()` throwing
`ERR_SOCKET_BAD_PORT` 201. Sockets that connect and then close (destroy
after `'connect'`, server-side destroy, `end()`, ECONNREFUSED) do not
leak: 1. The constant 1 is the class's prototype object, which
`heapStats()` counts under the same name.
- With the fix all of the above report 1 on the debug ASAN build, and
the reporter's 1000-per-round loop stays at 1.
- The terminal paths that downgrade a connect attempt's reference:
`mark_inactive` after `on_close`, `handle_connect_error`, and the
semi-socket branch of `close()`/`terminate()`.
- What keeps an unreferenced `net.Socket` alive before its connect
starts: the `process.nextTick` closure (literal IP) or the lookup
callback (hostname) captures it. Probes: 200 `net.connect()` calls with
no JS reference kept, `Bun.gc(true)` forced between creation and the
deferred connect and on a 1 ms interval. All 200 connect, receive data,
and close (also with `BUN_GARBAGE_COLLECTOR_LEVEL=2`). The same probe
with `host: "localhost"` (with and without `autoSelectFamily`) and with
a custom async `lookup`: 100 of 100 each.
- The test lives in `socket-retention.test.ts` because that file is the
guard for the `JsRef` retention model and already drives it through
`node:net` in a child process.
- Suites run on the Linux debug ASAN build:
`test/js/bun/net/socket-retention.test.ts`,
`test/js/bun/net/socket.test.ts`, `test/js/node/net/node-net.test.ts`
(the 10 failures left are environment failures that stock bun shows too:
`localhost` resolution and no outbound DNS), `handle-leak.test.ts`,
`net-mongodb-pattern-leak.test.ts`, `double-connect.test.ts`,
`socket-reconnect-live.test.ts`,
`connect-autoselectfamily-stale-timer.test.ts`,
`node-tls-connect.test.ts`, `node-tls-upgrade.test.ts`, 53 `test-net-*`
and 38 `test-tls-*` Node parallel tests.
- Windows x64 debug build: the new test (fails on stock bun there too,
101), `should work with named pipes` (700 pipe connections, `TCPSocket`
count check), `should not leak when connect({path}) fails asynchronously
while polling for a pipe`, the `Bun.connect` named-pipe lifecycle tests
in `socket.test.ts`, and `node-tls-namedpipes.test.ts` (400 TLS pipe
connections, `TLSSocket` count at most 3) pass. The Windows runs predate
the `protectedObjectTypeCounts` assertion and the comment trims, which
change no compiled code.
- `cargo check --target x86_64-pc-windows-msvc` passes for the
`#[cfg(windows)]` arms.

</details>

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

---

**no test proof** · iteration 2 · 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

<!-- robobun:evidence:end -->

---------

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

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants