Skip to content

socket: shutdown() sends the FIN after queued bytes; a close frees the queue and keeps bytesWritten - #44300

Open
robobun wants to merge 12 commits into
robobun/3bf73240/pending-writes-one-doorfrom
robobun/3bf73240/fin-after-queue-release-at-close
Open

robobun wants to merge 12 commits into
robobun/3bf73240/pending-writes-one-doorfrom
robobun/3bf73240/fin-after-queue-release-at-close

Conversation

@robobun

@robobun robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #44291.

Problem

  • socket.shutdown() sends the FIN at once, before bytes that end(data) or a node:net write left in the queue. Of 8,388,608 bytes, 2,633,835 arrive, and the peer sees a clean close. NewSocket::shutdown (src/runtime/socket/socket_body.rs) does not read the queue.
  • When the peer closes a socket with queued bytes, the queue stays allocated until the GC frees the wrapper: 14,143,312 B for one socket.

Fix

  • shutdown() over a queue with bytes records the FIN in PendingWrites. internal_flush sends it after the last byte.
  • Every discard goes through discard_pending_writes. It frees the buffer and keeps the dropped count in bytesWritten, as node does. on_close and close() call it too: 344 B remain.
  • Verified: 8 new tests in test/js/bun/net/socket.test.ts and test/js/node/net/node-net.test.ts, 6 fail on the base. Notes list the suites.
  • Self-reviewed: 17 concerns raised, 15 addressed. Rejected 2 that need a node:net socket's private handle (Notes).

Background

  • The queue (buffered_data_for_node_net) holds bytes that a write accepted and the socket did not take. internal_flush sends them later.
  • A FIN is the TCP packet that ends the write side.
  • Considered a Flags bit for the waiting FIN. Flags is a full u16.

Downsides

  • size_of::<NewSocket> grows from 240 B to 248 B. The mimalloc block stays 256 B.
  • A FIN that waits for a peer that never reads is never sent.
  • bytesWritten after terminate() or a failed send now includes the queued bytes that were dropped.
Notes

Delivery, debug build, base to this PR

case sent arrive on the base arrive with this PR
end(data) then shutdown(), tcp 8,388,608 2,633,835 8,388,608
end(data) then shutdown(), tls 8,388,608 2,621,440 8,388,608
node:net: 1 MiB writes until one stays queued, then socket._handle.shutdown() 3,145,728 2,633,835 3,145,728

Memory of one socket whose peer sent FIN, never read a 16 MiB end(data), then reset (estimateShallowMemoryUsageOf, release builds)

before the close after the close bytesWritten after
base 14,143,312 B 14,143,312 B 16,777,216
this PR 14,143,320 B 344 B 16,777,216

node v26.3.0 also reads 16,777,216 from bytesWritten after a queued write and a reset by the peer.

Costs, release builds of the base (04d1f0645b) and this PR (ca01d4c216)

  • Binary: 80,840,264 B for both. .text is 80,673,561 B and 80,672,793 B (768 B less).
  • size_of::<NewSocket<false>> and <true>: 240 B to 248 B (the queue type goes from 32 B to 40 B for one flag byte). mi_usable_size(mi_malloc(n)) is 256 for n = 232, 240, 248 and 256, so the heap block does not grow.
  • libc socket calls for 1,000 loopback connections that queue nothing (connect, one 1 KB write, end()), counted with an LD_PRELOAD shim: send 1,000, recv 1,997, epoll_ctl ADD 2,002 / MOD 1,001 / DEL 2,001, close 2,006, setsockopt 4,001 on both builds. 2,000 connections give twice these counts on both.
  • A write that the socket takes whole runs no new code. write_or_end is not changed, and the new check in write_or_end_buffered is after its empty-queue return.
  • New work per call: one flag test and one length read in shutdown(), one byte load and branch in internal_flush when the socket does not end after the flush, and one release per close (a flag test, a length read, a buffer reset and an add). A send of queued bytes runs through lend: two byte stores and one load more.
  • Source changes came after these builds (lend, the free in close(), the raw-half test in shutdown()). I did not build release binaries again for them. The struct sizes did not change: the debug-assertions layout reads 520 B for NewSocket and 40 B for the queue type at the final head, as at ca01d4c216.
  • Not measured: instructions per call. perf and valgrind are not on my machine.

Which close frees the queue. Every one: on_close for a close that the peer or an error starts, and close(), terminate() and the other detach sites for a close that bun starts. close() frees it at once, also when a TLS close still waits for ciphertext that the kernel did not take.

