Skip to content

node:net: reset the stream state when connect() reuses a half-closed socket - #38980

Open
robobun wants to merge 4 commits into
mainfrom
farm/c5c98b91/net-reconnect-reset-stream-state
Open

node:net: reset the stream state when connect() reuses a half-closed socket#38980
robobun wants to merge 4 commits into
mainfrom
farm/c5c98b91/net-reconnect-reset-stream-state

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • net.Socket#connect() on a socket whose previous connection is still being torn down (after end(), or after the peer's FIN on an allowHalfOpen socket) emits connect for the new connection, but the first write on it fails with error: write after end / code: "ERR_STREAM_WRITE_AFTER_END"; a readable side that had ended never emits end again, and remoteAddress/remotePort keep describing the old peer.
  • test/js/node/net/node-net.test.ts "net.Socket write > should allow reconnecting after end()" hits exactly this: it reconnected 3ms after end()'s callback, which only works when the previous connection has finished closing by then. On a Windows debug build it failed about half the time with the error above.
  • Cause: Socket.prototype.connect (src/js/node/net.ts) only called initSocketHandle() (which _undestroy()s the Duplex) when it created a new handle, i.e. for a fresh socket or one that had already been destroyed. A socket that is not destroyed yet keeps its handle; since node:net: handle socket.connect() on a socket that still has a live native handle #32739 the native connect tears the previous connection down and connects that handle again (connect_finish -> detach_for_reconnect in src/runtime/socket/), but nothing reset the stream state that went with the previous connection.

Fix

  • connect() runs initSocketHandle() whether or not it had to create the handle, and initSocketHandle() also clears the cached _peername (it already cleared _sockname). The _peername/_sockname clears in connect()'s if (this.destroyed) block are deleted since initSocketHandle() now covers them; only _handle = null stays.
  • Why this is right: the native side already gives the reused handle a fresh connection, so the Duplex has to start over at the same point, exactly as it does for a fresh handle; initSocketHandle is node's own reset for that situation (_undestroy + clearing the cached names). It is a no-op on the paths that already ran it (fresh socket, destroyed socket, autoSelectFamily retry), so only the reuse case changes.
  • Node for comparison: connect() on a socket that was end()ed but not destroyed fails with connect EISCONN (the kernel rejects connect(2) on the still-connected fd), so the pattern only works there once the socket has been destroyed. Bun chose in node:net: handle socket.connect() on a socket that still has a live native handle #32739 to replace the connection instead; this makes that work at the stream layer too, so reconnecting after end() no longer depends on whether the peer's FIN happened to be processed first.
  • Tests, all in test/js/node/net/node-net.test.ts and all failing before / passing after the change with bun bd test:
    • "should allow reconnecting after end()" now reconnects from end()'s callback (deterministically before the peer's FIN is read), checks that every write()/end() succeeds and that no error is emitted. Before the change it fails on the second iteration with write after end.
    • "connect() while the previous connection is half-closed": writable side re-opened (TCP and unix socket path, data delivered on the new connection), readable side re-opened on an allowHalfOpen socket (end emitted again), remotePort reports the new peer.
  • Also run with the change: the rest of node-net.test.ts (its remaining failures in this environment, e.g. www.example.com lookups and localhost binding to ::1, are identical without the change), socket-reconnect-live.test.ts, double-connect.test.ts, connect-autoselectfamily-stale-timer.test.ts, node-net-allowHalfOpen.test.js, handle-leak.test.ts, and the Node test-net-*.js / test-tls-*.js suites in test/js/node/test/parallel (including test-net-reconnect.js, the reconnect-after-close path, and the remote/local address tests).

Background

  • net.Socket is a Duplex stream over a native handle (socket._handle). Ending the writable side (end()) and the peer ending the readable side (end event) are recorded as flags on the stream's writable/readable state; write() on a stream whose writable state is ended throws ERR_STREAM_WRITE_AFTER_END regardless of what the handle underneath is doing.
  • _undestroy() is the streams-internal routine that clears those flags (ended/ending/finished, endEmitted, destroyed, errored, ...) so a stream object can be used for a new session. Node's initSocketHandle() calls it and clears the socket's cached address, and node calls it whenever connect() attaches a handle to the socket; this change calls it on the reused handle as well.
  • A socket is "half-closed" when one direction has been shut down. With the default allowHalfOpen: false, a net.Socket destroys itself once both directions are done, so between end() and the arrival of the peer's FIN (or, with allowHalfOpen: true, indefinitely) the socket is half-closed and socket.destroyed is false. That is the window in which connect() took the reuse path.
  • Reconnecting a socket that is still connecting is a different case (node:net: reject connect() while a connect is already in flight with EALREADY #32812 is about that one) and is not changed here. Reconnecting a tls.connect() socket in this state throws a TypeError before reaching this code and is also a separate issue.

…socket

Socket.prototype.connect only ran initSocketHandle (which _undestroy()s
the Duplex) when it created a new handle, i.e. on a fresh socket or one
that had already been destroyed. A socket whose previous connection was
still being torn down (end() called, or the peer's FIN received on an
allowHalfOpen socket) kept its handle, so the native side replaced the
connection but the stream kept the ended/finished flags of the previous
one: 'connect' fired and the first write failed with
ERR_STREAM_WRITE_AFTER_END, and a readable side that had ended never
emitted 'end' again.

Run initSocketHandle on every connect(), and also drop the cached
_peername there so remoteAddress/remotePort describe the new peer.

The "should allow reconnecting after end()" test reconnected 3ms after
end()'s callback and only passed when the previous connection had
finished closing by then (it flaked on slow debug builds); it now
reconnects from the callback itself, which reuses the half-closed handle
deterministically.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7268d358-7835-4476-b2f6-2b9f04f55d1e

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 5314d9e.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/js/node/net/node-net.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; ready for review (HEAD 5314d9e).

Reproduced on Linux with the released bun: reconnecting a net.Socket from end()'s callback (the existing "should allow reconnecting after end()" test minus its 3ms timer) fails every time with ERR_STREAM_WRITE_AFTER_END; with the change it passes. The five tests in test/js/node/net/node-net.test.ts touched by this PR fail without the src/js/node/net.ts change and pass with it (bun bd test test/js/node/net/node-net.test.ts -t "reconnecting after end|half-closed"), 8/8 repeated runs green.

The follow-up commits only apply the review nits (dead _peername/_sockname clears removed, listen promises reject on error, shorter comments); the fix itself is unchanged from the first commit.

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

LGTM — the two inline findings are non-blocking nits (redundant _peername/_sockname clears that the unconditional initSocketHandle now covers, and the listen() helper not wiring server.once('error', reject)); neither affects correctness.

Extended reasoning...

Overview

The runtime change is two lines in src/js/node/net.ts: initSocketHandle(this) is moved out of the if (!this._handle) block so it runs on every connect(), and initSocketHandle now also clears _peername. The rest of the diff is test coverage in test/js/node/net/node-net.test.ts: the flaky "should allow reconnecting after end()" test is rewritten to reconnect deterministically from end()'s callback (before the peer's FIN is read), and four new tests under "connect() while the previous connection is half-closed" cover the writable side (TCP + unix socket), the readable side on an allowHalfOpen client, and remotePort reporting the new peer.

Security risks

None. This is stream-state bookkeeping in the node:net compat layer — no auth, crypto, path handling, or untrusted-input parsing is touched. _undestroy() and the _peername/_sockname/kclosed/kended clears are all reset-to-default operations on the socket's own fields.

Level of scrutiny

Medium. Socket.prototype.connect is a hot path, but the change is narrowly scoped: on paths where initSocketHandle already ran (fresh socket, destroyed socket, kReinitializeHandle autoSelectFamily retry) it is idempotent — _undestroy() on an already-fresh Duplex is a no-op, and the field assignments overwrite with the same values. Only the reuse-a-half-closed-handle case changes, which is exactly the case the tests exercise. I traced lines 2123-2145 to confirm nothing between the destroyed block and the new initSocketHandle call reads the stale stream state, and checked that the added _peername = null in initSocketHandle is also correct for its other caller (kReinitializeHandle, where the peer changes on retry).

Other factors

  • The PR description documents that the full node-net.test.ts, socket-reconnect-live.test.ts, double-connect.test.ts, the autoSelectFamily test, node-net-allowHalfOpen.test.js, handle-leak.test.ts, and the test-net-*.js / test-tls-*.js Node parallel suites were run with the change.
  • The new tests await observable conditions (once(socket, 'connect'), once(socket, 'finish'), once(socket, 'end')), collect errors into an array asserted at the end, and clean up in try/finally — they follow the file's existing patterns and REVIEW.md's guidance.
  • The two inline findings are pure hygiene: the redundant _peername/_sockname clears in the if (this.destroyed) block are now dead but harmless, and the listen() helper's missing error-reject matches how the pre-change test (and several neighboring tests in this file) already listen. Neither affects correctness or should hold the fix.
  • No CODEOWNERS entry covers these files, and there are no outstanding human review comments.

