Skip to content

test(socket): reset the accepted socket's peer in-process instead of killing a child - #40108

Open
robobun wants to merge 3 commits into
mainfrom
farm/a434095c/socket-peer-reset-test-race
Open

robobun wants to merge 3 commits into
mainfrom
farm/a434095c/socket-peer-reset-test-race

Conversation

@robobun

@robobun robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/bun/net/socket.test.ts > close() error after the peer resets the connection > tcp > the accepted socket reports the reset as read ECONNRESET fails on the darwin x64 lane: closeErrorShape(...) gets { reported: false, code: undefined, syscall: undefined } instead of read ECONNRESET. 14 builds between 08:44Z and 13:20Z today, for example 103585.
  • The test from socket: report a peer reset on Windows as close(socket, ECONNRESET) instead of a code-less error #39615 spawns a peer process, writes to it, and kills it with SIGKILL so the kernel closes the socket with unread data and sends an RST. Over loopback the delivery of that write is asynchronous. The teardown after the kill takes about 0.7 ms on the Intel minis (tcpdump). When delivery is slower, the peer's receive buffer is empty at close, xnu sends a FIN (tcp_disconnect), and the accepted socket reads a clean end. The RST that follows arrives too late.

Fix

  • The peer resets in-process with terminate() for tcp and tls. It closes with SO_LINGER{1,0}, so the kernel sends a bare RST whatever is queued. A TLS terminate() sends no close_notify since tls: send a bare RST from terminate(), no close_notify #39632, which is why the child process was needed before.
  • The accepted side takes the same path as before: a poll error, recv() ECONNRESET, close(socket, error) with read ECONNRESET. The kernel path is the same too: a kill with unread data and an SO_LINGER close both reach tcp_drop.
  • Verified on darwin-x64-bagel (macOS 14.8.9) with the build 103585 binary: a 3 ms dummynet delay on loopback makes the old test fail 3 of 3 runs (tcp and tls) with the CI shape, and the new test passes 3 of 3 under the same delay. Also the debug build on Linux and the canary on Windows x64 (5 of 5).

Background

  • On xnu, close() sends an RST only if the receive buffer holds unread data or SO_LINGER is set with a zero timeout. Otherwise it sends a FIN.
  • Loopback delivery on macOS goes through the DLIL input thread. send() returns before the peer's socket has the data. Under load that delay reached 0.8 ms on the CI minis.
  • usockets reports a FIN as recv() == 0, which ends the socket cleanly. An RST is reported as a poll error with ECONNRESET.
Notes
  • Not caused by usockets: drain the receive queue before closing on a peer reset #39860 or usockets(kqueue): stop a non-reading socket's read knote from waking the loop on data #39949: 143 darwin x64 test jobs on commits that include both ran between 03:00Z and 08:05Z without one failure. The failures started at 08:44Z on every Intel mini (bagel, matzo, pretzel, crumpet, naan, rye, cornbread) and stopped with the fleet's daily reboot at 13:27Z (0 of 37 jobs since). Job durations in both windows were the same (568 s mean), so the host condition that widened the delivery delay is unknown.
  • Packet capture of a failing run (3 ms delay on the server to peer direction): greeting, ready, then the peer's FIN 1.0 ms after ready, then the payload 3.5 ms later, then three RSTs from the peer.
  • The delay was a pf dummynet rule limited to root's connections (dummynet out quick on lo0 proto tcp from any to any user root pipe 1, dnctl pipe 1 config delay 3), so CI jobs on the host were not affected. pf was disabled and flushed afterwards.
  • The repro probe lo_async_probe.py (write, then FIONREAD on the other end) showed the data absent right after send() in 30 to 98 percent of 3000 tries, with a delay of 4 to 160 us idle and up to 787 us under 16 CPU burners plus process churn.
  • Whole file on the Linux debug build: 85 pass, 6 skip, 9 fail. The same 9 fail on pristine main in this container (localhost resolution and external network), as noted in test: cover terminate() on a TLS socket sending a bare RST #40040.
  • Overlap: test: cover terminate() on a TLS socket sending a bare RST #40040 extends the third test in this block to tls and test: keep the accepted socket's buffer empty in the paused peer-reset tests #39653 rewords the comment this PR removes. Either one lands with a one-hunk conflict against this PR.
  • Self-review: one nit, the new comment narrated the removed child peer. Trimmed in 6ed6df6 to one clause that points here.

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

