Skip to content

In a terminated tunnel, an HTTP/1.0 client gets the whole answer - #704

Merged
ronleizrowice-ant merged 2 commits into
mainfrom
fix/http10-answers-arrive-whole
Oct 16, 2026
Merged

ronleizrowice-ant merged 2 commits into
mainfrom
fix/http10-answers-arrive-whole

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

In a TLS-terminated tunnel, an HTTP/1.0 client could lose the end of a long answer. With a Content-Length, curl says (18) transfer closed with N bytes remaining. Without one, the file is short and nothing says so.

In no release: it came with in-process termination (#666). Bun 1.4.3 shows it. Bun 1.4.2, which CI runs, and Node do not.

Why

Under Bun the TLS socket runs on a stream of ours over the tunnel's socket. It has finished once it has written into that stream, long before the client has the bytes.

Bun 1.4.3's HTTP server ends an HTTP/1.0 response with end(), then destroySoon(), which is to destroy the socket once all that was written is out. Here it believes that at once. Traced, for one answer of 5 MiB: 311,738 bytes still queued at the destroy, and 4,915,200 of 5,242,880 delivered. After HTTP/1.1 with Connection: close the server calls end() alone, and the close waits for the flush.

What changes

One line, in serverTlsOn: on that TLS socket, destroySoon() ends it. The tunnel then closes as it does after Connection: close, once the client has everything.

destroy() still cuts at once. The first commit here did it otherwise: it counted every destroy() with no error as an end. That softened the proxy's own back-stops, which destroy a tunnel a second after ending it. With no mux in front (srt proxy), a refused client that never closes was held for 5 to 6 s, where main lets it go after 1 s. The second commit takes that back; the stream is main's again.

What it costs

An HTTP/1.0 client is now treated as one of HTTP/1.1 with Connection: close already is:

main, HTTP/1.0 main, 1.1 + close; here, both
reads it all, and never closes let go at once after 5 to 7 s
reads nothing let go at once (the data dropped) after 300 to 365 s

A client that closes when it has the answer, as curl does, sees no difference.

Testing

curl --http1.0 through the public export: 1, 5 and 20 MiB, with and without a Content-Length, 8 at once. Whole answers:

main here
Bun 1.4.3 0 of 144 144 of 144
Bun 1.4.2, Node all 144 of 144 each
HTTP/1.1, Connection: close and keep-alive all all
  • Refused clients that never read and never close, after a 403 and a 400, with and without the mux, on three builds of Bun: let go when main lets them go (0.2 to 1.3 s).
  • Four HTTP/1.0 clients that read nothing, with maxTunnels: 4: a new CONNECT gets 503, then 200 from 303 to 363 s on.
  • Four tests, each on a real TLS socket from serverTlsOn, so each runtime brings its own order of events. destroySoon(), with and without an end() first: the client gets every byte. Both fail on main under Bun 1.4.2 too. destroy(), at once and once the socket has finished: cut. The second fails on this PR's first commit.
  • 37 network test files on Bun 1.4.2 and 1.4.3: nothing fails that does not on main.

…S socket

Under Bun the TLS socket runs on a stream over the tunnel's socket, and has
finished once it has written there, long before the client has it all.
Destroyed then, it took the stream and the socket with it, and what was queued
was dropped. Bun 1.4.3's HTTP server does that after every HTTP/1.0 response
(destroySoon): the client lost the tail of a long answer, with no error where
there is no Content-Length.

A destroy with no error, once the TLS socket has finished, now counts as the
stream's end, and the client is given all it holds. And once the stream has
handed the socket to closeAfterFlush, its own destruction leaves the socket
alone: that cut the close short, and a client with bytes nobody had read lost
what the kernel still held for it.
@ronleizrowice-ant ronleizrowice-ant changed the title In a terminated tunnel, an HTTP/1.0 client gets the whole answer [on hold] In a terminated tunnel, an HTTP/1.0 client gets the whole answer Oct 14, 2026
… cuts again

The commit before this one counted every destroy() with no error, once the TLS
socket had finished, as the end of the stream under it. That cured the lost
tail of an HTTP/1.0 answer, but it softened the proxy's own back-stops, which
destroy a tunnel a second after ending it: with no mux in front (srt proxy) a
refused client that never closes was held for 5 to 6 s, not 1 s.

The two calls mean different things. destroy() is to cut now. destroySoon() is
to destroy once all that was written is out, and under Bun it believes that too
early, because the TLS socket has finished as soon as it has written to the
stream under it. So destroySoon() now ends that socket, which closes the tunnel
after the flush, and the stream is as it was.
@ronleizrowice-ant ronleizrowice-ant changed the title [on hold] In a terminated tunnel, an HTTP/1.0 client gets the whole answer In a terminated tunnel, an HTTP/1.0 client gets the whole answer Oct 14, 2026

@ig-ant ig-ant left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ronleizrowice-ant
ronleizrowice-ant merged commit 1e8b808 into main Oct 16, 2026
16 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the fix/http10-answers-arrive-whole branch October 16, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants