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

Draft
CI
3mo ago
+364-38

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)

  • 11 inspector-protocol hangs (test-inspector*, test-esm-loader-hooks-inspect-*, test-runner-inspect, test-debugger-pid): hang awaiting the /json/list discovery endpoint. The entire CDP stack lives on the combined-34719 lineage where #35493 already landed that fix; this base has none of it (debugger.ts has /json/list as a TODO). Porting would duplicate a PR chain — left to the inspector lineage.
  • test-http-server-multiple-client-error: needs external-socket adoption via server.emit('connection', socket) — owned by open PR #35285.
  • test-perf-hooks-eventlooputilization: ELU implementation is in #34424.
  • test-async-hooks-http-parser-destroy: needs async_hooks init/destroy for HTTPINCOMINGMESSAGE/HTTPCLIENTREQUEST resources (async_hooks resource-type gap, #35366 lineage).
  • test-http-regr-gh-2928: HTTPParser.prototype.consume() is a TODO stub in JSHTTPParserPrototype.cpp (http-parser lineage, #34432).
  • test-http2-pack-end-stream-flag: needs PerformanceObserver 'http2' entries (Http2Session with framesReceived).
  • test-heapsnapshot-near-heap-limit-worker: setHeapSnapshotNearHeapLimit + worker OOM snapshots unimplemented.
  • test-tls-async-cb-after-socket-end: needs server-side legacy TLS1.2 session cache (newSession/resumeSession events with async done-callback; SSL_CTX_sess_set_get_cb is not wired).
  • test-stack-size-limit: not fixable at this layer — Bun executes CJS/-e in strict mode, so JSC's proper tail calls turn (function explode(){ return explode(); })() into a genuine infinite loop instead of a RangeError (V8 has no PTC). Needs sloppy-mode CJS execution.

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.