A release under a send. A send reads the queue through PendingWrites::lend. The socket can close from inside that send: usockets closes a TLS socket from inside us_internal_ssl_writev ("closed from inside the call"), and a Duplex transport runs user JS inside its write. A release() in that window only records itself, and the caller frees the queue when the send returned, after its own consume. So the slice stays valid for the whole call. The review of #44291 found the missing guard on main for terminate() under a Duplex send. No test reaches a release under a send: a Duplex whose write() destroys the socket during the flush after the handshake gave no ASAN report on the build before this guard either.

The raw half of an upgradeTLS() pair keeps the FIN of the base: shutdown() sends it at once. No writable event reaches that half, so nothing would send a FIN that waits.

Other discard sites. Before this PR, handle_connect_error, close_and_detach, detach_for_reconnect, write_buffered and end_buffered on a detached socket, and the two fatal-send paths freed the queue, and bytesWritten then lost those bytes. They now keep the count. upgrade_tls_impl now frees the queue of the TCP wrapper that it retires.

Tests. Windows takes a first send of any size whole, so the tests fill the kernel with writes first and then queue the chunk under test. I cannot run Windows here, so CI is the check for that. The tail of the shutdown() test continues the byte stream of its writes, like the retry of a short write does: with other bytes a TLS defect on main shows (see the comments on this PR).

  • socket.test.ts: shutdown() after end(data) (tcp, tls), a close by the peer frees a queued end(data) tail and keeps bytesWritten, close() on a TLS socket frees a queued end(data) tail at once, while the close waits for the peer, terminate() over a queued end(data) tail keeps the tail in bytesWritten. All 5 fail on the base.
  • node-net.test.ts, a write that is still queued natively: is sent before the FIN when the handle shuts down fails on the base. stays in bytesWritten when the peer resets the connection passes on the base and fails if on_close frees the queue and does not keep the count. does not hold back the FIN of a shutdown on the raw half ... passes on the base and fails (times out) if the raw half defers its FIN.
  • No test reaches the is_fin_deferred() check in write_or_end_buffered: test code cannot call the private $write while a FIN waits.

Suites on a debug build with this PR: socket.test.ts 244 pass, 6 skip, 1 fail (should not call drain before handshake needs www.example.com). node-net.test.ts 112 pass, 10 fail, all 10 also on the base (8 net.Socket read tests, #13126, unref should exit when no more work pending: no internet). All pass: socket-pending-writes, socket-syscall-fault, tcp-server, net-syscall-fault, node-net-server, node-net-allowHalfOpen, node-tls-connect, node-tls-server, node-tls-raw-end, node-tls-upgrade, tls-syscall-fault, node-tls-socket-allow-half-open-option, node-tls-wrapped-socket-close, and two node-tls-duplex-* files.

Self-review. Eight reviewers read the diff, one per area (FIN state, writes after a shutdown, the byte count, the free in on_close, node:net, the transports, the tests, the text). They raised 17 concerns, each with a probe. I checked each against the code.

  • 7 concerns, one defect: close() or node:tls destroy() on a TLS socket with a queued tail kept the tail after the close (14,197,112 B), because on_close read the detached wrapper. Fixed: close() frees the queue itself, and on_close frees it for every transport. New test.
  • 2 concerns, one defect: shutdown() over a queued tail on the raw half of an upgradeTLS() pair never sent its FIN. Fixed: the raw half sends it at once, as on the base. New test.
  • 4 concerns: the test stays in bytesWritten when destroy() could not fail, and no test read the count after a local close. Fixed: that test is gone, and the terminate() test fails on the base.
  • 1 concern: the tcp shutdown() test did not prove that a tail was queued. Fixed: it fills the kernel and asserts it.
  • 1 concern: a sentence in this body about write() after shutdown() described the effect of end(data). Removed.
  • After the PR was ready, the review bot raised 2 nits: the free in on_close could run under a live borrow of the queue during a TLS flush, and the fill loop of one test stopped at 16 steps. Fixed: lend, and 64 steps.
  • Rejected: on the private handle of a node:net socket, write() or end(data) after a shutdown() that waits for a node:net write are accepted, where the base returned -1. write_or_end already asserts in debug builds that the public calls do not run over a node:net tail, and a check for it would add a load and a branch to every write().
  • Rejected: on that private handle, flush() after a shutdown() that waits sends the last byte and the FIN, and no drain follows, so the node:net write callback does not run. net.ts never calls flush(), and the base lost the data in the same call order.

Meets #44313. That PR reports a write error when a close loses the queued tail. It reads the queue length at the top of on_close, where this PR frees the queue. The two merge with a text conflict there. The right order is the check of #44313 first, then the free.

Not in this PR

  • The event-loop hold for queued bytes (unref() with a queued tail). It is the next step of this stack and needs a maintainer decision on its shape first.
  • A queued tail that a peer pins by never reading has no bound except the timeout handler.
  • Two defects on main that this work found: a short TLS write() can leave one sealed record that it did not count (handed off), and a node:net write on the raw half of a tls.connect({ socket }) pair queues a tail that nothing sends.
  • bun.d.ts says shutdown(true) shuts the write side. The code shuts the read side for true and the write side for no argument. This PR does not touch that text.

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

…e queue and keeps bytesWritten

shutdown() over a queue with bytes records the FIN, and internal_flush
sends it after the last byte. Every discard of the queue goes through
one helper that frees the buffer and adds the dropped count to
bytes_written, so bytesWritten keeps the accepted total. on_close frees
the queue of a usockets-backed socket at once.
@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green.

How I reproduced it (loopback, debug build unless noted):

  • Bun.connect, then in open: socket.end(Buffer.alloc(8 << 20)) and socket.shutdown(). The server counts 2,633,835 of 8,388,608 bytes (tcp) or 2,621,440 (tls), then sees a clean close. With this PR it counts 8,388,608.
  • net.connect, 1 MiB writes until one stays queued, then socket._handle.shutdown(): 2,633,835 of 3,145,728 bytes arrive before, all of them with this PR.
  • A Bun.listen socket answers with end(Buffer.alloc(16 << 20)) to a peer that sent FIN and never reads. The peer then resets. estimateShallowMemoryUsageOf(socket) reads 14,143,312 B before and after the close (release build). With this PR it reads 344 B after the close, and bytesWritten stays 16,777,216.

Tests: test/js/bun/net/socket.test.ts and test/js/node/net/node-net.test.ts (8 new tests, 6 fail on the base).

Self-review: 17 concerns raised, 15 addressed, 2 rejected. The Notes in the body list them.

CI at c973b00cad: the new tests pass on every lane, Windows and the release lanes included. Buildkite build 122265 has one red test, test/js/bun/spawn/spawn.test.ts on the x64-asan lane. This PR does not touch that code, and the same test is red on main. The other failures passed on a retry. mordant, cargo clippy, cargo miri test and comment-cop pass.

@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:14 AM PT - Oct 1st, 2026

✅ @robobun, your commit daba962dd377db4c2a57640f2eddc4e3740feeff passed in Build #122283! 🎉


🧪   To try this PR locally:

bunx bun-pr 44300

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

bun-44300 --bun

… the raw half keeps its immediate FIN

close() detaches the wrapper before a TLS close that waits for its
ciphertext spill, so on_close read a detached wrapper and kept the
queue. It now reads the socket of the dispatch. shutdown() on the raw
half of an upgradeTLS pair sends the FIN at once, as before: no writable
event reaches that half, so a deferred FIN was never sent.
@robobun
robobun marked this pull request as ready for review October 1, 2026 03:21
@robobun
robobun requested a review from alii as a code owner October 1, 2026 03:21
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

What comes after this PR. It is not built yet, and it needs a maintainer decision first.

The defect that is left. unref() lets the process exit while accepted bytes are still in user space.

  • Bun API: unref() + end(8 MiB) delivers 5,318,638 of 8,388,608 bytes (tcp) or 4,210,688 (tls), exit code 0.
  • node:net: write(32 MiB) + unref() delivers 5,574,849 of 33,554,432 bytes on bun 1.4.3, and the write callback never runs. node v26.3.0 delivers all of them and runs the callback.

Where the bytes are. Accepted bytes sit in two stores: the queue of NewSocket (PendingWrites), and the TLS ciphertext that the kernel did not take (ssl_spill in packages/bun-usockets/src/crypto/openssl.c). A hold that ends when the queue is empty still loses the last TLS records.

The plan, one PR each:

  1. usockets: the spill of a Bun socket holds the event loop while it has bytes. One setter for ssl_spill_owner takes a loop hold when the slot fills and gives it back when it empties, only for the socket kind BUN_SOCKET_KIND_BUN_SOCKET_TLS. A close that the application starts gives the hold back at once, unless ssl_shutdown_after_spill is set. Loop teardown must not arm SO_LINGER{1,0} on a TLS socket whose clean close waits for the spill.
  2. socket: PendingWrites gets its own KeepAlive. It takes the hold when the queue goes from empty to not empty, and gives it back when the queue drains or is released. It applies to usockets-backed sockets when the handshake is done or END_AFTER_FLUSH is set: an end() tail holds before the handshake, and a node:net write that waits for the handshake does not, as in node. ref(), unref(), Flags and poll_ref stay as they are. net.ts then drops its own write hold (the re-ref in _write, unrefAfterDrain) and gets node's _onTimeout rule, so a tail that a peer pins still gets 'timeout'.
  3. http2: H2FrameParser.write_buffer is a third store on the same socket. It gets the same hold.

The decision.

  • Is a loop hold that usockets C code takes acceptable? It would be the first C code that writes the loop's active count. If not, the wrapper polls the spill instead, which costs one FFI call per TLS write.
  • Is a second KeepAlive that the queue owns acceptable, in place of the single computed poll_ref (reading || write_pending) that node:net: start sockets with readableFlowing null so early bytes buffer instead of being discarded #34285 asked for? If not, the hold moves onto poll_ref with a recorded unref() wish. That shape needs a sync call at 13 to 18 sites, and a missed one pins the process.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread test/js/bun/net/socket.test.ts Outdated
…the queue

The queue lends its bytes to a send through lend(). A release that runs
while the send is on the stack (the socket closed from inside the write)
takes effect when the send returns, so the slice stays valid for the
whole call. With that, on_close frees the queue for every transport, and
close() frees it at once, also when a TLS close waits for its peer.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/socket/socket_body.rs — pre-existing: a node:net write on the raw half of a tls.connect({ socket }) pair that the kernel takes only partly is queued into a buffer nothing ever flushes, so its tail is lost and its write callback never fires. socket_body.rs:2807 passes buffer_unwritten_data = true for every socket, while end at socket_body.rs:3382 passes !BYPASS_TLS because no writable event reaches that half (the PR's own comment at 3306). Fix: queue no tail on a BYPASS_TLS socket in write_or_end_buffered too, and report the short write to JS as the raw half's end already does, so the caller can retry from drain instead of waiting forever.

    Why this was flagged

    After tls.connect({ socket: raw }) net.ts sets connection._handle = raw (src/js/node/net.ts:2243), the BYPASS_TLS twin built at socket_body.rs:3816-3842. A raw.write(chunk) whose send is short reaches write_buffered -> write_or_end_buffered with an empty queue, which at socket_body.rs:2807 calls write_or_end::<false>(global, &mut values, true); at 3132-3136 the unsent remainder is appended to buffered_data_for_node_net. Dispatch for that us_socket_t goes to the tls half via the ext slot; the raw half is reached only by us_dispatch_ssl_raw_tap (openssl.c:2393-2394), which is a data hook, so NewSocket::on_writable and internal_flush never run for it and the tail is never sent. net.ts stores the write callback at src/js/node/net.ts:3050 and waits for a drain that never comes, so raw.end() never finishes. The base branch behaves the same; the PR's end binding already avoids queuing on BYPASS_TLS (socket_body.rs:3382) but write_or_end_buffered does not.

    Verification: pre-existing (the base takes the identical route). Trigger: user code writes to the net.Socket that tls.connect({ socket }) adopted and the kernel takes the send only partly. With an empty queue socket_body.rs:2807 passes buffer_unwritten_data = true for every socket, BYPASS_TLS included. Nothing ever flushes that queue on the raw half; the sibling end binding avoids this at 3381-3382.

Comment thread src/runtime/socket/pending_writes.rs Outdated

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Three commits since the review above.

  • 9b19163b6d answers the two open nits. The queue lends its bytes to a send through PendingWrites::lend, and a release() under that send waits until the send returned. So on_close frees the queue for every transport, close() frees it at once, and the two comments that said a write does not touch the queue are gone. The fill loop of the shutdown() test stops at 64 steps like its siblings.
  • 4205e568d9 makes the doc of lend one line.
  • c973b00cad fixes that test on the release lanes (build 122244 failed there with mismatchAt equal to the filled length). The tail of the test now continues the byte stream of its writes.

Why the test needed that: it found a defect in the TLS write path that is on main and is not part of this PR. When another TLS socket on the loop holds the ciphertext spill slot, a short write() leaves one sealed record that it did not count. The next write then sends that record in place of its own first 16,384 bytes. A caller that retries the remainder of the same buffer does not see it. I measured it with two connections (the other data started 16,384 bytes late, the total length was right) and handed it off as its own fix.

On the additional finding (a node:net write on the raw half of a tls.connect({ socket }) pair queues a tail that nothing flushes): agreed, and it is on the base too. This PR only keeps the FIN of that half as it was. The write path of the raw half is not changed here.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/net/node-net.test.ts Outdated

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

Code review found no issues

No high-confidence issues detected in this change.

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.

2 participants