…killing a child

The tcp and tls "accepted socket reports the reset as read ECONNRESET" tests
spawned a peer process, wrote to it, and killed it with SIGKILL so the kernel
would close its socket with unread data and send an RST. Over loopback the
delivery of that last write is asynchronous, and the teardown after the kill
takes about 0.7ms on the Intel CI minis. When delivery is slower the peer's
receive buffer is empty at close, the kernel sends a FIN, and the accepted
socket closes cleanly. The test then fails with no error code.

The peer now resets in-process with terminate(). It closes with SO_LINGER{1,0},
so the kernel sends a bare RST whatever is queued, and a TLS terminate() sends
no close_notify since #39632. The accepted side takes the same path as before:
a poll error, recv() ECONNRESET, close(socket, error) with code ECONNRESET and
syscall read.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 57f96c6d-92b1-4c68-8b44-ae467c1ab8b7

📥 Commits

Reviewing files that changed from the base of the PR and between 169e127 and 6ed6df6.

📒 Files selected for processing (1)
  • test/js/bun/net/socket.test.ts

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

The TLS/TCP accepted-socket reset test now uses an in-process client. The client waits for the greeting, calls terminate(), and the accepted socket reports read ECONNRESET. The child-process setup and kill logic were removed.

Accepted socket reset test

Layer / File(s) Summary
In-process terminate reset flow
test/js/bun/net/socket.test.ts
The test replaces the child-process peer with a client socket. The client waits for the greeting, calls terminate(), and verifies read ECONNRESET on the accepted socket. Handshake errors remain explicitly rejected.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main test change: resetting the accepted socket peer in-process instead of killing a child process.
Description check ✅ Passed The description explains the problem, fix, background, and verification results, although it does not use the template headings exactly.

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

@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 — test-only de-flake that replaces a racy SIGKILL-based RST with a deterministic terminate() (SO_LINGER{1,0}) reset.

What was reviewed:

  • The assertion is unchanged (closeErrorShape still expects read ECONNRESET), so the test is not weakened — same kernel path (tcp_drop), just without the loopback delivery race.
  • Failure paths on the new in-process peer are wired: error/connectError/close all reject greeted, and the server's handshake failure now rejects closedWith (the only awaited promise) instead of the removed ready.
  • Checked that the peer's close firing after terminate() rejecting an already-resolved greeted is a no-op, not an unhandled rejection.
  • No production code touched; the removed tempDir/subprocess machinery is fully replaced, no dead helpers left behind.
Extended reasoning...

Overview

This PR touches a single test in test/js/bun/net/socket.test.ts — the "accepted socket reports the reset as read ECONNRESET" case inside the close() error after the peer resets the connection block. The old test spawned a child process, wrote to it after a ready handshake, then SIGKILL'd it so the kernel would close the peer socket with unread data and emit an RST. The flake root cause (documented in exhaustive detail in the PR description with tcpdump captures and a dummynet repro) is that loopback delivery on macOS is asynchronous through the DLIL input thread: when the write hadn't landed in the peer's receive buffer by the time SIGKILL teardown ran, xnu sent a FIN instead of an RST and the test observed a clean close.

The fix replaces the child process with an in-process Bun.connect peer that calls terminate() after receiving the server's greeting. terminate() closes with SO_LINGER{1,0}, which forces an RST regardless of receive-buffer state — the same tcp_drop kernel path the SIGKILL approach reached, but deterministically. The PR notes this is now viable for the TLS variant because #39632 made TLS terminate() skip the close_notify that previously turned the reset into a clean end on the reading side.

