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
21 changes: 12 additions & 9 deletions packages/bun-usockets/src/quic.c
Original file line number Diff line number Diff line change
Expand Up @@ -1005,16 +1005,19 @@ void us_quic_stream_close(us_quic_stream_t *s) {

/* From lsquic_stream.h (not in the public header). */
void lsquic_stream_maybe_reset(struct lsquic_stream *, uint64_t error_code, int);
uint64_t lsquic_stream_peer_error_code(const struct lsquic_stream *);

/* Abort the send half with RESET_STREAM(H3_REQUEST_CANCELLED) instead of
* FIN. lsquic_stream_close/shutdown queue FIN after the buffered tail,
* which is a protocol error if a content-length was advertised and the
* client is abandoning the upload short — the server's lsquic will
* CONNECTION_CLOSE on the mismatch (RFC 9114 §4.1.2). RESET_STREAM is
* the wire-level "I'm cancelling this send" and lets the server treat it
* as a stream-level cancellation rather than a malformed message. */
void us_quic_stream_reset(us_quic_stream_t *s) {
if (s->stream) lsquic_stream_maybe_reset(s->stream, 0x10C, 1);
/* Signal an HTTP/3 stream error carrying `code` (RFC 9114 §8.1), not a clean
* FIN (which would violate an advertised content-length). RESET_STREAM on an
* open send half; otherwise STOP_SENDING carries the code. */
void us_quic_stream_reset(us_quic_stream_t *s, uint64_t code) {
if (s->stream) lsquic_stream_maybe_reset(s->stream, code, 1);
}

/* Application error code from the peer's RESET_STREAM or STOP_SENDING
* (RFC 9114 §8.1), or 0 if none has arrived. */
uint64_t us_quic_stream_peer_error_code(us_quic_stream_t *s) {
return s->stream ? lsquic_stream_peer_error_code(s->stream) : 0;
}

int us_quic_stream_has_unacked(us_quic_stream_t *s) {
Expand Down
6 changes: 5 additions & 1 deletion packages/bun-usockets/src/quic.h
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ void us_quic_stream_shutdown(us_quic_stream_t *s);
void us_quic_stream_flush(us_quic_stream_t *s);
void us_quic_stream_shutdown_read(us_quic_stream_t *s);
void us_quic_stream_close(us_quic_stream_t *s);
void us_quic_stream_reset(us_quic_stream_t *s);
/* Abort the stream with HTTP/3 application error `code` (RFC 9114 §8.1):
* RESET_STREAM if the send half is still open, otherwise STOP_SENDING. */
void us_quic_stream_reset(us_quic_stream_t *s, uint64_t code);
/* Application error code from the peer's RESET_STREAM or STOP_SENDING, or 0. */
uint64_t us_quic_stream_peer_error_code(us_quic_stream_t *s);
int us_quic_stream_has_unacked(us_quic_stream_t *s);

void *us_quic_stream_ext(us_quic_stream_t *s);
Expand Down
101 changes: 101 additions & 0 deletions patches/lsquic/stream-error-code.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
Plumb HTTP/3 application error codes through lsquic's stream error paths.

Upstream lsquic has two gaps that make RFC 9114 §4.1.2 stream errors
(H3_MESSAGE_ERROR, 0x010E) impossible to emit or observe from an application:

- lsquic_stream_maybe_reset()'s already-closed-write branch discards the
caller's error_code, and generate_stop_sending_frame() hardcodes
HEC_NO_ERROR, so a peer that has already FIN'd its send half (every GET)
cannot put an application error code on the wire at all.
- The peer's incoming RESET_STREAM / STOP_SENDING application error code is
parsed and then dropped before the on_reset callback; nothing stores it
and no accessor exists.

This records the caller's code so STOP_SENDING carries it, stores the peer's
incoming code in a new sm_peer_error_code field, and adds the
lsquic_stream_peer_error_code() accessor. Every internal lsquic caller
passes error_code == 0, which preserves the HEC_NO_ERROR default, so no
existing behavior changes. Not upstream: lsquic has no issue tracker entry
for this; the gap is simply unexposed API surface.

--- a/src/liblsquic/lsquic_stream.h
+++ b/src/liblsquic/lsquic_stream.h
@@ -275,6 +275,10 @@
uint64_t max_send_off;
uint64_t sm_last_recv_off;
uint64_t error_code;
+ /* Application error code carried by the peer's RESET_STREAM or
+ * STOP_SENDING frame. Zero until one is received.
+ */
+ uint64_t sm_peer_error_code;

/* From the network, we get frames, which we keep on a list ordered
* by offset.
@@ -521,6 +525,12 @@
void
lsquic_stream_maybe_reset (struct lsquic_stream *, uint64_t error_code, int);

+/* Application error code from the peer's RESET_STREAM or STOP_SENDING frame,
+ * or zero if none has been received.
+ */
+uint64_t
+lsquic_stream_peer_error_code (const struct lsquic_stream *);
+
void
lsquic_stream_call_on_close (lsquic_stream_t *);

--- a/src/liblsquic/lsquic_stream.c
+++ b/src/liblsquic/lsquic_stream.c
@@ -1221,6 +1221,7 @@
return 0;
}

+ stream->sm_peer_error_code = error_code;
SM_HISTORY_APPEND(stream, SHE_RST_IN);
/* This flag must always be set, even if we are "ignoring" it: it is
* used by elision code.
@@ -1308,6 +1309,7 @@
return;
}

+ stream->sm_peer_error_code = error_code;
SM_HISTORY_APPEND(stream, SHE_STOP_SENDIG_IN);
stream->stream_flags |= STREAM_SS_RECVD;

@@ -4313,7 +4315,21 @@
stream_reset(stream, error_code, do_close);
}
else if (do_close)
+ {
+ /* Send half already closed or reset: STOP_SENDING is the only stream
+ * error left, so record the code for generate_stop_sending_frame.
+ * First error wins; a later cancel must not clobber a queued one. */
+ if (!stream->error_code)
+ stream->error_code = error_code;
stream_shutdown_read(stream);
+ }
+}
+
+
+uint64_t
+lsquic_stream_peer_error_code (const struct lsquic_stream *stream)
+{
+ return stream->sm_peer_error_code;
}


