Skip to content

fetch: reject a close-delimited body that a connection reset cut short - #46235

Draft
robobun wants to merge 2 commits into
mainfrom
robobun/6840fc3e/fetch-close-delimited-reset
Draft

robobun wants to merge 2 commits into
mainfrom
robobun/6840fc3e/fetch-close-delimited-reset

Conversation

@robobun

@robobun robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: two decisions needed.

  1. On Linux a close-delimited body (no Content-Length, no chunked framing) rejects when its connection ends without a FIN, even with every byte in. Node resolves it. No flag.
  2. uSockets keeps one fetch-only int: the errno of a rejected TLS send.

Problem

  • fetch() over HTTP/1.x completes a close-delimited body at any close. A TCP reset that cuts it resolves as a complete 200, and Bun.write leaves a short file.
  • The client cannot tell a reset from a FIN. Handler::on_close (src/http/HTTPContext.rs) dropped the close code.

Fix

  • The client records how the read side ended (ReadEnd). Only a FIN completes the body. A cut fails with ECONNRESET, as for a framed body.
  • Unchanged: 101 upgrades, unix sockets, CONNECT tunnels, systems other than Linux and Android.
  • Verified: 58 new tests (fetch-gzip.test.ts, fetch-syscall-fault.test.ts, a source lint). 38 fail on main's source.
  • Self-reviewed: 30 concerns raised, 25 addressed (rest in Notes).

Background

  • The kernel gives a reset's errno to the first recv(), send() or SO_ERROR read. On Linux EPIPE means the FIN came first.
  • Considered: the close code alone. It still resolved 7 of 8 slow-reader cuts and would reject 8 of 10 whole https bodies.

Downsides

  • Breaking on Linux: a reply from a server that closes with request bytes unread fails its body read. Node's 431 reply to a 100 KB POST: 10 of 10 (1.4.3: 0 of 10).
  • Release-build costs are not measured yet.
Notes

The two questions

1. The difference from Node's fetch, with no flag. undici completes a close-delimited body on ECONNRESET on purpose (onHttpSocketError in client-h1.js, nodejs/undici#784, a macOS workaround for a whole body). curl exits 56 and Chrome errors the stream. After this PR, on Linux:

  • A body that a reset cut short rejects. This is the fix.
  • A whole body whose connection ends with a reset and no FIN (on TLS: no close_notify) also rejects. The client cannot tell the two apart. Measured on this branch against a stock Node 26.3 http.createServer (default header limit), 10 runs each, res.text() of Node's own 431 reply:
request 1.4.3 this branch Node's fetch
GET, 17 KB or 40 KB header resolves, 10 resolves, 10 resolves, 10
GET, 200 KB header resolves, 10 ECONNRESET, 10 resolves, 10
POST, 17 KB header, 100 KB body resolves, 10 ECONNRESET, 10 resolves, 10
POST, 17 KB header, 4 MB body resolves, 10 ECONNRESET, 9 resolves, 8 (2 reject in fetch())

fetch() itself still resolves with the status in these rows. Only the body read rejects.

  • If the answer is no, this PR closes.

2. State in uSockets for fetch only. int rejected_send_error in the cold per-SSL us_ssl_rare_t (packages/bun-usockets/src/crypto/openssl.c, size stays 64 bytes, static assert added) and one accessor, us_socket_rejected_send_error. us_socket_t and us_socket_flags do not change.

  • Why it exists: when TLS has a send rejected and then reads the peer's FIN, uSockets closes with a literal ECONNRESET (ssl_close_after_rejected_send). The errno of that send says what happened: EPIPE on Linux means the FIN came before the reset, so the body is whole. Without the int, a whole https answer to an upload that the server did not read rejects (the pin https: a FIN with no close_notify, then a reset fails). The cut-body tests pass without it.
  • Alternative, not built: one int in the padding of us_socket_t after unclassified_send_failures, stored by us_internal_classify_failed_send. It gives one home for plain TCP and TLS and removes InternalStateFlags::write_took_error. The review of usockets: drain the receive queue before closing on a peer reset #39860 asked to remove state from us_socket_t rather than add to it, so this PR does not take that path without a decision.
  • If the answer is no to both, the client needs its own TLS write latch (write_check_error on every TLS request write).
  • Overlap: open tls: keep what a client sends behind its ClientHello while an SNICallback is pending #46073 also changes us_ssl_rare_t and asserts 64 bytes. The two together are 72 bytes, so the second to land must agree on one layout.