Comment thread src/js/node/net.ts
Comment thread test/js/node/net/node-net.test.ts
Comment thread src/js/node/net.ts Outdated
Comment on lines +2136 to +2142
// Unlike node (where connecting a handle that is still connected fails with
// EISCONN), a handle whose previous connection is still open or half-closed is
// reused: doConnect closes that connection and connects the same handle again.
// The stream state has to be re-initialized for it as well, otherwise a
// connect() issued after end() but before the previous connection finished
// closing keeps its ended/finished state and the first write on the new
// connection fails with ERR_STREAM_WRITE_AFTER_END.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line in 33e982c / 5314d9e.

Comment thread src/js/node/net.ts Outdated
Comment on lines +4269 to +4271
// Called before every connect(): on a new Socket, and whenever a Socket is
// re-used for another connection (after a close, or while the previous
// connection is still being torn down).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line in 33e982c.

Comment thread src/js/node/net.ts Outdated
Comment on lines +2136 to +2137
// Also for a reused handle: doConnect replaces the connection it still
// carries, so the stream state of that connection has to go with it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to one line in 5314d9e.

Comment thread src/js/node/net.ts
Comment on lines +2136 to +2137
// A reused handle gets a new connection from doConnect, so it is reset as well.
initSocketHandle(this);

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 hostname variant of this fix has a race window: initSocketHandle(this) resets state synchronously here, but for a non-IP host lookupAndConnect defers internalConnectdetach_for_reconnect to an async DNS callback, so the old still-attached connection can deliver end/close (SocketHandlers2 has no self.connecting guard) between the reset and the detach and re-set kended/self.write=writeAfterFIN/push(null). Not a regression — before this PR the hostname case failed 100% of the time — but every new test uses "127.0.0.1" (the process.nextTick fast path), so the gap is untested. Guarding finishSocketEnd/close on self.connecting, or repeating the reset in internalConnect right before doConnect, would close the window; fine as a follow-up.

Extended reasoning...

What the gap is

Socket.prototype.connect now runs initSocketHandle(this) unconditionally at net.ts:2137, which _undestroy()s the Duplex and clears kended/kclosed/_peername. That happens synchronously. The old native connection, however, is not detached until internalConnectdoConnectdetach_for_reconnect() runs. For an IP-literal host that is scheduled via process.nextTick (net.ts:2896) — no I/O poll happens between a nextTick queue flush and the code that scheduled it, so the window is closed. For a unix path it is synchronous. But for a hostname host, lookupAndConnect defers internalConnect to an asynchronous dns.lookup callback (net.ts:2938-2953), and the event loop does poll I/O in between.

The code path that re-corrupts the reset state

During that DNS window the old (still-attached) connection can deliver its FIN or close to SocketHandlers2:

  • SocketHandlers2.end (net.ts:1304-1309) calls finishSocketEnd(self) with no self.connecting guard. finishSocketEnd (net.ts:589-606) checks only self[kended] — which initSocketHandle just cleared to false — so it runs: self[kended] = true, self.write = writeAfterFIN (default allowHalfOpen: false), self.push(null) (readable side re-ended), and socket.unref().
  • SocketHandlers2.close (net.ts:1326-1368) similarly has no self.connecting guard, sets self[kclosed] = true, and calls finishSocketEnd. A knock-on: with kclosed already true, the new connection's later close hits if (self[kclosed]) return; at line 1330 and is silently dropped.

The this.write restore at net.ts:2119-2122 already ran before this, so it does not undo the re-installed writeAfterFIN. internalConnect and afterConnect do not repeat the reset.

Why the existing tests don't cover it

Every new test in this PR — the rewritten "should allow reconnecting after end()" and all four "connect() while the previous connection is half-closed" cases — connects to "127.0.0.1" (or a unix path). isIP("127.0.0.1") is truthy, so lookupAndConnect takes the process.nextTick fast path at line 2896, and no I/O poll can interleave between initSocketHandle and detach_for_reconnect. A test that reconnects to "localhost" (or via a custom options.lookup that resolves asynchronously) while the previous peer's FIN is still in flight would exercise the window.

Step-by-step trace

  1. socket.end(); server keeps its side open (allowHalfOpen: true), so the client is half-closed and socket.destroyed === false.
  2. socket.connect(port, "localhost") runs. this.write === writeAfterFIN is restored (line 2119). this.connecting = true. initSocketHandle(this) runs _undestroy(), sets kended = false, kclosed = false. lookupAndConnect sees isIP("localhost") === 0 and calls dns.lookup(...).
  3. Control returns to the event loop; the loop polls I/O. The old connection's peer now closes its side (or the FIN that was already on the wire is read). SocketHandlers2.end fires → finishSocketEnd(self): kended = true, self.write = writeAfterFIN, self.push(null).
  4. The DNS callback fires → internalConnectdoConnectdetach_for_reconnect (old connection finally detached) → new connection established → afterConnect emits 'connect'.
  5. socket.write("data") is now writeAfterFIN. push(null) on a !allowHalfOpen Duplex triggered auto-end(), so writableEnded is true again and the write fails — same user-visible symptom this PR set out to fix, just via a narrower race and a different code (EPIPE instead of ERR_STREAM_WRITE_AFTER_END).

