Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d5f7aa2
Bun.serve: add HTTP/3 (QUIC) listener via `h3: true`
Jarred-Sumner Apr 27, 2026
a0fc950
serve(h3): route HTTP/3 through RequestContext instead of a separate …
Jarred-Sumner Apr 27, 2026
33ac980
serve(h3): mirror static and file routes onto the H3 app
Jarred-Sumner Apr 27, 2026
768ff6d
serve(h3): fix UAF in tryEnd after synchronous stream close; adversar…
Jarred-Sumner Apr 27, 2026
d211992
serve(h3): fix 7 bugs from branch sweep
Jarred-Sumner Apr 27, 2026
6017dc6
serve(h3): production-readiness pass
Jarred-Sumner Apr 27, 2026
b3e8fc2
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
7ff1a21
LICENSE: add lsquic, ls-qpack, ls-hpack
Jarred-Sumner Apr 27, 2026
8706e4b
serve(h3): address review findings
Jarred-Sumner Apr 27, 2026
b1eacbd
Http3Request: no-arg getQuery() strips leading '?' to match HttpRequest
Jarred-Sumner Apr 27, 2026
6acf87b
serve(h3): second-round review fixes
Jarred-Sumner Apr 27, 2026
fa49794
quic.c: include <stdio.h> for fwrite/stderr in us_quic_log_buf
Jarred-Sumner Apr 27, 2026
4c57d66
quic.c: gate lsquic logger to BUN_DEBUG builds
Jarred-Sumner Apr 27, 2026
49fca0a
quic.c: drop dead <assert.h>; lift lsquic global init into the C++ layer
Jarred-Sumner Apr 27, 2026
d6f713e
serve(h3): drive lsquic from loop_post + drainMicrotasks instead of p…
Jarred-Sumner Apr 27, 2026
c5ff317
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
afa5d7c
serve(h3): count each QUIC connection as a virtual poll
Jarred-Sumner Apr 27, 2026
08f9a4a
remove stray h3blast load-tester and bench script accidentally swept in
Jarred-Sumner Apr 27, 2026
60279c5
restore h3blast load tester source + http3-hello bench; gitignore bui…
Jarred-Sumner Apr 27, 2026
64c96b2
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
bbc106d
test(h3): waitForStderr rejects on child EOF; rename misnamed concurr…
Jarred-Sumner Apr 27, 2026
f751000
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
82314e2
serve(h3): batch process_conns at loop_pre/loop_post; encoder-side perf
Jarred-Sumner Apr 27, 2026
d75e84e
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
8336f33
serve(h3): skip lsquic priority walk; fix UDP EPOLLERR+EPOLLOUT spin
Jarred-Sumner Apr 27, 2026
145711b
Http3ResponseData: WTF::Vector<_, N> for header storage
Jarred-Sumner Apr 27, 2026
fd09e8e
serve(h3): drop the QUIC timerfd; fold earliest_adv_tick into getTimeout
Jarred-Sumner Apr 27, 2026
3f3cf81
serve(h3): review-feedback batch
Jarred-Sumner Apr 27, 2026
09987e7
serve(h3): drainQuicIfNecessary after the deferred-task queue
Jarred-Sumner Apr 27, 2026
b1305ec
serve(h3): drop inline threshold flush; parse sock_extended_err in MS…
Jarred-Sumner Apr 27, 2026
7510ebb
serve(h3): include <netinet/in.h> for Android; review nits
Jarred-Sumner Apr 27, 2026
4322337
NodeHTTP: explicit case 0 / ASSERT_NOT_REACHED in toUWSResponse kind …
Jarred-Sumner Apr 27, 2026
3a48620
serve: type the resp_kind selector as a real enum on both sides
Jarred-Sumner Apr 27, 2026
bf70914
serve(h3): fill uncovered methods when "/*" route is method-specific
Jarred-Sumner Apr 27, 2026
f52eed3
serve(h3): enable on Windows
Jarred-Sumner Apr 27, 2026
b887d7b
serve(h3): drop the remaining Windows guards missed in f52eed3
Jarred-Sumner Apr 27, 2026
9712ae4
serve(h3): cap CL-less bodies at maxRequestBodySize; treat h3_listene…
Jarred-Sumner Apr 27, 2026
0704dc8
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
77fe95f
serve(h3): drop literal Host when :authority is present; review fixups
Jarred-Sumner Apr 27, 2026
bc7fb4a
Http3Request: case-insensitive host skip in forEachHeader
Jarred-Sumner Apr 27, 2026
562b4f1
serve(h3): coderabbit review pass
Jarred-Sumner Apr 27, 2026
136c156
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 27, 2026
deace3b
HTTPHeaderNames: add Proxy-Connection; fix h3/libuv timer re-arm
Jarred-Sumner Apr 27, 2026
63b0ab3
Http3Request: getHeader("host") returns the resolved authority directly
Jarred-Sumner Apr 27, 2026
ad7cddc
serve(h3): emit Alt-Svc on static/file routes; close quic_timer on lo…
Jarred-Sumner Apr 27, 2026
f4ee4df
ServerConfig: reject h1:false with a unix socket
Jarred-Sumner Apr 27, 2026
9bebc25
test(h3): make graceful-stop test diagnostic; widen post-stop sleep
Jarred-Sumner Apr 27, 2026
3addffd
test(serve-protocols): use getReader/releaseLock so the stderr drain …
Jarred-Sumner Apr 27, 2026
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ Third-party C/C++ libraries are vendored locally and can be read from disk (thes
- `vendor/libuv/` - libuv (Windows event loop)
- `vendor/lolhtml/` - lol-html (HTML rewriter)
- `vendor/lshpack/` - ls-hpack (HTTP/2 HPACK)
- `vendor/lsqpack/` - ls-qpack (HTTP/3 QPACK)
- `vendor/lsquic/` - lsquic (QUIC / HTTP/3)
- `vendor/mimalloc/` - mimalloc (memory allocator)
- `vendor/nodejs/` - Node.js headers (compatibility)
- `vendor/picohttpparser/` - PicoHTTPParser (HTTP parsing)
Expand Down
3 changes: 3 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Bun statically links these libraries:
| [`brotli`](https://github.com/google/brotli) | MIT |
| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) |
| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause |
| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT |
| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT |
| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) |
| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT |
| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License |
| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license |
Expand Down
58 changes: 58 additions & 0 deletions bench/snippets/http3-hello.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