Security risks

None. Test-only change to a socket test file; no production code, no new inputs, no external network.

Level of scrutiny

Low-to-medium. This is a test de-flake with no runtime code changes. The key REVIEW.md concerns for test edits are (1) not weakening the assertion and (2) not routing around a real runtime bug. Both are satisfied: the assertion is byte-identical, and the PR description proves the flake is a test-construction race (async loopback delivery vs. SIGKILL timing), not a runtime defect — the same RST-on-close behavior is exercised via the documented equivalent kernel path. The new test is also strictly better on the harness axes: no subprocess spawn, no temp dir, faster, and every failure event on both sides is wired to reject the awaited promise.

Other factors

  • The PR description includes cross-platform verification (darwin-x64 with a 3 ms dummynet delay reproducing the flake 3/3 on the old test and 0/3 on the new; Linux debug; Windows x64 canary 5/5).
  • I checked the one subtle interaction: after peer.terminate(), the peer's own close handler fires and calls greeted.reject(...), but greeted was already resolved by data — rejecting a settled withResolvers promise is a no-op, not an unhandled rejection.
  • The removed ready promise's rejection sites (peer premature exit, early server close) are correctly folded into the remaining greeted and closedWith resolvers, so no failure path is left un-awaited.
  • No prior reviews or comments on the timeline; the bug-hunting system found nothing. The noted overlap with #40040 and #39653 is a one-hunk textual conflict, not a semantic one.

@robobun

robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 AM PT - Aug 22nd, 2026

✅ @robobun, your commit 6ed6df6860ff70975306981a2ef3a2aa7eae1bce passed in Build #103651! 🎉


🧪   To try this PR locally:

bunx bun-pr 40108

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

bun-40108 --bun

@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 — test-only de-flake that replaces a race-prone SIGKILL-based RST with an in-process terminate(), keeping the same assertion.

What was reviewed:

  • The new test still asserts the identical { reported: true, code: "ECONNRESET", syscall: "read" } shape — no weakening; the kernel path (SO_LINGER{1,0} → tcp_drop → bare RST) exercises the same usockets poll-error → recv() ECONNRESET → close(socket, error) sequence the original protected.
  • All failure paths on both sides wire to a reject: server handshake failure rejects closedWith; client error/connectError/close reject greeted. The client close firing after terminate() hits an already-settled greeted, which is a no-op.
  • Waiting on the client's data (the greeting) is the correct readiness signal for both transports — for tls the server writes it only after handshake(success), so both handshakes are complete before terminate().
  • No sleeps, no timeouts, port: 0, using listener for cleanup; removed child-process machinery is fully deleted.
Extended reasoning...

Overview

This PR touches a single test in test/js/bun/net/socket.test.ts: the "accepted socket reports the reset as read ECONNRESET" case inside the close() error after the peer resets the connection block. The old test spawned a child process, wrote data to it, then SIGKILLed it so the kernel would close its socket with unread data and emit an RST. The new test connects a client in-process, waits for the server's greeting, and calls socket.terminate(), which closes with SO_LINGER{1,0} and sends a bare RST. The assertion (closeErrorShape(await closedWith.promise) equals readReset) is unchanged.

Security risks

None. Test-only change with no production code touched. No new external inputs, no network hosts contacted, no new dependencies.

Level of scrutiny