Why this is a nit, not a blocker

  • Not a regression. Before this PR initSocketHandle was never called on the reused-handle path, so the hostname case failed 100% of the time with ERR_STREAM_WRITE_AFTER_END. After this PR it fails only when the old FIN/close races into the DNS window. Strict improvement on every path.
  • Bun-specific extension. Node rejects this whole pattern with EISCONN (per the PR description); reconnecting a half-closed net.Socket to a hostname while the old peer's FIN is in flight is a narrow edge of a Bun-only behavior.
  • The proper fix is a bit larger than this PR's scope: either guard finishSocketEnd / the close handler on self.connecting for the reused-handle case (so late old-connection events are ignored), or detach the old connection synchronously in connect(), or repeat initSocketHandle inside internalConnect just before doConnect (after detach_for_reconnect has nulled the old ext slot so no further JS callbacks can arrive from it). Any of these is a reasonable follow-up.

Per REVIEW.md's "Cover the variant matrix, not just the repro" this is worth noting — the hostname variant is an untested gap — but not worth blocking a PR that fixes the flaky test it set out to fix and strictly improves every case.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:53 AM PT - Aug 15th, 2026

@robobun, your commit 5314d9e has 1 failures in Build #98042 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38980

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

bun-38980 --bun

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

I was handed the same flake (the Windows debug failure of "should allow reconnecting after end()") and landed on the same cause, so I am not opening a second PR. Two things I found while checking the reuse path, in case they are useful here. Both were measured by emulating this PR's reset from userland (socket._undestroy(); socket._sockname = socket._peername = null; before the second connect()), on bun 1.4.0 and on a debug build of main.

1. The cached address getters on the reused handle are stale. _getsockname()/_getpeername() are cleared, but they re-read _handle.localPort etc., and on the native wrapper localAddress, localFamily, localPort, remoteAddress and remoteFamily are cache: true getters (src/runtime/socket/sockets.classes.ts), so a reused wrapper keeps returning the previous connection's values. remotePort is the one getter without cache: true, which is why the "reports the address of the new peer" test passes; the same test on localPort fails:

server: accepted connection from client port 48496
first connection localPort = 48496
after end(): destroyed = false readyState = readOnly
server: accepted connection from client port 48510
second connection: socket.localPort = 48496

(server created with allowHalfOpen: true so the client stays half-closed; socket.localPort read once on each connection.)

2. The lookup window from the review comment above is deterministic when the lookup answers after the previous connection's FIN, which is the normal ordering on a release build (loopback FIN well under 1ms, DNS answer later; with host: "localhost" on 1.4.0 it fails 3/3 runs without any help). Reproducer: end(cb), then in cb reconnect with a lookup option whose callback fires from socket.once("end", ...). On both binaries the result is: the old connection's 'end' is emitted on the reconnecting socket, writableEnded is already true when 'connect' fires (readable 'end' -> allowHalfOpen auto end() -> _final queued behind 'connect'), and the first write fails with EPIPE ("This socket has been ended by the other party"). Re-running the reset before doConnect, or skipping finishSocketEnd while connecting, would still leave that stray 'end' (and any late data) from the old connection on the socket.

Both go away if connect() on a non-destroyed socket tears the previous connection down synchronously and then takes a fresh handle, i.e. the path a destroyed socket already takes: the native routine for that exists (NewSocket::detach_for_reconnect, what connect_finish runs today, only later) and could be exposed next to newDetachedSocket/doConnect in src/runtime/node/node_net_binding.rs, with connect() calling it and nulling _handle before initSocketHandle. That also gives the kUserUnrefed unref in initSocketHandle a detached handle to act on, and leaves connect_finish's reuse branch only for two connect() calls in the same tick. Note detach_for_reconnect closes with CloseCode::Failure (SO_LINGER 0, so an RST that drops whatever end() left in the kernel send buffer); _handle.close() uses FastShutdown for that reason, and the reconnect teardown probably wants the same. If used standalone it also needs to downgrade this_value so the abandoned wrapper can be collected (connect_finish re-upgrades it right away, so that is a no-op for the existing caller).

Happy to leave all of this with this PR; #39008 routes TLS sockets into the same path, so whatever is decided here covers both.

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