--- a/src/liblsquic/lsquic_full_conn_ietf.c
+++ b/src/liblsquic/lsquic_full_conn_ietf.c
@@ -2558,7 +2558,11 @@
generate_stop_sending_frame (struct ietf_full_conn *conn,
struct lsquic_stream *stream)
{
- if (0 == generate_stop_sending_frame_by_id(conn, stream->id, HEC_NO_ERROR))
+ /* Non-zero error_code is an explicit application stream error (set by
+ * lsquic_stream_maybe_reset); carry it on the STOP_SENDING instead of
+ * NO_ERROR. Every internal path leaves it zero. */
+ if (0 == generate_stop_sending_frame_by_id(conn, stream->id,
+ stream->error_code ? stream->error_code : HEC_NO_ERROR))
{
lsquic_stream_ss_frame_sent(stream);
return 1;
1 change: 1 addition & 0 deletions scripts/build/deps/lsquic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const lsquic: Dependency = {
"patches/lsquic/allow-no-sni.patch",
"patches/lsquic/skip-priority-walk.patch",
"patches/lsquic/disable-gquic.patch",
"patches/lsquic/stream-error-code.patch",
],

fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"],
Expand Down
8 changes: 7 additions & 1 deletion src/http/H3Client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! - `callbacks` — lsquic → Rust glue (on_hsk_done / on_stream_* / …)
//! - `PendingConnect` — DNS-pending connect resolution

use core::sync::atomic::AtomicU32;
use core::sync::atomic::{AtomicU32, AtomicU64};

#[path = "h3_client/AltSvc.rs"]
pub mod alt_svc;
Expand Down Expand Up @@ -55,5 +55,11 @@ pub static live_streams: AtomicU32 = AtomicU32::new(0);
pub use live_sessions as LIVE_SESSIONS;
pub use live_streams as LIVE_STREAMS;

/// Test-only: the HTTP/3 application error code (RFC 9114 §8.1) Bun.serve's
/// h3 server last observed on a peer RESET_STREAM or STOP_SENDING. Set only
/// by the debug-build `x-bun-test-100-then-data` hook; always 0 in release.
#[allow(non_upper_case_globals)]
pub static test_last_peer_stream_error: AtomicU64 = AtomicU64::new(0);

// H3TestingAPIs lives in bun_http_jsc and is accessed via the
// extension-trait pattern there.
14 changes: 13 additions & 1 deletion src/http/h3_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ impl ClientSession {
// is the correct "I'm abandoning this send half" so lsquic reaps
// the stream instead of leaking it on the pooled session.
if !request_body_done {
qs.reset();
qs.reset(quic::ErrorCode::REQUEST_CANCELLED);
}
}
st.qstream = None;
Expand All @@ -201,6 +201,18 @@ impl ClientSession {
}
}

/// Fail `stream` for a malformed response as a stream error of type
/// H3_MESSAGE_ERROR (RFC 9114 §4.1.2), not the clean FIN `fail()` would
/// emit. Mirrors `h2_client::ClientSession::rst_stream(PROTOCOL_ERROR)`.
pub fn fail_malformed(&mut self, stream: *mut Stream) {
// Must run before abort()/detach(): their close() sets lsquic's
// U_WRITE_DONE, which neuters lsquic_stream_maybe_reset.
if let Some(qs) = stream_mut(stream).qstream_mut() {
qs.reset(quic::ErrorCode::MESSAGE_ERROR);
}
self.fail(stream, crate::Error::HTTP3ProtocolError);
}

/// A stream closed before any response headers arrived. If the request
/// hasn't been retried yet and the body wasn't a JS stream (which may
/// already be consumed), re-enqueue it on a fresh session — this is the
Expand Down
11 changes: 9 additions & 2 deletions src/http/h3_client/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) {
if stream.status_code == 0
&& (is_malformed_response_field(name) || is_malformed_response_value(value))
{
session.fail(stream, crate::Error::HTTP3ProtocolError);
session.fail_malformed(stream);
return;
}
stream
Expand All @@ -243,7 +243,7 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) {
if stream.status_code != 0 {
return;
}
session.fail(stream, crate::Error::HTTP3ProtocolError);
session.fail_malformed(stream);
return;
}
if status >= 100 && status < 200 {
Expand All @@ -256,6 +256,13 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) {
extern "C" fn on_stream_data(s: *mut quic::Stream, data: *const u8, len: c_uint, fin: c_int) {
let s = qstream_arg(s);
let Some(stream) = stream_of(s) else { return };
// RFC 9114 §4.1: DATA before the final response HEADERS is malformed
// (a 1xx alone leaves status_code 0); lsquic only checks that *some*
// HEADERS preceded DATA, so a 1xx + DATA flood would grow unbounded.
if len > 0 && stream.status_code == 0 {
stream.session_mut().fail_malformed(stream);
return;
}
// SAFETY: lsquic guarantees `data` points to `len` valid bytes (or `(null,0)`).
let slice = unsafe { bun_core::ffi::slice(data, len as usize) };
stream.body_buffer.extend_from_slice(slice);
Expand Down
20 changes: 20 additions & 0 deletions src/http_jsc/headers_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ impl H3TestingAPIs {
);
Ok(obj)
}

/// Last HTTP/3 application error code (RFC 9114 §8.1) the h3 server
/// observed on a peer RESET_STREAM or STOP_SENDING. Written only by the
/// debug-build `x-bun-test-100-then-data` hook; always 0 in release.
pub(crate) fn quic_test_peer_stream_error(
_global: &JSGlobalObject,
_frame: &CallFrame,
) -> JsResult<JSValue> {
use bun_http::h3_client;
Ok(JSValue::js_number_from_uint64(
h3_client::test_last_peer_stream_error.load(Ordering::Relaxed),
))
}
}