let cert, key;
if (process.argv[2] && process.argv[3]) {
cert = readFileSync(process.argv[2], "utf8");
key = readFileSync(process.argv[3], "utf8");
} else {
const dir = mkdtempSync(join(tmpdir(), "h3-hello-"));
const certPath = join(dir, "cert.pem");
const keyPath = join(dir, "key.pem");
const { status, stderr } = spawnSync(
"openssl",
[
"req",
"-x509",
"-nodes",
"-newkey",
"rsa:2048",
"-days",
"365",
"-subj",
"/CN=localhost",
"-keyout",
keyPath,
"-out",
certPath,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
if (status !== 0) {
throw new Error("openssl failed: " + stderr);
}
cert = readFileSync(certPath, "utf8");
key = readFileSync(keyPath, "utf8");
}

const TOTAL = 10_000_000;
var i = 0;

const server = Bun.serve({
port: 3001,
h3: true,
h1: true,
tls: { cert, key, rejectUnauthorized: false },
routes: { "/hi": new Response("hello!") },
fetch(req) {
if (i++ === TOTAL - 1) setTimeout(() => server.stop().then(() => process.exit(0)));
return new Response("Hello, World!" + i);
Comment thread
Jarred-Sumner marked this conversation as resolved.
},
});
setTimeout(() => {}, 999999);

console.log(String(server.url));
13 changes: 13 additions & 0 deletions packages/bun-types/serve.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,19 @@ declare module "bun" {
*/
ipv6Only?: boolean;

/**
* Also listen for HTTP/3 (QUIC) on the same port. Requires {@link tls}.
* @default false
*/
h3?: boolean;

/**
* Listen for HTTP/1.1 (and HTTP/2) over TCP. Set to `false` together
* with `h3: true` to serve HTTP/3 only.
* @default true
*/
h1?: boolean;

/**
* Sets the number of seconds to wait before timing out a connection
* due to inactivity.
Expand Down
13 changes: 9 additions & 4 deletions packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,16 @@ void us_timer_set(struct us_timer_t *t, void (*cb)(struct us_timer_t *t),
struct us_internal_callback_t *internal_cb =
(struct us_internal_callback_t *)t;

// only add the timer to the event loop once
if (internal_cb->has_added_timer_to_event_loop) {
return;
// Match the epoll_kqueue backend: re-arming is allowed (uv_timer_start
// restarts an already-running timer). The one-shot guard only applies to
// the sweep timer, which is set with the same args from every new socket
// context — restarting it would skew the 4s tick.
if (internal_cb->loop->data.sweep_timer == t) {
if (internal_cb->has_added_timer_to_event_loop) {
return;
}
internal_cb->has_added_timer_to_event_loop = 1;
}
internal_cb->has_added_timer_to_event_loop = 1;

internal_cb->cb = (void (*)(struct us_internal_callback_t *))cb;

Expand Down
19 changes: 19 additions & 0 deletions packages/bun-usockets/src/internal/loop_data.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,30 @@
#endif

// IMPORTANT: When changing this, don't forget to update the zig version in uws.zig as well!
struct us_quic_socket_context_s;

struct us_internal_loop_data_t {
struct us_timer_t *sweep_timer;
int sweep_timer_count;
struct us_internal_async *wakeup_async;
struct us_socket_context_t *head;
/* QUIC engines on this loop. us_quic_loop_process walks the list from
* loop_post / drainMicrotasks; the lazy fallthrough timer only wakes the
* loop for lsquic's time-driven state (RTO, ACK delay) — its callback
* just calls us_quic_loop_process. */
struct us_quic_socket_context_s *quic_head;
/* µs until lsquic next wants process_conns (min earliest_adv_tick
* across engines), or -1 for "no deadline". Written by
* us_quic_loop_process from loop_post; read by Bun's getTimeout() to
* bound the epoll_pwait2 timeout. No timerfd, no scheduling syscall —
* the gap between loop_post and getTimeout is sub-µs so storing the
* relative diff is precise enough. */
long long quic_next_tick_us;
/* libuv only: a fallthrough us_timer_t armed to quic_next_tick_us so the
* uv loop wakes for lsquic's time-driven state. POSIX folds the deadline
* into the epoll_pwait2 timeout via getTimeout() instead, so this stays
* NULL there. */
struct us_timer_t *quic_timer;

Check warning on line 59 in packages/bun-usockets/src/internal/loop_data.h

View check run for this annotation

Claude / Claude Code Review

quic_timer not freed in us_internal_loop_data_free (libuv leak)

nit: On the libuv backend, `us_quic_loop_process()` lazily creates `loop->data.quic_timer = us_create_timer(loop, 1, 0)` (quic.c:148-149) the first time an H3 deadline is armed, but `us_internal_loop_data_free()` (loop.c:87-97) only closes `sweep_timer` and `wakeup_async` — never `quic_timer`. The uv_timer_t + its `us_internal_callback_t` allocation leak when the loop is destroyed. Bun's main loop is process-lifetime so this rarely matters, but worker threads create/destroy their own loops, so a
Comment thread
claude[bot] marked this conversation as resolved.
struct us_socket_context_t *iterator;
struct us_socket_context_t *closed_context_head;
char *recv_buf;
Expand Down
75 changes: 56 additions & 19 deletions packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@
// clang-format off
#include "libusockets.h"
#include "internal/internal.h"
#include "quic.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#ifndef WIN32
#include <sys/ioctl.h>
#endif
#ifdef __linux__
#include <netinet/in.h>
#include <linux/errqueue.h>
#endif

#if __has_include("wtf/Platform.h")
#include "wtf/Platform.h"
Expand Down Expand Up @@ -308,10 +314,19 @@ void us_internal_loop_pre(struct us_loop_t *loop) {
us_internal_handle_dns_results(loop);
us_internal_handle_low_priority_sockets(loop);
loop->data.pre_cb(loop);
#ifdef LIBUS_USE_QUIC
/* Flush stream writes that JS tasks made before this tick (timers,
* immediates, promise resolutions outside on_read) so they go out
* before epoll blocks. loop_post handles what this iteration receives. */
if (loop->data.quic_head) us_quic_loop_process(loop);
#endif
}

void us_internal_loop_post(struct us_loop_t *loop) {
us_internal_handle_dns_results(loop);
#ifdef LIBUS_USE_QUIC
if (loop->data.quic_head) us_quic_loop_process(loop);
#endif
/* A poll callback may re-enter the loop (e.g. expect().toThrow() →
* waitForPromise → us_loop_run_bun_tick). The inner tick must not free
* closed sockets/contexts: the outer tick's dispatch is mid-iteration
Expand Down Expand Up @@ -608,25 +623,43 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
#if defined(__linux__)
/* On Linux with IP_RECVERR, EPOLLERR fires when an ICMP error
* (port unreachable, host unreachable, TTL exceeded, ...) is
* queued on the socket. The kernel may or may not also set
* EPOLLIN. Calling recvmmsg on such a socket returns -1 with
* the ICMP errno (ECONNREFUSED, EHOSTUNREACH, ENETUNREACH,
* EMSGSIZE, ...), which we surface via on_recv_error. The
* socket stays open. On other platforms (kqueue's EV_ERROR,
* Windows) an error event is a fatal socket condition, not a
* drainable error queue — preserve the pre-existing
* close-on-error behavior. */
* queued on the socket's error queue. For an *unconnected* UDP
* socket regular recvmmsg does NOT dequeue these — only
* recvmsg(MSG_ERRQUEUE) does — so EPOLLERR stays level-triggered
* until we drain it explicitly. Do that here, surfacing each
* errno via on_recv_error; the socket stays open. On other
* platforms (kqueue EV_ERROR, Windows) an error event is fatal —
* preserve close-on-error there. */
int recv_error_surfaced = 0;
/* recv_would_block_only means: we drained the error queue and
* the only remaining outcome was EAGAIN, so the residual
* EPOLLERR is stale — don't treat it as fatal. */
int recv_would_block_only = 0;
int recv_drain_for_error = error;
#else
int recv_drain_for_error = 0;
if (error) {
struct msghdr eh; char ectrl[512]; char ebuf[1];
struct iovec eiov = { ebuf, sizeof(ebuf) };
while (!u->closed) {
memset(&eh, 0, sizeof(eh));
eh.msg_iov = &eiov; eh.msg_iovlen = 1;
eh.msg_control = ectrl; eh.msg_controllen = sizeof(ectrl);
if (recvmsg(us_poll_fd(p), &eh, MSG_ERRQUEUE) < 0) break;
recv_error_surfaced = 1;
if (u->on_recv_error) {
/* The queued ICMP error is in sock_extended_err,
* not errno. */
int ee = 0;
for (struct cmsghdr *cm = CMSG_FIRSTHDR(&eh); cm; cm = CMSG_NXTHDR(&eh, cm)) {
if ((cm->cmsg_level == IPPROTO_IP && cm->cmsg_type == IP_RECVERR) ||
(cm->cmsg_level == IPPROTO_IPV6 && cm->cmsg_type == IPV6_RECVERR)) {
ee = ((struct sock_extended_err *) CMSG_DATA(cm))->ee_errno;
break;
}
}
u->on_recv_error(u, ee ? ee : ECONNREFUSED);
}
}
}
#endif

if ((events & LIBUS_SOCKET_READABLE) || recv_drain_for_error) {
if ((events & LIBUS_SOCKET_READABLE) && !u->closed) {

do {
struct udp_recvbuf recvbuf;
bsd_udp_setup_recvbuf(&recvbuf, u->loop->data.recv_buf, LIBUS_RECV_BUFFER_LENGTH);
Expand Down Expand Up @@ -664,14 +697,18 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
} while (!u->closed);
}

if (events & LIBUS_SOCKET_WRITABLE && !error && !u->closed) {
if (events & LIBUS_SOCKET_WRITABLE && !u->closed) {
/* Clear WRITABLE before on_drain so a callback that re-arms it
* (e.g. QUIC packets_out hitting EAGAIN) keeps the re-arm. We
* still default to one-shot drain semantics for callers that
* don't touch the poll mask. Not gated on !error: a queued
* ICMP error must not leave WRITABLE armed (level-triggered
* EPOLLOUT + EPOLLERR would spin the loop). */
us_poll_change(&u->p, u->loop, us_poll_events(&u->p) & LIBUS_SOCKET_READABLE);
u->on_drain(u);
if (u->closed) {
break;
}
// We only poll for writable after a read has failed, and only send one drain notification.
// Otherwise we would receive a writable event on every tick of the event loop.
us_poll_change(&u->p, u->loop, us_poll_events(&u->p) & LIBUS_SOCKET_READABLE);
}

#if defined(__linux__)
Expand Down
Loading
Loading