How the errno of a reset is kept

The kernel gives the errno of a reset to one caller. After that the next recv() returns 0, like a FIN.

  • recv() fails: uSockets closes with the errno as the close code (loop.c). Handler::read_end classifies it. Before, on_close ignored the code.
  • SO_ERROR: maybe_pause_receive read it through is_closed_or_has_error() before a receive pause. Under the rule it checks is_closed() || is_shutdown() only. Off Linux and Android the read stays.
  • A plain-TCP request write: write_once uses write_check_error. A send that fails with ECONNRESET sets write_took_error, which turns Fin into Error. EPIPE and every other errno do not.
  • A TLS send: the int of question 2.
  • On TLS the peer's close_notify ends its stream, whatever follows (SSL_RECEIVED_SHUTDOWN ranks first in read_end).

test/internal/source-lints/http-reset-errno.test.ts keeps two lists closed: only write_once writes to the socket in src/http/lib.rs, and SO_ERROR is read in src/http only where no body can still end.

What still resolves as before

  • A truncation that ends with a FIN (bun, Node and curl all accept it).
  • A TLS end of file without close_notify (browsers accept it).
  • fetch over a unix socket: on AF_UNIX ECONNRESET only says the peer closed with input unread.
  • fetch through a CONNECT tunnel: the socket ends with the proxy's teardown. With the rule on, 27 of 160 whole answers through five CONNECT relays rejected (squid, tinyproxy, npm proxy, two Go relays). With the exemption 160 of 160 resolve.
  • A 101-upgraded stream.
  • macOS, Windows, FreeBSD: the rule is off. From a read of XNU source, a whole body, a FIN and then a reset read as ECONNRESET there. Nothing was run on those systems.
  • A Bun.file upload over plain http (sendfile) is outside the rule.
  • A cut whose errno a send took first, when that errno is not ECONNRESET (a timeout, an abort, or an errno uSockets retries).
  • A cut behind a relay that turns the origin's reset into a FIN (docker-proxy, HAProxy TCP mode, nginx stream, socat in the self-review's runs).

What else changes

  • A compressed close-delimited body that a reset cuts now fails with ECONNRESET, not with the decoder's error that fetch: reject truncated compressed bodies on close-delimited responses #34922 documented. A cut on a gzip member or zstd frame boundary now rejects. It resolved before.
  • A whole reply followed by a reset with no FIN rejects on a plain GET too (test the accepted break).
  • When the server's FIN segment is lost and a reset follows, Linux reports ECONNRESET and the whole answer rejects (self-review run: 30 of 30 with the FIN dropped).
  • Through slirp4netns and gvproxy a FIN then a reset arrived as a bare reset in the self-review's runs: 37 of 320 whole answers rejected. curl failed the same way.
  • bun install and the other users of the client get the same rule.