/// Free-fn aliases of [`H2TestingAPIs::live_counts`] /
Expand All @@ -210,3 +223,10 @@ pub fn h2_live_counts(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JS
pub fn h3_quic_live_counts(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JSValue> {
H3TestingAPIs::quic_live_counts(global, frame)
}
#[inline]
pub fn h3_quic_test_peer_stream_error(
global: &JSGlobalObject,
frame: &CallFrame,
) -> JsResult<JSValue> {
H3TestingAPIs::quic_test_peer_stream_error(global, frame)
}
7 changes: 7 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,13 @@ export const fetchH3Internals = {
sessions: number;
streams: number;
},
/**
* HTTP/3 application error code (RFC 9114 §8.1) that Bun.serve's h3 server
* last observed on a peer RESET_STREAM or STOP_SENDING. Only ever written
* by the debug-build `x-bun-test-100-then-data` server hook's abort
* handler; always 0 in release builds.
*/
lastPeerStreamError: $newRustFunction("http/H3Client.rs", "TestingAPIs.quicTestPeerStreamError", 0) as () => number,
};

export const fileSinkInternals = {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/dispatch_js2native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_

pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts;
pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts;
pub use bun_http_jsc::headers_jsc::h3_quic_test_peer_stream_error as http_h3_client_testing_ap_is_quic_test_peer_stream_error;

/// Lives here (not in `src/bun.rs`)
/// because the flag it reads — `cli::Arguments::Bun__Node__UseSystemCA` — is
Expand Down
25 changes: 25 additions & 0 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2706,6 +2706,31 @@ where
if !Self::HAS_H3 {
unreachable!();
}
#[cfg(bun_debug)]
{
// Test hook: HEADERS(100) then DATA with no final response, a
// sequence no conformant handler can produce (RFC 9114 §4.1).
// Exercised by fetch-http3-adversarial.test.ts on debug builds.
if let Some(body) = req.header(b"x-bun-test-100-then-data") {
use core::sync::atomic::Ordering;
http::h3_client::test_last_peer_stream_error.store(0, Ordering::Relaxed);
resp.write_continue();
// Leaves the stream open, so it can only close once the
// client's stream error arrives and the code is recordable.
resp.test_data_after_informational(body);
// Record the stream error the client puts on the wire in
// response, so the test can assert it is H3_MESSAGE_ERROR
// (RFC 9114 §4.1.2) and not a clean close.
resp.on_aborted(
|_s: &mut Self, r: &mut uws::H3::Response| {
http::h3_client::test_last_peer_stream_error
.store(r.peer_error_code(), Ordering::Relaxed);
},
core::ptr::from_mut(self),
);
return;
}
}
if self.config.on_request.is_none() {
return Self::on_h3_404(self, req, resp);
}
Expand Down
20 changes: 20 additions & 0 deletions src/uws_sys/h3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ impl Response {
pub fn write_continue(&mut self) {
c::uws_h3_res_write_continue(self)
}
/// Application error code from the peer's RESET_STREAM or STOP_SENDING
/// frame (RFC 9114 §8.1), or 0 if none has arrived yet.
pub fn peer_error_code(&mut self) -> u64 {
c::uws_h3_res_peer_error_code(self)
}
/// Test-only: queue `data` as a DATA frame with no final response HEADERS
/// (RFC 9114 §4.1) and leave the stream open so only the client's stream
/// error can close it. Exercised by `fetch-http3-adversarial.test.ts`.
#[cfg(bun_debug)]
pub fn test_data_after_informational(&mut self, data: &[u8]) {
// SAFETY: self is a live FFI handle; data ptr/len valid for read
unsafe { c::uws_h3_res_test_data_after_informational(self, data.as_ptr(), data.len()) }
}
pub fn flush_headers(&mut self, immediate: bool) {
c::uws_h3_res_flush_headers(self, immediate)
}
Expand Down Expand Up @@ -603,6 +616,13 @@ mod c {
opts: BunSocketContextOptions,
) -> bool;
pub(super) safe fn uws_h3_res_write_continue(res: &mut Response);
pub(super) safe fn uws_h3_res_peer_error_code(res: &mut Response) -> u64;
#[cfg(bun_debug)]
pub(super) fn uws_h3_res_test_data_after_informational(
res: *mut Response,
p: *const u8,
n: usize,
);
pub(super) fn uws_h3_app_get(
app: *mut App,
p: *const u8,
Expand Down
20 changes: 20 additions & 0 deletions src/uws_sys/libuwsockets_h3.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ void uws_h3_res_pause(uws_h3_res_t* res) { ((Http3Response*)res)->pause(); }
void uws_h3_res_resume(uws_h3_res_t* res) { ((Http3Response*)res)->resume(); }
void uws_h3_res_write_continue(uws_h3_res_t* res) { ((Http3Response*)res)->writeContinue(); }

/* Application error code from the peer's RESET_STREAM or STOP_SENDING
* (RFC 9114 §8.1), or 0 if none has arrived yet. */
uint64_t uws_h3_res_peer_error_code(uws_h3_res_t* res)
{
return us_quic_stream_peer_error_code((us_quic_stream_t*)res);
}

#ifdef BUN_DEBUG
/* Test-only: DATA with no final-response HEADERS (RFC 9114 §4.1). Queued via
* backpressure so it flushes after lsquic's stashed 1xx block; leaves the
* stream open so only the client's stream error can close it (test reads it). */
void uws_h3_res_test_data_after_informational(uws_h3_res_t* res, const char* data, size_t length)
{
Http3ResponseData* d = ((Http3Response*)res)->getHttpResponseData();
d->state |= Http3ResponseData::HTTP_STATUS_CALLED | Http3ResponseData::HTTP_WRITE_CALLED;
d->backpressure.append(data, length);
us_quic_stream_want_write((us_quic_stream_t*)res, 1);
}
#endif

void uws_h3_res_write_status(uws_h3_res_t* res, const char* status, size_t length)
{
((Http3Response*)res)->writeStatus(sv(status, length));
Expand Down
1 change: 1 addition & 0 deletions src/uws_sys/quic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod stream;
pub use self::context::Context;
pub use self::pending_connect::PendingConnect;
pub use self::socket::Socket;
pub use self::stream::ErrorCode;
pub use self::stream::Stream;

pub use self::header::Header;
Expand Down
Loading
Loading