Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,7 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) {
loop_ssl_data->ssl_send_error = 0;
if (!ssl_drain_spill(loop_ssl_data, s)) return s;
if (loop_ssl_data->ssl_send_error) return ssl_close_after_rejected_send(s, loop_ssl_data->ssl_send_error);
int sent_deferred_shutdown = s->ssl_shutdown_after_spill;
if (s->ssl_shutdown_after_spill) {
s->ssl_shutdown_after_spill = 0;
us_internal_ssl_shutdown(s);
Expand All @@ -2500,8 +2501,9 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) {
/* uWS HTTP sockets keep the pre-existing SENT_SHUTDOWN suppression: their
* onWritable clears the teardown timeout armed at shutdown. node sockets
* still get write-completion dispatch after a half-close in either
* direction. */
if (ssl_is_uws_http_tls(s) && us_internal_ssl_is_shut_down(s)) return s;
* direction. The event that sent a shutdown held behind the spill goes through:
* nothing else tells the owner that the bytes it counts as written have left. */
if (ssl_is_uws_http_tls(s) && us_internal_ssl_is_shut_down(s) && !sent_deferred_shutdown) return s;

if (s->ssl_handshake_state != HANDSHAKE_PENDING) {
s->ssl_write_parked = 0;
Expand Down
2 changes: 2 additions & 0 deletions packages/bun-uws/src/AsyncSocketData.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ struct AsyncSocketData {
/* Whether it has fired the filter with -3 (HttpResponse::setNodeHttpTunnelIdle).
* +3 undoes it, and an idle socket fires -4 in place of -2. */
bool filteredIdleTunnel = false;
/* Whether it has fired the filter with +5 (HttpResponse::setNodeHttpTunnelSending). -5 undoes it, on the paths that balance +2 too. */
bool filteredSendingTunnel = false;
};

}
Expand Down
10 changes: 10 additions & 0 deletions packages/bun-uws/src/HttpContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ struct HttpContext {
f((HttpResponse<SSL> *) s, -1);
}
}
((HttpResponse<SSL> *) s)->setNodeHttpTunnelSending(false);
if (httpResponseData->filteredAccept) {
for (auto &f : httpContextData->filterHandlers) {
f((HttpResponse<SSL> *) s, httpResponseData->filteredIdleTunnel ? -4 : -2);
Expand Down Expand Up @@ -894,6 +895,15 @@ struct HttpContext {
auto *asyncSocket = reinterpret_cast<AsyncSocket<SSL> *>(s);
auto *httpResponseData = reinterpret_cast<HttpResponseData<SSL> *>(asyncSocket->getAsyncSocketData());

/* Behind its FIN, a TLS socket gets this event only so that a tunnel hears that what TLS held has left (ssl_on_writable). */
if (SSL && us_socket_is_shut_down(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.

🟣 pre-existing, not blocking: pre-existing: over plain http a request body sent behind the FIN of req.socket.end() is still dropped when the finished response does not fit the kernel send buffer; the new SSL-only branch at HttpContext.h:899 lets https read it. For non-SSL, deferShutdownUntilResponseDrains (JSNodeHTTPServerSocket.cpp:358-369) sees uWS bytes and sets HTTP_CONNECTION_CLOSE, so the gate at HttpContext.h:1017-1021 runs shutdown() then close() once drained. Fix: on the non-SSL twin, a half-close deferred behind buffered response bytes must end in a shutdown only (a half-close-after-drain state consumed by onWritable), as the small-response path and Node (allowHalfOpen) do; then unskip the http row at node-http-server-socket-end-drain.test.ts:187. [also at: test/js/node/http/node-http-server-socket-end-drain.test.ts:204 - nit, pre-existing: this test locks in https keeping a Connection: close connection open after req.socket.end() to read the body behind its FIN, which Node and Bun's own http twin do not do.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A node:http (not https) handler calls res.end(body) with a body larger than the kernel takes in one send, then req.socket.end(), and the client sends a request body after it receives the server's FIN (the http row that test/js/node/http/node-http-server-socket-end-drain.test.ts:187 skips). jsFunctionNodeHTTPServerSocketEnd (JSNodeHTTPServerSocketPrototype.cpp:303) calls shutdownAfterResponseDrains; deferShutdownUntilResponseDrains at JSNodeHTTPServerSocket.cpp:358-369 finds getBufferedAmount() > 0 and sets HTTP_CONNECTION_CLOSE instead of a half-close. When the buffer drains, HttpContext::onWritable reaches the gate at HttpContext.h:997-1023: shouldCloseConnection() is true, so it runs shutdown() and close() and the request body never reaches req ('close' without 'end'). Over https getBufferedAmount() is 0, halfClose() defers only the shutdown, and the new early return at HttpContext.h:899-905 keeps that gate from running, so the body is read. The base branch drops the body on http the same way; this PR fixes only the SSL twin.

Verification: JSNodeHTTPServerSocket.cpp:358-369 sets HTTP_CONNECTION_CLOSE (line 364) when getBufferedAmount() != 0, so halfClose() is never reached. On the next writable event the gate at HttpContext.h:1017-1021 runs asyncSocket->shutdown(); asyncSocket->close(); and the body the client sends after seeing the FIN is never read. The new early return at HttpContext.h:899-905 is SSL-only.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both points are right, and both stay as they are in this PR: I ran res.end(body) + req.socket.end() + a request body behind the FIN for http/https, with and without Connection: close, with 2 B / 64 KiB / 8 MiB responses behind a 4 KiB SO_SNDBUF, and this head gives the same result as 5af7e9dc in all 12 cases.

  • Without Connection: close, Node v22.22.0 reads the body in all six cases. Bun drops it over http at 64 KiB and 8 MiB, and over https at 8 MiB. A half-close that waits for the uWS buffer is a change to ordinary responses, not to tunnels, so it belongs in its own PR.
  • With Connection: close, Node reads the body in none of the six cases. Bun reads it only over https at 64 KiB, where TLS alone holds the rest of the response. The test pins that case because it is the one place where the new writable event behind the FIN could change what an ordinary response does, not because it matches Node. If another push comes, the comment above the test will say so.

Generated by Claude Code

auto *httpContextData = getSocketContextDataS(s);
if (httpResponseData->isConnectRequest && httpResponseData->socketData && httpContextData->onSocketDrain) {
httpContextData->onSocketDrain(httpResponseData->socketData, SSL, (struct us_socket_t *) s);
}
return s;
}

/* Attempt to drain the socket buffer before triggering onWritable callback */
size_t bufferedAmount = asyncSocket->getBufferedAmount();
if (bufferedAmount > 0) {
Expand Down
20 changes: 18 additions & 2 deletions packages/bun-uws/src/HttpResponse.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ struct HttpResponse : public AsyncSocket<SSL> {
return true;
}

/* node:http: an idle tunnel is at read EOF and has nothing left to send. Like a libuv handle in that state, it does not hold
* the event loop. The filter hears -3 when a tunnel becomes idle and +3 when it has bytes to send again. */
/* node:http: an idle tunnel does not read (its reads are stopped or at EOF) and has nothing left to send. Like a libuv handle in
* that state, it does not hold the event loop: https://github.com/nodejs/node/blob/v26.3.0/deps/uv/src/unix/stream.c#L1473-L1485.
* The filter hears -3 when a tunnel becomes idle and +3 when it reads or has bytes to send again. */
void setNodeHttpTunnelIdle(bool idle) {
HttpResponseData<SSL> *httpResponseData = getHttpResponseData();
if (!httpResponseData->filteredAccept || httpResponseData->filteredIdleTunnel == idle) {
Expand All @@ -197,6 +198,20 @@ struct HttpResponse : public AsyncSocket<SSL> {
}
}

/* node:http: a tunnel with bytes left to send holds the event loop whatever is unref'd, like a libuv write request:
* https://github.com/nodejs/node/blob/v26.3.0/deps/uv/src/unix/core.c#L393-L398.
* The filter hears +5 when a tunnel has bytes left to send and -5 when it has none any more. */
void setNodeHttpTunnelSending(bool sending) {
HttpResponseData<SSL> *httpResponseData = getHttpResponseData();
if (!httpResponseData->filteredAccept || httpResponseData->filteredSendingTunnel == sending) {
return;
}
httpResponseData->filteredSendingTunnel = sending;
for (auto &f : HttpContext<SSL>::getSocketContextDataS((us_socket_t *) this)->filterHandlers) {
f(this, sending ? 5 : -5);
}
}

/* Marks the response in flight as one that user JavaScript produces. See
* HTTP_SEND_WHEN_COMPLETE. */
void sendWhenComplete() {
Expand Down Expand Up @@ -494,6 +509,7 @@ struct HttpResponse : public AsyncSocket<SSL> {
f((HttpResponse<SSL> *) this, -1);
}
}
setNodeHttpTunnelSending(false);
if (((AsyncSocketData<SSL> *) responseData)->filteredAccept) {
for (auto &f : httpContextData->filterHandlers) {
f((HttpResponse<SSL> *) this, ((AsyncSocketData<SSL> *) responseData)->filteredIdleTunnel ? -4 : -2);
Expand Down
22 changes: 18 additions & 4 deletions src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ void JSNodeHTTPServerSocket::readStop()
}
tunnelReadsStopped = true;
applyTunnelReads();
updateTunnelIdle();
}

void JSNodeHTTPServerSocket::readStart()
Expand All @@ -212,6 +213,7 @@ void JSNodeHTTPServerSocket::readStart()
}
tunnelReadsStopped = false;
applyTunnelReads();
updateTunnelIdle();
Comment thread
dylan-conway marked this conversation as resolved.
}

void JSNodeHTTPServerSocket::didDeliverQueuedTunnelBytes(size_t length)
Expand Down Expand Up @@ -396,6 +398,8 @@ JSC::EncodedJSValue JSNodeHTTPServerSocket::halfClose(JSC::JSGlobalObject* globa
if (socket && cycleReads) {
us_socket_resume(socket);
}
// The shutdown can send what TLS still held, and no writable event comes behind the FIN.
updateTunnelIdle();
return result;
}

Expand Down Expand Up @@ -884,16 +888,26 @@ extern "C" bool Bun__NodeHTTPServerSocket__writeBehindResponse(us_socket_t* sock
return is_ssl ? writeBehindResponse<true>(socket, data, length) : writeBehindResponse<false>(socket, data, length);
}

template<bool SSL>
static void updateTunnelIdleImpl(us_socket_t* socket, bool reads, bool buffered)
{
auto* response = reinterpret_cast<uWS::HttpResponse<SSL>*>(socket);
const bool sending = buffered || !response->hasFullyDrained();
response->setNodeHttpTunnelSending(sending);
response->setNodeHttpTunnelIdle(!reads && !sending);
}

void JSNodeHTTPServerSocket::updateTunnelIdle()
{
if (!tunnelReadEnded || upgraded || isClosed()) {
if (!isTunnel(this)) {
return;
}
Comment on lines +902 to 904

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.

🟣 pre-existing, not blocking: pre-existing: on an Upgrade request that carries a body, bytes a listener writes before the body completes neither hold the event loop nor get flushed, so behind server.unref() the process exits with them unsent and socket.end() after them never sends its FIN. updateTunnelIdle() at JSNodeHTTPServerSocket.cpp:902 returns while isConnectRequest is still false (HTTP_NODE_TUNNEL_AFTER_BODY), so no +5 fires, and HttpContext.h:940 skips onSocketDrain for the same reason, so the writable event drops its interest with streamBuffer still full. Fix: treat a HTTP_NODE_TUNNEL_AFTER_BODY socket as a tunnel for drain and sending/idle accounting, so the rule "bytes left to send hold the loop" covers this sibling path too.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

An 'upgrade' listener for a request with Content-Length > 0 calls socket.write(data) larger than the kernel takes while the body is still arriving. jsFunctionNodeHTTPServerSocketWrite (JSNodeHTTPServerSocketPrototype.cpp:281) parks the rest in streamBuffer, then calls updateTunnelIdle(), which returns at JSNodeHTTPServerSocket.cpp:902 because isTunnel() reads isConnectRequest and that is only set at HttpContext.h:672 when the body's fin is parsed; so setNodeHttpTunnelSending(true) never runs and sending_tunnel_ref (mod.rs:1633) is never taken. The writable event reaches HttpContext::onWritable, but :940 gates onSocketDrain on isConnectRequest, so JSNodeHTTPServerSocket::onDrain never flushes streamBuffer and loop.c:665 drops the writable interest. With server.unref() and nothing else holding the loop, the process exits with the bytes unsent; without unref, a later socket.end() returns at JSNodeHTTPServerSocketPrototype.cpp:317-320 waiting for a drain that never comes, so the FIN is never sent. The base branch behaves the same on this path.

Verification: pre-existing. An 'upgrade' listener for an Upgrade request that carries a body writes more than the kernel takes while the body is still arriving. updateTunnelIdle() returns at JSNodeHTTPServerSocket.cpp:902, so no +5 fires. HttpContext::onWritable skips onSocketDrain at HttpContext.h:940, and loop.c:665-666 then drops writable polling. Behind server.unref() the process exits with the bytes unsent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Stays as is, and the description lists it under "Not changed by this PR": the same output on 1.4.3, on a canary of main, on 5af7e9d and here, for http and https. Taking +5 alone there would be worse than today, because uWS does not flush that buffer until the body has ended: a body that never ends would hold the process for good. It needs the drain path changed first, so it is a fix of its own.


Generated by Claude Code

const bool sent = streamBuffer.bufferedSize() == 0;
const bool reads = !tunnelReadEnded && !tunnelReadsStopped;
const bool buffered = streamBuffer.bufferedSize() > 0;
if (is_ssl) {
reinterpret_cast<uWS::HttpResponse<true>*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast<uWS::AsyncSocket<true>*>(socket)->hasFullyDrained());
updateTunnelIdleImpl<true>(socket, reads, buffered);
} else {
reinterpret_cast<uWS::HttpResponse<false>*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast<uWS::AsyncSocket<false>*>(socket)->hasFullyDrained());
updateTunnelIdleImpl<false>(socket, reads, buffered);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/node/JSNodeHTTPServerSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject {
void readStop();
void readStart();
bool tunnelReadsPaused() const { return tunnelReadsStopped || tunnelReadsQueuedFull; }
/* Tells uWS whether this tunnel is idle: at read EOF with nothing left to send. See HttpResponse::setNodeHttpTunnelIdle. */
/* Tells uWS whether this tunnel has bytes left to send, and whether it is idle: it has none and does not read (readStop() or read EOF). See HttpResponse::setNodeHttpTunnelIdle. */
void updateTunnelIdle();
/* uWS still holds bytes of an HTTP response on this connection. A raw write has to go through the same buffer, or it reaches the wire first. */
bool hasUnsentResponseBytes() const;
Expand Down
38 changes: 32 additions & 6 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ bitflags::bitflags! {
pub struct ServerFlags: u8 {
const DEINIT_SCHEDULED = 1 << 0;
const TERMINATED = 1 << 1;
/// `server.unref()` with no `server.ref()` behind it.
const UNREFED_BY_USER = 1 << 2;
}
}

Expand Down Expand Up @@ -267,8 +269,11 @@ pub(crate) struct NewServer<const SSL: bool, const DEBUG: bool> {
/// ([`NewServer::is_drained`]); for Bun.serve it also holds the
/// graceful-stop promise open ([`NewServer::is_closed`]).
pub(crate) active_connection_count: core::cell::Cell<u32>,
/// The node:http tunnels in `active_connection_count` that are at read EOF and have nothing left to send.
/// The node:http tunnels in `active_connection_count` that do not read (stopped or at EOF) and have nothing left to send.
pub(crate) idle_tunnel_count: core::cell::Cell<u32>,
/// The node:http tunnels that have bytes left to send. They hold the loop through `sending_tunnel_ref`, whatever `server.unref()` did to `poll_ref`.
sending_tunnel_count: core::cell::Cell<u32>,
sending_tunnel_ref: jsc::JsCell<KeepAlive>,
/// Live `ServerWebSocket` count. Lives on the server (not the websocket
/// context) so a reload's context swap cannot reset it, and sits in a
/// `Cell` because the open/close accounting arrives through shared
Expand Down Expand Up @@ -506,7 +511,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
/// uWS filter: `+2` at TCP accept (before any TLS handshake), `-2` on
/// `HttpContext::onClose` / `HttpResponse::upgrade()` — see
/// `AsyncSocketData::filteredAccept`. Feeds [`Self::active_connection_count`].
/// `-3` / `+3`: a node:http tunnel becomes idle / has bytes to send again. `-4`: it closes idle.
/// A node:http tunnel: `-3` / `+3` it becomes idle / is not idle any more, `-4` it closes idle, `+5` / `-5` it has bytes left to send / has none any more.
extern "C" fn on_connection_filter(
_socket: *mut uws_sys::us_socket_t,
opened: i32,
Expand All @@ -527,13 +532,17 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
}
3 => {
this.note_tunnel_idle(false);
// With a listener open, the loop ref is the user's to drop (`server.unref()`).
if !this.has_listener() {
// SAFETY: `this` is not used again, and a tunnel write never runs inside `app.close()`.
// With a listener open, the loop ref is the user's to drop (`server.unref()`), and what they dropped stays dropped.
if !this.has_listener() && !this.flags.contains(ServerFlags::UNREFED_BY_USER) {
// SAFETY: `this` is not used again, and a tunnel write or read start never runs inside `app.close()`.
unsafe { &mut *user_data.cast::<Self>() }.ref_();
}
return;
}
5 | -5 => {
this.note_tunnel_sending(opened == 5);
return;
}
-3 => this.note_tunnel_idle(true),
-4 => {
this.note_tunnel_idle(false);
Expand Down Expand Up @@ -1615,7 +1624,22 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
});
}

/// An idle tunnel is open but, like a libuv handle at EOF with no write pending, does not hold the loop.
fn note_tunnel_sending(&self, sending: bool) {
let count = self.sending_tunnel_count.get();
// `AsyncSocketData::filteredSendingTunnel` pairs every +5 with one -5.
debug_assert!(sending || count > 0);
let count = if sending { count + 1 } else { count - 1 };
self.sending_tunnel_count.set(count);
self.sending_tunnel_ref.with_mut(|keep_alive| {
if count > 0 {
keep_alive.ref_(self.vm.loop_ctx());
} else {
keep_alive.unref(self.vm.loop_ctx());
}
});
}

/// An idle tunnel is open but, like a libuv handle that does not read and has no write pending, does not hold the loop.
fn has_loop_holding_connections(&self) -> bool {
self.active_connection_count.get() > self.idle_tunnel_count.get()
}
Expand Down Expand Up @@ -2202,6 +2226,8 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
pending_requests: core::cell::Cell::new(0),
active_connection_count: core::cell::Cell::new(0),
idle_tunnel_count: core::cell::Cell::new(0),
sending_tunnel_count: core::cell::Cell::new(0),
sending_tunnel_ref: jsc::JsCell::new(KeepAlive::default()),
active_websocket_count: core::cell::Cell::new(0),
deinit_running: core::cell::Cell::new(false),
abort_handle: jsc::AbortHandle::for_owner::<Self>(),
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,7 @@ where
callframe: &CallFrame,
) -> JsResult<JSValue> {
let this_value = callframe.this();
self.flags.remove(ServerFlags::UNREFED_BY_USER);
self.ref_();
Ok(this_value)
}
Expand All @@ -2489,6 +2490,7 @@ where
callframe: &CallFrame,
) -> JsResult<JSValue> {
let this_value = callframe.this();
self.flags.insert(ServerFlags::UNREFED_BY_USER);
self.unref();
Ok(this_value)
}
Expand Down
53 changes: 53 additions & 0 deletions test/js/node/http/node-http-connect-proxy-pipes-fixture.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading