Skip to content

socket: every edit of the pending-write queue goes through one method - #44291

Open
robobun wants to merge 4 commits into
robobun/3bf73240/stack-base-end-tail-pending-writesfrom
robobun/3bf73240/pending-writes-one-door
Open

robobun wants to merge 4 commits into
robobun/3bf73240/stack-base-end-tail-pending-writesfrom
robobun/3bf73240/pending-writes-one-door

Conversation

@robobun

@robobun robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #41785 and #44219. The base branch merges their heads, with no conflict.

Behaviour change: none

Problem

  • NewSocket edits its queue of unsent bytes at 14 call sites through buffered_data_for_node_net.with_mut(..) (src/runtime/socket/socket_body.rs). A rule about the queue must be repeated at each site.
  • Two later changes need one place for such a rule: a FIN that waits for the queue, and an event-loop hold while it has bytes.

Fix

  • PendingWrites (the queue type from socket: drain node:net's pending-write queue with a cursor #44219) now owns its cell. The edits are append, consume and release. The reads stay len, slice and capacity.
  • The sites map 1:1: 3 append_slice to append, 3 consume stay, 7 clear_and_free to release. One clear_and_free, after a writev that sent everything, becomes consume(written), which frees the queue the same way.
  • Correct because no operation changes. grep with_mut on the field returns 0 outside pending_writes.rs.
  • Verified: test/internal/socket-pending-writes.test.ts, test/js/bun/net/socket.test.ts and test/js/node/net/node-net.test.ts give the same results as the base. Notes list the other four files.

Background

  • buffered_data_for_node_net holds bytes that a write accepted and the socket did not take. internal_flush sends them when the socket is writable.
  • JsCell is the cell for state that only the JS thread touches. with_mut lends a mutable borrow.
  • Considered one computed poll_ref with a sync call at each edit site. A type with one door needs no sync call.

Downsides

  • None found. size_of::<NewSocket> is 240 B before and after (release layout). Each site runs the same operation.
Notes

Struct size, read with a const array-length probe in cargo check -p bun_runtime (release, and the debug-assertions layout in brackets). NewSocket<false> and NewSocket<true> are equal at each commit.

commit NewSocket queue field
#41785 head 6927ed38a3 232 B (504 B) Vec<u8>, 24 B
base of this PR deaaed462b (with #44219) 240 B (512 B) PendingWrites, 32 B
this PR 04d1f0645b 240 B (512 B) PendingWrites, 32 B

Which edit sites the suites run. I put a counter at each of the 14 sites and ran 7 test files on a debug build: the three above, test/js/bun/net/socket-syscall-fault.test.ts, test/js/node/net/node-net-server.test.ts, test/js/node/net/net-syscall-fault.test.ts and test/js/node/tls/node-tls-connect.test.ts.

  • 10 sites run. 7 of them run with bytes in the queue: the partial writev (consume, append), the slow path (append, consume), the end() tail (append), and internal_flush (consume, and release after a fatal send).
  • 3 sites run only with an empty queue: the release in handle_connect_error, close_and_detach and detach_for_reconnect.
  • 4 sites do not run. Each is a 1:1 verb change:
    • write_buffered on a detached socket: clear_and_free() to release().
    • end_buffered on a detached socket: clear_and_free() to release().
    • write_or_end_buffered, writev sent the queue and the whole chunk: clear_and_free() to consume(written). written is at least len() there, so consume frees the queue.
    • write_or_end_buffered, slow path, the write failed: clear_and_free() to release().

Test results on a debug build, this PR and its base

  • socket.test.ts: 239 pass, 6 skip, 1 fail. The failure is should not call drain before handshake. It connects to www.example.com, which my machine cannot reach.
  • node-net.test.ts: the 8 net.Socket read tests, #13126, unref should exit when no more work pending and the 60 s connect({path}) leak test fail on the base too (no internet, or a debug-build timeout).
  • should allow reconnecting after end() reconnects 3 ms after end(). On my loaded machine it fails in 1 of 8 runs on the base and 2 of 5 runs with this PR, with write after end in both cases.
  • test-https-agent-unref-socket.js, test-net-socket-timeout-unref.js and test-http2-session-unref.js exit 0.

Where this goes. The next PR adds two rules inside the type: shutdown() over a queue with bytes sends the FIN after the last byte, and every discard frees the queue and keeps bytesWritten. A later PR adds the event-loop hold for queued bytes.

PendingWrites owns its cell. NewSocket reads it with len, slice and
capacity, and edits it with append, consume and release. No call site
holds a mutable borrow of the queue.
@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green.

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

Beyond the inline finding, I also checked the one non-1:1 substitution — clear_and_free() to consume(written) in the writev fast path (socket_body.rs, leftover == 0 branch): written >= total_to_write >= len() there, so it takes the set(StreamBuffer::default()) branch and frees the queue identically. The six Default::default() constructor sites in Listener.rs / node_net_binding.rs still build the field correctly since JsCell<StreamBuffer>: Default.

Extended reasoning...

The change moves the JsCell from the NewSocket field into PendingWrites and rewrites 14 edit sites in socket_body.rs to call append/consume/release; it touches no security-sensitive surface. The inline finding about the slice lent across a user-JS-running call on the TLS-over-duplex path is what keeps this from an approve, and the ruled-out items above are the only other things I verified concretely.

Comment thread src/runtime/socket/socket_body.rs
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the check of the consume(written) substitution and of the six Default::default() constructors. I changed nothing for them.

On the inline finding (the queue slice lent across a Duplex send that runs JS): it is on main with the same shape, and this PR does not change it. I replied in the thread. It goes to a separate fix with an ASAN repro, and the guard will live inside PendingWrites.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

New commits since the first push. None changes behaviour.

The comment in those two places says that the send does not touch the queue. The review above showed that this is not true for a Duplex transport. That is the separate fix. I did not re-word the comments here, so that the fix can replace them.

@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