Low-to-medium. This is a de-flake, and the repo's review rules call out "when de-flaking, keep asserting the property the original assertion protected" — this does exactly that. The PR description root-causes the flake precisely (xnu's tcp_disconnect sends FIN when the receive buffer is empty; loopback delivery on macOS is asynchronous through the DLIL input thread; a 3 ms dummynet delay reproduces the CI failure deterministically), and the replacement mechanism reaches the same kernel path (tcp_drop) without depending on write-delivery timing. The comment that was removed (explaining why an in-process terminate wasn't usable for TLS because it wrote close_notify) is correctly obsoleted by #39632, and the new comment cites that.

Other factors

  • The test structure follows the file's existing conventions (Promise.withResolvers, using listener, event-driven awaits, no sleeps). It is now simpler and faster (no subprocess spawn, no tempDir).
  • Every failure event is wired to reject the awaited promise; the one benign race (client close firing after greeted is already resolved) is a no-op on a settled promise.
  • The sibling test in the same block ("a connected socket reports the reset the same way (tcp)") already used in-process terminate(), so this change also brings the two tests into alignment.
  • The bug-hunting system found nothing. CodeRabbit found nothing actionable. No prior human review comments to address.

@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: both runs (103621 and 103631) ran test/js/bun/net/socket.test.ts green on every lane, including both darwin x64 shards. The red jobs are unrelated to this diff: 103621 had h2-conformance.test.ts on darwin aarch64 (fails on main too, reported to triage), and 103631 had two Windows jobs canceled because the CI agent VMs could not be created (Azure 409 on a VM name with a pending delete). Every other entry passed on retry.

@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 — deterministic de-flake of the accepted-socket ECONNRESET test.

What was reviewed:

  • Confirmed the new in-process terminate() (SO_LINGER{1,0} → bare RST) exercises the same accepted-side path the SIGKILL variant did, and the assertion (read ECONNRESET shape) is unchanged for both tcp and tls.
  • Checked that every failure path is wired to reject the awaited promise (greeted: error/connectError/close; closedWith: handshake failure), and that the peer's post-terminate close rejecting an already-settled greeted is a no-op.
  • Verified tempDir/bunEnv are still used elsewhere in the file (no dead imports), and the sibling connected-socket test already uses the same in-process terminate() pattern.
Extended reasoning...

Overview

This PR rewrites one test in test/js/bun/net/socket.test.ts — "the accepted socket reports the reset as read ECONNRESET" — to trigger the peer's RST via an in-process Bun.connect client calling socket.terminate(), instead of spawning a child process, writing to it, and killing it with SIGKILL. The child-process approach relied on the write reaching the peer's receive buffer before the kill so xnu would send an RST rather than a FIN; over macOS loopback that delivery is asynchronous and raced the process teardown, producing 14 CI failures on darwin x64. terminate() closes with SO_LINGER{1,0}, which sends a bare RST unconditionally, and since #39632 the TLS variant no longer writes a close_notify first — removing the original reason a subprocess was needed.

Security risks

None. Test-only change with no production code touched; no new inputs, no external network, no credentials.

Level of scrutiny

Low-to-moderate. This is a test de-flake, not a behavior change. The key review question is whether the rewrite preserves what the test protects — that the accepted socket's close(socket, error) reports { code: 'ECONNRESET', syscall: 'read' } when the peer resets. The assertion is byte-identical; both trigger paths reach the same kernel tcp_drop and the same usockets poll-error → recv ECONNRESET → close-with-error sequence. The PR description documents a reproduction (dummynet 3ms delay makes the old test fail 3/3 and the new test pass 3/3 on the affected hardware), and CI ran the file green on every lane including both darwin x64 shards across two builds.

Other factors

  • All await points have failure paths wired to reject: greeted is rejected by the client's error, connectError, and close handlers; closedWith is rejected by a server handshake failure. The client's close firing after terminate() re-rejects an already-resolved greeted, which is a no-op.
  • The subprocess removal drops the write-then-kill dance entirely; the extra accepted.write(...) existed only to seed unread data for the SIGKILL RST and is not part of the property under test.
  • The sibling test in the same describe block ("a connected socket reports the reset the same way (tcp)") already used in-process terminate(), so this change also brings the two into a consistent pattern.
  • tempDir and bunEnv remain used elsewhere in the file (33 occurrences), so no dead imports were introduced.
  • The PR description flags one-hunk conflicts with #40040 and #39653; those are trivial and expected.

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