Tests

  • test/js/web/fetch/fetch-gzip.test.ts: 43 new (30 fail on main's source, 13 pins that pass before and after). It holds the matrix beside the close-delimited tests of the same function. fetch.test.ts and fetch.tls.test.ts need the network, so the before and after runs could not be done on them offline. That is why the tests are not there.
  • test/js/web/fetch/fetch-syscall-fault.test.ts: 13 new (6 fail on main's source, 7 pins). They need fault injection.
  • test/internal/source-lints/http-reset-errno.test.ts: 2.
  • Each exemption has a pin that fails when the exemption is removed. Checked with one-line local edits and a rebuild: the close_notify rank, the unix exemption, the tunnel exemption, the SO_ERROR leg, the narrow send latch, and the accessor returning 0.
  • Existing suites on the final debug build, once each: chunked-trailing 23/0, 39846 1/0, fetch.unix 16/0, fetch.upgrade 1/0, fetch-keepalive 49/0, fetch-redirect 30/0, proxy 103/0, serve-stream-body-error 34/0.
  • Three files had 5-second timeouts on that build: tls-syscall-fault (2 of 48, the beside a stalled TLS socket tests), fetch-backpressure (5 of 129: four h3 tests and one keep-alive test), body (0, 1 and 2 of 778 in three runs, in string body consumption does not leak). The first two time out the same way on a debug build of main on the same machine. body was not compared with main.
  • bun run rust:check-all: 12 ok.

Not done

  • Release builds of main and of this branch, for the cost lines: getsockopt(SO_ERROR) per receive pause (expected to go from about 1 per pause to 0 on Linux), instructions per plain-TCP request write through us_socket_write_check_error, the TLS send path (the store is in a noinline, cold helper), InternalState size, binary size.
  • The kernel errno order on macOS and Windows for three cases (data, FIN, reset. Data, reset. Data, FIN). It decides if the rule can be turned on there.
  • A run on Android.

Self-review

30 concerns raised. 19 addressed in code, tests or text. 6 addressed in part (they name the two questions above and the systems that were not measured). Not taken: moving the tests to fetch.test.ts and fetch.tls.test.ts (reason above). Left for the maintainer: the single store in us_socket_t (three entries, question 2). Open: the layout overlap with #46073.

Prior work


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/web/fetch/fetch-syscall-fault.test.ts, test/js/web/fetch/fetch-gzip.test.ts

@robobun

robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How it was reproduced: a node:net server writes the response head and half of a body that has no Content-Length and no chunked framing, then resets the connection (socket.resetAndDestroy()). On 1.4.3 and on main, await res.text() resolves with the half body. On this branch, on Linux, it rejects with ECONNRESET. The same body ended by a FIN resolves on both.

PR: #46235

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:37 AM PT - Oct 22nd, 2026

@robobun, your commit 1d173c0 is building: #130912

A response with no Content-Length and no chunked framing ends when the
server closes the connection. The HTTP/1 client took every close for that
end, so a TCP reset completed a truncated body as a whole 200 response.

On Linux and Android the client now records how the read side ended:
ReadEnd::Fin or ReadEnd::Error. The close-delimited arm of
is_body_complete_on_close completes the body only on Fin. A body that a
reset cut short fails with ConnectionClosed (ECONNRESET).

The kernel gives the errno of a reset to the first call that meets it.
The client keeps it in these cases:
- Handler::on_close classifies the uSockets close code. It ignored the
  code before.
- maybe_pause_receive does not read SO_ERROR under the rule. That read
  took the pending errno, and the next recv() returned 0 like a FIN.
- A plain-TCP request write uses write_check_error. A send that fails
  with ECONNRESET sets a latch that turns Fin into Error.

On TLS a close_notify from the peer ends its stream, whatever the
connection does after it. A TLS connection that had a send rejected
(us_socket_ssl_send_rejected) keeps the old rule: the code of its close
stands for the lost records, not for a failed read.

The rule does not apply to a 101-upgraded connection, a request over a
unix socket, or a request through a CONNECT tunnel. On other systems
fetch does what it did before.
fetch-gzip.test.ts has the fetch matrix, beside the close-delimited tests
of the same function. A body with no Content-Length and no chunked
framing fails with ECONNRESET when a reset cuts it short. The strict
assertions are for Linux. On other systems the read must settle. The
group "still the end of the body" passes with and without the rule.

fetch-syscall-fault.test.ts has the deterministic cases. A fault puts the
errno of the reset in one send() or recv().

http-reset-errno.test.ts is a source lint. It keeps two lists closed: the
writes of the HTTP/1 client to its socket, and the reads of SO_ERROR in
src/http.

The fetch error section of the docs states the rule and its limits.
@robobun
robobun force-pushed the robobun/6840fc3e/fetch-close-delimited-reset branch from 388e672 to 1d173c0 Compare October 22, 2026 12:37
Comment thread src/http/HTTPContext.rs
Comment on lines +1469 to +1470
/// How the read side of `socket` ended, from the close that uSockets reports with
/// `code`: above `CloseCode::fast_shutdown` that is the error of the failed read (loop.c).

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

Comment thread src/http/HTTPContext.rs
Comment on lines +1476 to +1478
// The peer's close_notify ended its stream, whatever the connection did after
// it. BoringSSL sets the flag for a fatal read error too; uSockets closes that
// session itself, with no errno.

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

Comment thread src/http/HTTPContext.rs
Comment on lines +1488 to +1490
// A send that the kernel rejected took the connection's error. The code of the
// close that follows stands for the lost records, not for a failed read, so it
// does not say how the read side ended: the body keeps the old rule.

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

Comment thread src/http/InternalState.rs
Comment on lines +101 to +102
/// A request write failed with ECONNRESET. The failed `send()` took that error from the
/// kernel, so the read side then ends with `recv() == 0` like a FIN.

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

Comment thread src/http/InternalState.rs
Comment on lines +265 to +266
/// close-delimited response (no Content-Length, no Transfer-Encoding) whose
/// connection reached the end of the peer's stream.

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

Comment thread src/http/lib.rs
Comment on lines +1487 to +1490
/// Whether a close-delimited body completes only at the peer's FIN. Limited to the kernel whose
/// errno order was measured: on Linux a reset that follows the peer's FIN leaves EPIPE, and only
/// a reset with no FIN leaves ECONNRESET. Elsewhere every end of the connection completes such a
/// body.

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

Comment thread src/http/lib.rs
Comment on lines +1493 to +1495
/// Whether `errno`, a uSockets close code, says that the connection was cut before the peer's
/// stream ended. Up to `FastShutdown` a close code is this side's own close, and EPIPE is the
/// reset that followed the peer's FIN.

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

Comment thread src/http/lib.rs
Comment on lines +1500 to +1506
/// One write to the socket that carries a request. Returns what `socket.write` returns.
///
/// A plain-TCP `send()` that fails with ECONNRESET takes the reset out of the kernel, so the
/// read side can no longer report it: `took_error` keeps that for `HTTPClient::on_close`.
/// ECONNRESET only: a send can also fail with a timeout or an abort after the peer's FIN
/// arrived, and the body is whole then. A TLS connection that had a send rejected keeps the
/// old rule (`Handler::read_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

Comment thread src/http/lib.rs
Comment on lines +2288 to +2290
// An upgraded connection is a byte stream with no body to cut. On a unix socket
// ECONNRESET only says that the peer closed with input unread, whole answer or not.
// Behind a CONNECT tunnel the socket ends with the proxy's teardown, not the origin's.

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

Comment thread src/http/lib.rs
Comment on lines +2327 to +2329
// A close-delimited body ends at the peer's FIN (RFC 9112 section 8): a reset leaves
// it incomplete. undici completes it (`onHttpSocketError` in client-h1.js,
// nodejs/undici#784), so Node's fetch resolves with the part that arrived.

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

Comment thread src/http/lib.rs
Comment on lines +4330 to +4331
// Under the rule, not SO_ERROR: reading it takes a pending reset out of the kernel,
// and the `recv()` after the pause then returns 0 like a FIN.

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

Comment thread src/uws_sys/socket.rs
Comment on lines +289 to +292
/// Reads AND clears `SO_ERROR`. Once it has taken a pending reset, the next
/// `recv()` returns 0 like a FIN, so a caller that still reads a body from
/// this socket must not call it: it is for the decision to keep or drop a
/// connection whose exchange is over.

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

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