Skip to content

node:net: fail the write when the flush at the end of the open dispatch gets a fatal send error - #43026

Open
robobun wants to merge 4 commits into
mainfrom
robobun/50b02138/net-open-flush-fatal-send
Open

robobun wants to merge 4 commits into
mainfrom
robobun/50b02138/net-open-flush-fatal-send

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:net write made inside a 'connection' or 'connect' listener can be reported as delivered when it was not. The write callback succeeds and 'finish' fires, with no 'error'. Repro: the listener writes 16 MB and the peer resets before the listener returns. Node fails the write with ECONNRESET. Bun reports success with 2.6 MB sent.
  • The cause is the flush at the tail of on_open (src/runtime/socket/socket_body.rs:1623): let _ = this.internal_flush();. On a fatal send() errno, internal_flush drops buffered_data_for_node_net and returns the errno. The tail discarded it, saw the buffer empty, and dispatched the drain callback.

Fix

  • Move the POSIX fatal-flush delivery of on_writable (node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #32488) into fail_fatal_flush. Call it from the tail of on_open too. It calls the error handler with a write errno, then closes the socket.
  • write_or_end no longer queues the bytes of a write whose first send was fatal. JS fails that write, but the tail flush sent its bytes again.
  • Windows keeps the legacy drain contract, as on_writable documents.
  • Verified: test/js/node/net/net-syscall-fault.test.ts (6 new cases), test/js/node/net/node-net-server.test.ts (real peer reset, no injector), and the Node suite's test-net-*, test-http-*, test-tls-*. Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • buffered_data_for_node_net holds the unsent rest of a short send(). JS parks the write callback until the native drain callback arrives.
  • internal_flush sends that buffer again. It returns 0, or the errno of a fatal send (EPIPE, ECONNRESET).
  • The open dispatch is the native on_open call. Its JS open handler emits 'connection' or 'connect'. A write made there is flushed when the handler returns.
Notes

Real-kernel repro (no fault injection, no timers). The server's 'connection' listener writes 16 MB (a real short send), calls end(), then stays in the listener until the peer has reset the connection. The peer signals the reset through a marker file.

node v26.3.0:  ["write:ECONNRESET","error:ECONNRESET:write","close:true"]
bun main:      ["write:ok","finish","close:false"]   bytesWritten 2634240 of 16777216
this branch:   ["write:ECONNRESET","error:ECONNRESET:write","close:true"]

bun main is release 1.4.3-canary.1+c6b7fcb5b and a debug build of 630e921db0. Both give the same result.

Schedule matrix (no injection, the listener does not block). The writer queues 16 MB inside its 'connection' listener or 'connect' callback. A node v26 peer checks every byte and resets at a set point: in its callback, after 0 to 40 ms, after 1 B to 12 MB read, N ms after its first data, or a close with unread data. A run fails when the peer got less than everything and the writer saw no error.

release main c6b7fcb5b:      16 of 30 runs end in write ok + 'finish' + close:false
debug build of main:         10 of 30, and 30 of 32 with resets timed from the peer's first data
debug build of this branch:  0 of 152
node v26.3.0:                0 of 30

The peer always received a prefix of the stream. A trace of a failing run on release main shows that the failing send() is the second one, in the same loop iteration as the accept, with no epoll_pwait in between. That send is the flush at the tail of on_open. About 10 ms pass between the two sends while the 14 MB rest is copied into the buffer, so a reset that arrives in that window is enough. The listener does not have to block.

47.173 ms  accept4 -> fd 10
50.898 ms  send fd 10 len 16777216 -> 2681856
61.631 ms  send fd 10 len 14095360 -> -1 (errno 104)
62.437 ms  epoll_pwait enter

Syscall-level repro (LD_PRELOAD shim on send, accepted fd only). net.createServer(c => { 16 x c.write(4 KiB); c.end(); }), plan: send #2 short (1 byte), send #3 EPIPE.

main:         send #4 = 57344 bytes on the same fd. Peer gets 61441 of 65536 bytes,
              spliced at offset 4097. Server: no 'error', 16 write callbacks ok, 'finish'.
this branch:  no send after #3. Peer gets a clean 4097-byte prefix.
              Server: error:EPIPE:write, close:true, writes 1..15 fail with EPIPE.

Why the second change is needed. When the first send of a write is fatal, write_maybe_corked returns -errno and JS schedules failWrite on the next tick. write_or_end still appended the whole chunk to buffered_data_for_node_net, so the tail of on_open sent it a second time. Two effects:

  • On main, with a one-shot injected ECONNRESET, the second send succeeds. The peer receives 200 bytes of a write that JS was told failed. The new test is not sent again covers this.
  • With only the first change, a real reset before accept makes the second send fail with EPIPE, and fail_fatal_flush reports it before failWrite runs. 'error' then carries EPIPE while Node and main report ECONNRESET. Real-kernel check, reset before accept, two writes in the listener:
node / main / this branch:  ["write1:ECONNRESET","write2:ECONNRESET","error:ECONNRESET:write","close:true"]
first change only:          ["write1:ECONNRESET","write2:EPIPE","error:EPIPE:write","close:true"]

Only wrote < -1 (a fatal send) skips the queue. -1 is the legacy closed or rejected-TLS result, which JS treats as would-block. Its behavior does not change.

Other callers of internal_flush that discard the errno. end_buffered and end call it only when wrote == total, so the buffer is empty. The public flush() has no socket-handle caller in src/js. None of them dispatches drain.

History. #33803 made ENOBUFS/ENOMEM transient. #32488 made on_writable fail the write on POSIX. #33506 covered the same ground as #32488 and was closed as stale. The tail of on_open was the remaining caller.

Overlap. #41785 changes the same if buffer_unwritten_data line to wrote >= 0 for a different bug (Bun.connect end(data)). The second PR to land needs a one-line conflict resolution.

Not part of this change. SocketHandlers.error in net.ts (the handler table for net.connect({ fd }), which includes each connection a cluster worker accepts) fails the parked write callback and then also emits 'error' directly. That gives two 'error' events for one failed flush. It reproduces on main through on_writable, so this PR does not change it. Tracked in #43030.

Suites run on the debug build. net-syscall-fault.test.ts (27 pass), node-net-server.test.ts (27 pass), socket-syscall-fault.test.ts, tls-syscall-fault.test.ts, node-net.test.ts, and the Node suite files test-net-*.js (139), test-http-*.js (386), test-tls-*.js (186). Every failure there also fails on a debug build of main without this change (test-http-agent-keepalive, test-http-client-timeout-option, test-tls-client-allow-partial-trust-chain, two 1-byte-send timeouts in node-http-syscall-fault.test.ts, ten localhost cases in node-net.test.ts). cargo check --workspace --target x86_64-pc-windows-msvc passes.


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

… fatal send error

A write from inside the 'connection' or 'connect' listener runs under the
native open dispatch. When its send is short, on_open flushes the rest
of buffered_data_for_node_net once the listener returns. It discarded
the errno that internal_flush reports for a fatal send (EPIPE,
ECONNRESET). It then saw the buffer empty, because the fatal path drops
the bytes, and dispatched the drain callback. node:net completed the
write as delivered: the callback succeeded and 'finish' fired for bytes
that were never sent.

on_writable already fails the write in this case on POSIX. Move that
delivery into fail_fatal_flush and call it from the tail of on_open too.
Windows keeps the legacy contract in both places.

write_or_end also queued the bytes of a write whose first send was
fatal, although JS fails that write. The same flush then sent them
again. Do not queue them.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 3 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 3 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 1 minute for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 3b780588-3dd3-4f27-9997-65f287b45b42

📥 Commits

Reviewing files that changed from the base of the PR and between 630e921 and aecd9e3.

📒 Files selected for processing (3)
  • src/runtime/socket/socket_body.rs
  • test/js/node/net/net-syscall-fault.test.ts
  • test/js/node/net/node-net-server.test.ts

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

@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:35 AM PT - Sep 17th, 2026

✅ @robobun, your commit aecd9e37a5c4f469cfd914edcb95ac46ae4b9be8 passed in Build #116943! 🎉


🧪   To try this PR locally:

bunx bun-pr 43026

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

bun-43026 --bun

@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix and the tests are pushed. CI is green on aecd9e37a5 (build 116943). The PR needs a maintainer review.

How I reproduced it

  1. Real kernel, no fault injection. This is the new test in test/js/node/net/node-net-server.test.ts. The 'connection' listener writes 16 MB and calls end(). It stays in the listener until the peer has reset the connection.

    USE_SYSTEM_BUN=1 bun test test/js/node/net/node-net-server.test.ts -t "hits a peer reset"
    

    On main (release 1.4.3-canary.1+c6b7fcb5b and a debug build of 630e921db0) the write callback succeeds and 'finish' fires with 2.6 of 16 MB sent. Node v26.3.0 and this branch fail the write with ECONNRESET, emit 'error', then 'close'.

  2. Fault injection, debug build. These are the 6 new cases in test/js/node/net/net-syscall-fault.test.ts, for a client writer and a server writer.

    bun bd test test/js/node/net/net-syscall-fault.test.ts -t "open dispatch"
    

    On main all 6 fail. A short send followed by EPIPE or ECONNRESET on the flush gives headError: null, no 'error', and the peer receives 4097 bytes that are not a prefix of the stream. A write that failed with ECONNRESET is sent again, and the peer receives its 200 bytes. On this branch all 27 tests in the file pass.

  3. Schedule matrix, real kernel, no fault injection. The listener does not block. A node v26 peer checks every byte and resets at a set point (0 to 40 ms, after 1 B to 12 MB read, N ms after its first data, close with unread data). Release main: 16 of 30 runs end in write ok, 'finish', close:false with a short peer. A debug build of this branch: 0 of 152. The PR description has the details and a send() trace.

@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: fail_fatal_flush taking &self across call_error_handler — socket is a Cell and the dispatching frame (on_open's ref / on_writable's ScopeExit) already keeps the wrapper alive, so it matches the pre-refactor inline code; the wrote >= -1 change in write_or_end — -1 (closed / rejected-TLS would-block) still queues the remainder as before; and the TLS on_open tail — unchanged because it sits under the existing !SSL guard, so tls.Server/tls.connect listeners keep their prior behaviour rather than gaining a new failure mode.

Extended reasoning...

The change is a focused fix in the node:net socket runtime plus tests. One inline finding (a pre-existing double 'error' on fd-adopted sockets that the new on_open path now also reaches) is already posted. The additional items above were examined from the diff and surrounding code and do not appear to introduce new defects: the helper's &self use reads only a Cell field after user JS, under a frame that already holds the wrapper reference exactly as the inlined on_writable code did; the write_or_end condition change only excludes fatal (< -1) sends and leaves the legacy -1 path intact; and TLS sockets are excluded by the pre-existing !SSL gate rather than by this PR. Given this touches socket lifecycle and error delivery on a hot path, a human maintainer's look is still warranted; this note only records what was ruled out.

Comment thread src/runtime/socket/socket_body.rs
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.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.

I reviewed this version and found no bugs; since the change adds a second native-to-JS error dispatch (with a nested close) at the tail of on_open in src/runtime/socket/socket_body.rs, a human look at that lifecycle path is still worthwhile.

What was reviewed:

  • fail_fatal_flush is a byte-for-byte lift of the former on_writable block; its &self across call_error_handler matches the file's R-2 convention (mutated fields are Cell/JsCell), and the on_open caller already re-enters JS from the same spot (drain_callback / call_error_handler), so no new keep-alive requirement is introduced.
  • The wrote >= -1 gate in write_or_end only affects the buffer_unwritten_data path (write_or_end_buffered with an empty buffer); -1 (closed/rejected) still buffers, and TLS never produces < -1 since write_check_error sets no fatal errno for SSL sockets.
  • Windows gating: fail_fatal_flush and both call sites are #[cfg(not(windows))], so no dead-code warning on that target; the Windows drain contract is unchanged.
  • The new server test's busy-wait is bounded (10s) and asserts sawReset explicitly, so a missed marker fails rather than silently passes.
Extended reasoning...

Overview

The PR touches one Rust file (src/runtime/socket/socket_body.rs) and two test files. In Rust: (1) the POSIX fatal-flush delivery previously inlined in on_writable becomes fail_fatal_flush(&self, handlers, this_value, errno), (2) the on_open tail now checks internal_flush()'s return and calls fail_fatal_flush instead of dispatching the drain callback on an emptied buffer, and (3) write_or_end no longer appends a chunk to buffered_data_for_node_net when write_maybe_corked returned a fatal negative errno (< -1). Tests add fault-injected EPIPE/ECONNRESET cases for both writer sides in net-syscall-fault.test.ts and a real-peer-reset case in node-net-server.test.ts.

Security risks

None specific to this change. It does not parse untrusted input or touch auth/TLS negotiation; it changes how an already-detected kernel send failure is reported to JS. The failure now closes the socket and surfaces 'error' instead of acknowledging bytes the peer never received, which is strictly less permissive than before.

Level of scrutiny

Moderate. The refactor of on_writable is behavior-preserving (same sys::Error::from_code_int(..., Tag::write), same call_error_handler, same is_detached() guard before close(Normal)). The new on_open call site is the real change: it introduces a nested on_close from inside the open dispatch, which the file already handles for the open-error branch (the comment at the has_exception() check in on_close was updated accordingly). The &self borrow across JS re-entry follows the file's established pattern (internal_flush, write_or_end already do this; the R-2 note documents that all mutated fields are interior-mutable). The wrote >= -1 gate is scoped to the node:net buffered path only (buffer_unwritten_data is true only from write_or_end_buffered), and write_buffered already hands the negative errno to JS which schedules failWrite, so dropping the bytes is consistent with what JS reports. endBuffered is also affected but has no JS caller in src/js/node.

Other factors

No debug build was available in this environment, so the tests were not executed here; the PR itself defers platform-specific test proof to CI. Test structure follows the existing helpers in the same files (fd-scoped fault rules, once-based awaits, combined toEqual objects, await using for the spawned client). The busy-wait in the server test blocks the event loop deliberately (the whole point is to stay inside the listener) and is bounded with an explicit sawReset assertion. No CODEOWNERS entry covers the changed files. My prior inline note about the pre-existing double-'error' on fd-adopted sockets was replied to by the author and is tracked separately per the description; it is pre-existing and not introduced by this PR.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Independent check of this PR. I worked on the same report separately and did not see this PR until my diff was done. I am not opening a second PR. These are the results that apply here.

  • The bug reproduces on main 630e921db0 with the same plan: a short send, then EPIPE or ECONNRESET on the flush that ends the open dispatch. I ran it for a client writer and for a server writer. The write callback reports success, no 'error' fires, and the peer receives head[0] followed by the next write.
  • On aecd9e37a5 the same repro gives the write callback the errno, then 'error' with { code, syscall: "write" }, then 'close'. The peer receives 1 byte. test/js/node/net/net-syscall-fault.test.ts passes 27 of 27 on a debug build.
  • The wrote >= -1 guard in write_or_end is necessary. My diff had the same helper at the same two call sites, but not the guard. With a real kernel, a peer that resets before accept() then gives a write callback with ECONNRESET and an 'error' with EPIPE: broken pipe, write, in 3 of 3 runs. The first send() fails, the chunk is queued anyway, the flush sends it again and gets EPIPE, and that second errno reaches 'error' first. On aecd9e37a5 both are write ECONNRESET, the same as Node v26.3.0 and the canary build c6b7fcb5b.

One possible addition: the new tests check the write callback error and the 'error' event, but not their order. On aecd9e37a5 the order is write callback, 'error', 'close'. An assertion on that order would pin it.

Probe for the third point (reset before accept, no fault injection)
const net = require("node:net");
const { spawn } = require("node:child_process");

if (process.argv[2] === "client") {
  const s = net.connect({ port: Number(process.argv[3]), host: "127.0.0.1" });
  s.on("connect", () => s.resetAndDestroy());
  s.on("error", () => {});
} else {
  const events = [];
  const server = net.createServer(sock => {
    sock.on("error", e => events.push(`error ${e.code} ${e.syscall} | ${e.message}`));
    sock.on("close", () => {
      events.push("close");
      console.log(JSON.stringify(events));
      server.close();
    });
    sock.write(Buffer.alloc(1024, 0x41), err => events.push(`write cb ${err ? err.code + " | " + err.message : "ok"}`));
  });
  server.listen(0, "127.0.0.1", () => {
    spawn(process.execPath, [__filename, "client", String(server.address().port)], { stdio: "inherit" });
    // Block this loop turn so that the reset arrives while the connection is still in the accept backlog.
    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
  });
}
helper without the guard: ["write cb ECONNRESET | write ECONNRESET","error EPIPE write | EPIPE: broken pipe, write","close"]
aecd9e37a5:               ["write cb ECONNRESET | write ECONNRESET","error ECONNRESET write | write ECONNRESET","close"]
node v26.3.0:             ["write cb ECONNRESET | write ECONNRESET","error ECONNRESET write | write ECONNRESET","close"]

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