mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 06:12:08 +00:00
Compare commits
5 Commits
claude/ref
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cc73309d7 | ||
|
|
8d773e97e4 | ||
|
|
4befa83175 | ||
|
|
951f9657be | ||
|
|
0671c086b3 |
@@ -559,6 +559,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
/// These associate a route to the index in RouteList.cpp.
|
||||
/// User routes may get applied multiple times due to SNI.
|
||||
/// So we have to store it.
|
||||
/// Note: We don't free these during reload() because uWS handlers have direct pointers to them.
|
||||
user_routes: std.ArrayListUnmanaged(UserRoute) = .{},
|
||||
|
||||
on_clienterror: jsc.Strong.Optional = .empty,
|
||||
@@ -1019,7 +1020,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
pub fn onReloadFromZig(this: *ThisServer, new_config: *ServerConfig, globalThis: *jsc.JSGlobalObject) void {
|
||||
httplog("onReload", .{});
|
||||
|
||||
this.app.?.clearRoutes();
|
||||
const app = this.app.?;
|
||||
|
||||
// Clear routes for all SNI domains first to avoid dangling pointers
|
||||
this.clearSNIRoutesHelper(app, false); // false = don't remove server names on reload
|
||||
|
||||
// only reload those two, but ignore if they're not specified.
|
||||
if (this.config.onRequest != new_config.onRequest and (new_config.onRequest != .zero and !new_config.onRequest.isUndefined())) {
|
||||
@@ -1074,13 +1078,20 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
}
|
||||
this.config.user_routes_to_build.clearAndFree();
|
||||
this.config.user_routes_to_build = new_config.user_routes_to_build;
|
||||
for (this.user_routes.items) |*route| {
|
||||
route.deinit();
|
||||
}
|
||||
this.user_routes.clearAndFree(bun.default_allocator);
|
||||
|
||||
// Don't deinit or clear old UserRoutes during reload!
|
||||
// uWS route handlers have pointers directly to UserRoute structures in the ArrayList.
|
||||
// Even after clearRoutes(), in-flight handlers may still be executing.
|
||||
// We'll clean them all up during server deinit() when it's safe.
|
||||
}
|
||||
|
||||
const route_list_value = this.setRoutes();
|
||||
// When had_routes_object is false (fetch handler), pass true to free old routes
|
||||
// When had_routes_object is true (routes object reload), pass false to prevent freeing old routes during reload
|
||||
const route_list_value = this.setRoutesInternal(new_config.had_routes_object == false);
|
||||
|
||||
// Re-apply routes to all SNI domains after clearing
|
||||
this.reapplySNIRoutesHelper(app);
|
||||
|
||||
if (new_config.had_routes_object) {
|
||||
if (this.js_value.tryGet()) |server_js_value| {
|
||||
if (server_js_value != .zero) {
|
||||
@@ -1535,6 +1546,11 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
|
||||
this.notifyInspectorServerStopped();
|
||||
|
||||
// Clear SNI routes BEFORE closing listener to prevent route reuse
|
||||
if (this.app) |app| {
|
||||
this.clearSNIRoutesHelper(app, false); // Don't remove server names here - they get removed in deinit
|
||||
}
|
||||
|
||||
if (!abrupt) {
|
||||
listener.close();
|
||||
} else if (!this.flags.terminated) {
|
||||
@@ -1596,6 +1612,79 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to clear routes for all SNI domains
|
||||
/// This prevents dangling pointers when stopping/reloading servers with SNI
|
||||
fn clearSNIRoutesHelper(this: *ThisServer, app: *App, should_remove_server_names: bool) void {
|
||||
if (comptime !ssl_enabled) return;
|
||||
|
||||
// Preallocate capacity: 1 for main config + count of SNI entries
|
||||
const sni_count = if (this.config.sni) |sni| sni.len else 0;
|
||||
var server_names = std.ArrayList([*:0]const u8).initCapacity(bun.default_allocator, 1 + sni_count) catch std.ArrayList([*:0]const u8).init(bun.default_allocator);
|
||||
defer server_names.deinit();
|
||||
|
||||
// Add main ssl_config server name if present
|
||||
if (this.config.ssl_config) |*ssl_config| {
|
||||
if (ssl_config.server_name) |server_name_ptr| {
|
||||
const server_name: [:0]const u8 = std.mem.span(server_name_ptr);
|
||||
if (server_name.len > 0) {
|
||||
server_names.append(server_name_ptr) catch |err| {
|
||||
httplog("Failed to add server name to SNI route clearing list: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add all SNI server names
|
||||
if (this.config.sni) |*sni| {
|
||||
for (sni.slice()) |*sni_ssl_config| {
|
||||
if (sni_ssl_config.server_name) |server_name_ptr| {
|
||||
const server_name: [:0]const u8 = std.mem.span(server_name_ptr);
|
||||
if (server_name.len > 0) {
|
||||
server_names.append(server_name_ptr) catch |err| {
|
||||
httplog("Failed to add SNI server name to route clearing list: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always call clearSNIRoutes to clear default domain routes, even if no SNI names
|
||||
app.clearSNIRoutes(server_names.items, should_remove_server_names);
|
||||
}
|
||||
|
||||
/// Helper to re-apply routes to all SNI domains after clearing
|
||||
/// This is necessary during reload to ensure each domain has the updated handlers
|
||||
fn reapplySNIRoutesHelper(this: *ThisServer, app: *App) void {
|
||||
if (comptime !ssl_enabled) return;
|
||||
|
||||
// Re-apply routes for main server name if present
|
||||
if (this.config.ssl_config) |*ssl_config| {
|
||||
if (ssl_config.server_name) |server_name_ptr| {
|
||||
const server_name: [:0]const u8 = std.mem.span(server_name_ptr);
|
||||
if (server_name.len > 0) {
|
||||
app.domain(server_name);
|
||||
_ = this.setRoutes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply routes for each SNI domain
|
||||
if (this.config.sni) |*sni| {
|
||||
for (sni.slice()) |*sni_ssl_config| {
|
||||
if (sni_ssl_config.server_name) |server_name_ptr| {
|
||||
const server_name: [:0]const u8 = std.mem.span(server_name_ptr);
|
||||
if (server_name.len > 0) {
|
||||
app.domain(server_name);
|
||||
_ = this.setRoutes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to default domain after re-applying all SNI routes
|
||||
app.domain("");
|
||||
}
|
||||
|
||||
pub fn deinit(this: *ThisServer) void {
|
||||
httplog("deinit", .{});
|
||||
|
||||
@@ -1609,6 +1698,11 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
}
|
||||
this.user_routes.deinit(bun.default_allocator);
|
||||
|
||||
// Clear routes BEFORE deinit-ing config, since we need to access ssl_config and sni
|
||||
if (this.app) |app| {
|
||||
this.clearSNIRoutesHelper(app, true); // true = remove server names
|
||||
}
|
||||
|
||||
this.config.deinit();
|
||||
|
||||
this.on_clienterror.deinit();
|
||||
@@ -2461,6 +2555,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
}
|
||||
|
||||
fn setRoutes(this: *ThisServer) jsc.JSValue {
|
||||
return this.setRoutesInternal(true);
|
||||
}
|
||||
|
||||
fn setRoutesInternal(this: *ThisServer, should_free_old_routes: bool) jsc.JSValue {
|
||||
var route_list_value = jsc.JSValue.zero;
|
||||
const app = this.app.?;
|
||||
const any_server = AnyServer.from(this);
|
||||
@@ -2478,8 +2576,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
var user_routes_to_build_list = this.config.user_routes_to_build.moveToUnmanaged();
|
||||
var old_user_routes = this.user_routes;
|
||||
defer {
|
||||
for (old_user_routes.items) |*r| r.route.deinit();
|
||||
old_user_routes.deinit(bun.default_allocator);
|
||||
if (should_free_old_routes) {
|
||||
for (old_user_routes.items) |*r| r.route.deinit();
|
||||
old_user_routes.deinit(bun.default_allocator);
|
||||
}
|
||||
}
|
||||
this.user_routes = std.ArrayListUnmanaged(UserRoute).initCapacity(bun.default_allocator, user_routes_to_build_list.items.len) catch @panic("OOM");
|
||||
const paths_zig = bun.default_allocator.alloc(ZigString, user_routes_to_build_list.items.len) catch @panic("OOM");
|
||||
|
||||
@@ -55,6 +55,38 @@ extern "C"
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to clear routes for all SNI domains and optionally remove them
|
||||
// This prevents dangling pointers when stopping/reloading servers with SNI
|
||||
void uws_app_clear_sni_routes(int ssl, uws_app_t *app,
|
||||
const char **server_names,
|
||||
size_t server_name_count,
|
||||
int should_remove_server_names)
|
||||
{
|
||||
if (!ssl || !app) {
|
||||
return;
|
||||
}
|
||||
|
||||
uWS::SSLApp *uwsApp = (uWS::SSLApp *)app;
|
||||
|
||||
// Clear routes for each SNI domain
|
||||
if (server_names) {
|
||||
for (size_t i = 0; i < server_name_count; i++) {
|
||||
if (server_names[i] && server_names[i][0] != '\0') {
|
||||
uwsApp->domain(server_names[i]);
|
||||
uwsApp->clearRoutes();
|
||||
|
||||
if (should_remove_server_names) {
|
||||
uwsApp->removeServerName(server_names[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to the default domain and clear its routes as well
|
||||
uwsApp->domain("");
|
||||
uwsApp->clearRoutes();
|
||||
}
|
||||
|
||||
void uws_app_get(int ssl, uws_app_t *app, const char *pattern_ptr, size_t pattern_len, uws_method_handler handler, void *user_data)
|
||||
{
|
||||
std::string_view pattern = std::string_view(pattern_ptr, pattern_len);
|
||||
|
||||
@@ -67,6 +67,17 @@ pub fn NewApp(comptime ssl: bool) type {
|
||||
return c.uws_app_clear_routes(ssl_flag, @as(*uws_app_t, @ptrCast(app)));
|
||||
}
|
||||
|
||||
pub fn clearSNIRoutes(app: *ThisApp, server_names: []const [*:0]const u8, should_remove: bool) void {
|
||||
if (!ssl) return; // SNI only works with SSL
|
||||
return c.uws_app_clear_sni_routes(
|
||||
ssl_flag,
|
||||
@as(*uws_app_t, @ptrCast(app)),
|
||||
server_names.ptr,
|
||||
server_names.len,
|
||||
@intFromBool(should_remove),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn publishWithOptions(app: *ThisApp, topic: []const u8, message: []const u8, opcode: Opcode, compress: bool) bool {
|
||||
return c.uws_publish(
|
||||
@intFromBool(ssl),
|
||||
@@ -453,6 +464,13 @@ pub const c = struct {
|
||||
) void;
|
||||
|
||||
pub extern fn uws_app_clear_routes(ssl_flag: c_int, app: *uws_app_t) void;
|
||||
pub extern fn uws_app_clear_sni_routes(
|
||||
ssl_flag: c_int,
|
||||
app: *uws_app_t,
|
||||
server_names: [*]const [*:0]const u8,
|
||||
server_name_count: usize,
|
||||
should_remove_server_names: c_int,
|
||||
) void;
|
||||
};
|
||||
|
||||
const bun = @import("bun");
|
||||
|
||||
@@ -3,100 +3,169 @@ import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// This test verifies the fix for GitHub issue #21792:
|
||||
// SNI TLS array handling was incorrectly rejecting arrays with exactly 1 TLS config
|
||||
describe("SNI TLS array handling (issue #21792)", () => {
|
||||
// Use existing test certificates from jsonwebtoken tests
|
||||
// SNI with multiple TLS certificates caused crashes when stopping and restarting servers
|
||||
describe("SNI stop/restart crash (issue #21792)", () => {
|
||||
// Use existing test certificates
|
||||
const certDir = join(import.meta.dir, "../../js/third_party/jsonwebtoken");
|
||||
const crtA = readFileSync(join(certDir, "pub.pem"), "utf8");
|
||||
const keyA = readFileSync(join(certDir, "priv.pem"), "utf8");
|
||||
const crtB = crtA; // Reuse same cert for second test server
|
||||
const keyB = keyA;
|
||||
const cert = readFileSync(join(certDir, "pub.pem"), "utf8");
|
||||
const key = readFileSync(join(certDir, "priv.pem"), "utf8");
|
||||
|
||||
test("should accept empty TLS array (no TLS)", () => {
|
||||
// Empty array should be treated as no TLS
|
||||
using server = Bun.serve({
|
||||
test("should not crash when reusing same port with SNI after stop", async () => {
|
||||
const tls = [
|
||||
{ cert, key, serverName: "serverhost1.local" },
|
||||
{ cert, key, serverName: "serverhost2.local" },
|
||||
];
|
||||
|
||||
// 1. Create first server with SNI
|
||||
let server = Bun.serve({
|
||||
port: 0,
|
||||
tls: [],
|
||||
fetch: () => new Response("Hello"),
|
||||
development: true,
|
||||
tls: tls,
|
||||
fetch: () => new Response("Server 1"),
|
||||
development: false,
|
||||
});
|
||||
expect(server.url.toString()).toStartWith("http://"); // HTTP, not HTTPS
|
||||
|
||||
// Make request to register the routes in uWS
|
||||
const response1 = await fetch(server.url, {
|
||||
headers: { Host: "serverhost1.local" },
|
||||
tls: { servername: "serverhost1.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(await response1.text()).toBe("Server 1");
|
||||
|
||||
// 2. Stop the server - this frees the server but leaves routes in uWS
|
||||
const port = server.port;
|
||||
await server.stop();
|
||||
|
||||
// Force GC to ensure server is freed
|
||||
if (Bun?.gc) Bun.gc(true);
|
||||
await Bun.sleep(100);
|
||||
|
||||
// 3. Create new server on SAME PORT with SNI
|
||||
// This reuses the SSL contexts which still have the old route pointers
|
||||
server = Bun.serve({
|
||||
port: port,
|
||||
tls: tls,
|
||||
fetch: () => new Response("Server 2"),
|
||||
development: false,
|
||||
});
|
||||
|
||||
// 4. Make request - WITHOUT THE FIX this hits dangling pointer and crashes
|
||||
const response2 = await fetch(server.url, {
|
||||
headers: { Host: "serverhost1.local" },
|
||||
tls: { servername: "serverhost1.local", rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// Should get response from new server, not crash
|
||||
expect(await response2.text()).toBe("Server 2");
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test("should accept single TLS config in array", () => {
|
||||
// This was the bug: single TLS config in array was incorrectly rejected
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls: [{ cert: crtA, key: keyA, serverName: "serverA.com" }],
|
||||
fetch: () => new Response("Hello from serverA"),
|
||||
development: true,
|
||||
});
|
||||
expect(server.url.toString()).toStartWith("https://");
|
||||
});
|
||||
test("should not crash with routes object pattern", async () => {
|
||||
const tls = [
|
||||
{ cert, key, serverName: "route1.local" },
|
||||
{ cert, key, serverName: "route2.local" },
|
||||
];
|
||||
|
||||
test("should accept multiple TLS configs for SNI", () => {
|
||||
using server = Bun.serve({
|
||||
// Create server with routes object (like Elysia does)
|
||||
let server = Bun.serve({
|
||||
port: 0,
|
||||
tls: [
|
||||
{ cert: crtA, key: keyA, serverName: "serverA.com" },
|
||||
{ cert: crtB, key: keyB, serverName: "serverB.com" },
|
||||
],
|
||||
fetch: request => {
|
||||
const host = request.headers.get("host") || "unknown";
|
||||
return new Response(`Hello from ${host}`);
|
||||
tls: tls,
|
||||
routes: {
|
||||
"/": () => new Response("Route 1"),
|
||||
"/health": () => new Response("OK 1"),
|
||||
},
|
||||
development: true,
|
||||
fetch: () => new Response("Fallback 1"),
|
||||
development: false,
|
||||
});
|
||||
expect(server.url.toString()).toStartWith("https://");
|
||||
|
||||
const r1 = await fetch(server.url, {
|
||||
headers: { Host: "route1.local" },
|
||||
tls: { servername: "route1.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(await r1.text()).toBe("Route 1");
|
||||
|
||||
const port = server.port;
|
||||
await server.stop();
|
||||
if (Bun?.gc) Bun.gc(true);
|
||||
await Bun.sleep(100);
|
||||
|
||||
// Create new server with routes on same port
|
||||
server = Bun.serve({
|
||||
port: port,
|
||||
tls: tls,
|
||||
routes: {
|
||||
"/": () => new Response("Route 2"),
|
||||
"/health": () => new Response("OK 2"),
|
||||
},
|
||||
fetch: () => new Response("Fallback 2"),
|
||||
development: false,
|
||||
});
|
||||
|
||||
// This request should hit new routes, not crash with dangling pointer
|
||||
const r2 = await fetch(server.url, {
|
||||
headers: { Host: "route1.local" },
|
||||
tls: { servername: "route1.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(await r2.text()).toBe("Route 2");
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test("should reject TLS array with missing serverName for SNI configs", () => {
|
||||
expect(() => {
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
tls: [
|
||||
{ cert: crtA, key: keyA, serverName: "serverA.com" },
|
||||
{ cert: crtB, key: keyB }, // Missing serverName
|
||||
],
|
||||
fetch: () => new Response("Hello"),
|
||||
development: true,
|
||||
});
|
||||
}).toThrow("SNI tls object must have a serverName");
|
||||
});
|
||||
test("should not crash when reloading server with SNI", async () => {
|
||||
const tls = [
|
||||
{ cert, key, serverName: "reload1.local" },
|
||||
{ cert, key, serverName: "reload2.local" },
|
||||
];
|
||||
|
||||
test("should reject TLS array with empty serverName for SNI configs", () => {
|
||||
expect(() => {
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
tls: [
|
||||
{ cert: crtA, key: keyA, serverName: "serverA.com" },
|
||||
{ cert: crtB, key: keyB, serverName: "" }, // Empty serverName
|
||||
],
|
||||
fetch: () => new Response("Hello"),
|
||||
development: true,
|
||||
});
|
||||
}).toThrow("SNI tls object must have a serverName");
|
||||
});
|
||||
let responseText = "Version 1";
|
||||
|
||||
test("should accept single TLS config without serverName when alone", () => {
|
||||
// When there's only one TLS config in the array, serverName is optional
|
||||
using server = Bun.serve({
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
tls: [{ cert: crtA, key: keyA }], // No serverName - should work for single config
|
||||
fetch: () => new Response("Hello from default"),
|
||||
development: true,
|
||||
tls: tls,
|
||||
fetch: () => new Response(responseText),
|
||||
development: false,
|
||||
});
|
||||
expect(server.url.toString()).toStartWith("https://");
|
||||
});
|
||||
|
||||
test("should support traditional non-array TLS config", () => {
|
||||
// Traditional single TLS config (not in array) should still work
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls: { cert: crtA, key: keyA },
|
||||
fetch: () => new Response("Hello traditional"),
|
||||
development: true,
|
||||
// Make initial request
|
||||
const r1 = await fetch(server.url, {
|
||||
headers: { Host: "reload1.local" },
|
||||
tls: { servername: "reload1.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(server.url.toString()).toStartWith("https://");
|
||||
expect(await r1.text()).toBe("Version 1");
|
||||
|
||||
// Update response and reload
|
||||
responseText = "Version 2";
|
||||
server.reload({
|
||||
fetch: () => new Response(responseText),
|
||||
tls: tls,
|
||||
development: false,
|
||||
});
|
||||
|
||||
await Bun.sleep(100);
|
||||
|
||||
// This request should work with new handler, not crash with dangling pointer
|
||||
const r2 = await fetch(server.url, {
|
||||
headers: { Host: "reload1.local" },
|
||||
tls: { servername: "reload1.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(await r2.text()).toBe("Version 2");
|
||||
|
||||
// Reload again to test multiple reloads
|
||||
responseText = "Version 3";
|
||||
server.reload({
|
||||
fetch: () => new Response(responseText),
|
||||
tls: tls,
|
||||
development: false,
|
||||
});
|
||||
|
||||
await Bun.sleep(100);
|
||||
|
||||
const r3 = await fetch(server.url, {
|
||||
headers: { Host: "reload2.local" },
|
||||
tls: { servername: "reload2.local", rejectUnauthorized: false },
|
||||
});
|
||||
expect(await r3.text()).toBe("Version 3");
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user