Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ int us_ssl_ctx_use_privatekey_content(SSL_CTX *ctx, const char *content,
int reason_code, ret = 0;
BIO *in;
EVP_PKEY *pkey = NULL;
if (content == NULL) return 0;
in = BIO_new_mem_buf(content, strlen(content));
if (in == NULL) {
OPENSSL_PUT_ERROR(SSL, ERR_R_BUF_LIB);
Expand Down Expand Up @@ -963,6 +964,7 @@ int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) {

ERR_clear_error(); // clear error stack for SSL_CTX_use_certificate()

if (content == NULL) return 0;
Comment thread
claude[bot] marked this conversation as resolved.
in = BIO_new_mem_buf(content, strlen(content));
if (in == NULL) {
OPENSSL_PUT_ERROR(SSL, ERR_R_BUF_LIB);
Expand Down
76 changes: 51 additions & 25 deletions src/bun.js/api/server/SSLConfig.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,31 @@ client_renegotiation_window: u32 = 0,
requires_custom_request_ctx: bool = false,
is_using_default_ciphers: bool = true,
low_memory_mode: bool = false,
ref_count: RC = .init(),
cached_hash: u64 = 0,
ref_count: RC = .init(),

const RC = bun.ptr.ThreadSafeRefCount(@This(), "ref_count", destroy, .{});
// Split strong/weak refcounting (Arc/Weak). The GlobalRegistry holds a WEAK
// ref on interned entries; upgrade() lets it safely try to revive one without
// risking a 0->1 strong resurrection race.
const RC = bun.ptr.ThreadSafeWeakableRefCount(@This(), "ref_count", dropContents, freeMemory, .{});
pub const ref = RC.ref;
pub const deref = RC.deref;
const weakRef = RC.weakRef;
const weakDeref = RC.weakDeref;
const upgrade = RC.upgrade;

/// strong 1->0. Evict from the weak registry while content is still intact
/// (map eql/hash read it), then destruct field contents. The mixin drops the
/// collective weak ref after this returns.
fn dropContents(this: *SSLConfig) void {
GlobalRegistry.remove(this);
this.deinit();
}

/// weak 1->0. Struct allocation is no longer observable by anyone.
fn freeMemory(this: *SSLConfig) void {
bun.default_allocator.destroy(this);
}

const ReadFromBlobError = bun.JSError || error{
NullStore,
Expand Down Expand Up @@ -269,13 +288,10 @@ pub fn contentHash(this: *SSLConfig) u64 {
return this.cached_hash;
}

/// Called by the RC mixin when refcount reaches 0.
fn destroy(this: *SSLConfig) void {
GlobalRegistry.remove(this);
this.deinit();
bun.default_allocator.destroy(this);
}

/// Weak dedup cache. Each map entry holds a WEAK ref on its key.
/// Safety: upgrade() is memory-safe without the mutex (weak ref keeps the
/// struct allocated). The mutex only protects map structure and the invariant
/// that entry content is intact while in the map.
pub const GlobalRegistry = struct {
const MapContext = struct {
pub fn hash(_: @This(), key: *SSLConfig) u32 {
Expand All @@ -289,36 +305,46 @@ pub const GlobalRegistry = struct {
var mutex: bun.Mutex = .{};
var configs: std.ArrayHashMapUnmanaged(*SSLConfig, void, MapContext, true) = .empty;

/// Takes ownership of a heap-allocated SSLConfig.
/// If an identical config already exists in the registry, the new one is freed
/// and the existing one is returned (with refcount incremented).
/// If no match, the new config is registered and returned.
/// Takes ownership of a heap-allocated SSLConfig (strong=1, weak=1). Returns
/// either an existing equivalent (strong ref'd) or the passed config.
/// Either way the caller owns exactly one strong ref on the result.
pub fn intern(new_config: *SSLConfig) *SSLConfig {
mutex.lock();
defer mutex.unlock();

// Look up by content hash/equality
const gop = bun.handleOom(configs.getOrPutContext(bun.default_allocator, new_config, .{}));
if (gop.found_existing) {
// Identical config already exists - free the new one, return existing
const existing = gop.key_ptr.*;
new_config.ref_count.clearWithoutDestructor();
new_config.deinit();
bun.default_allocator.destroy(new_config);
existing.ref();
return existing;
// Registry holds a weak ref on existing, so its allocation is live.
// If strong > 0, CAS it up and we have a real ref.
if (existing.upgrade()) {
// new_config is sole-owned by us; bypass refcount and free directly.
new_config.deinit();
bun.default_allocator.destroy(new_config);
return existing;
}
// strong==0: existing is dying. Its deref() is blocked in remove()
// waiting for this mutex, so content is still intact (deinit hasn't
// run). Replace the slot and transfer the registry's weak ref from
// existing to new_config.
existing.weakDeref();
gop.key_ptr.* = new_config;
}

// New config - it's already inserted by getOrPut
// refcount is already 1 from initialization
// Registry takes a weak ref on the (new or replacement) entry.
new_config.weakRef();
return new_config;
}

/// Remove a config from the registry. Called when refcount reaches 0.
/// Called from deref() at strong 1->0, before deinit(). If intern() replaced
/// our slot while we blocked on the mutex, the pointer-identity check fails
/// and we skip (intern already dropped our weak ref).
fn remove(config: *SSLConfig) void {
mutex.lock();
defer mutex.unlock();
_ = configs.swapRemoveContext(config, .{});
const idx = configs.getIndexContext(config, .{}) orelse return;
if (configs.keys()[idx] != config) return;
configs.swapRemoveAt(idx);
config.weakDeref();
}
};

Expand Down
1 change: 1 addition & 0 deletions src/ptr.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub const ref_count = @import("./ptr/ref_count.zig");
pub const RefCount = ref_count.RefCount;
/// Deprecated; use `AtomicShared(*T)`.
pub const ThreadSafeRefCount = ref_count.ThreadSafeRefCount;
pub const ThreadSafeWeakableRefCount = ref_count.ThreadSafeWeakableRefCount;
/// Deprecated; use `Shared(*T)`.
pub const RefPtr = ref_count.RefPtr;

Expand Down
96 changes: 96 additions & 0 deletions src/ptr/ref_count.zig
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,102 @@ pub fn ThreadSafeRefCount(T: type, field_name: []const u8, destructor: fn (*T) v
};
}

/// Thread-safe split-count reference counting (Rust Arc/Weak pattern).
///
/// Strong and weak references are tracked separately:
/// strong 1->0 -> drop_contents() is called, then the collective weak ref is released
/// weak 1->0 -> free_memory() is called
///
/// weak_count == (weak holders) + (1 if strong_count > 0). The +1 is the
/// "collective" weak ref held on behalf of all strong refs; it keeps the
/// allocation alive during the strong 1->0 -> drop_contents() window so that
/// upgrade() is memory-safe as long as any weak ref is held.
///
/// `drop_contents` should destruct/deinit the object's fields but NOT free the
/// struct itself. `free_memory` should free the struct (e.g. allocator.destroy).
///
/// Avoid this over plain ThreadSafeRefCount unless you need weak references.
pub fn ThreadSafeWeakableRefCount(
T: type,
field_name: []const u8,
drop_contents: fn (*T) void,
free_memory: fn (*T) void,
options: Options,
) type {
return struct {
strong: std.atomic.Value(u32),
weak: std.atomic.Value(u32),

const debug_name = options.debug_name orelse bun.meta.typeBaseName(@typeName(T));
pub const scope = bun.Output.Scoped(debug_name, .hidden);

pub fn init() @This() {
return .{ .strong = .init(1), .weak = .init(1) };
}

pub fn ref(self: *T) void {
const rc = getRefCount(self);
const old = rc.strong.fetchAdd(1, .seq_cst);
if (comptime bun.Environment.enable_logs) {
scope.log("0x{x} ref {d} -> {d}", .{ @intFromPtr(self), old, old + 1 });
}
bun.debugAssert(old > 0);
}

pub fn deref(self: *T) void {
const rc = getRefCount(self);
const old = rc.strong.fetchSub(1, .seq_cst);
if (comptime bun.Environment.enable_logs) {
scope.log("0x{x} deref {d} -> {d}", .{ @intFromPtr(self), old, old - 1 });
}
bun.debugAssert(old > 0);
if (old == 1) {
drop_contents(self);
weakDeref(self);
}
}

pub fn weakRef(self: *T) void {
const rc = getRefCount(self);
const old = rc.weak.fetchAdd(1, .seq_cst);
bun.debugAssert(old > 0);
}

pub fn weakDeref(self: *T) void {
const rc = getRefCount(self);
const old = rc.weak.fetchSub(1, .seq_cst);
bun.debugAssert(old > 0);
if (old == 1) {
free_memory(self);
}
}

/// Attempt to acquire a strong ref from a weak ref. CAS-loops on the
/// strong count, only incrementing if currently > 0. Returns true on
/// success, false if strong is 0 (contents are or will be dropped).
///
/// Safe to call as long as ANY weak ref is held: weak > 0 guarantees the
/// struct allocation is live, even if strong is 0 and drop_contents is
/// running concurrently.
pub fn upgrade(self: *T) bool {
const rc = getRefCount(self);
var current = rc.strong.load(.seq_cst);
while (current > 0) {
current = rc.strong.cmpxchgWeak(current, current + 1, .seq_cst, .seq_cst) orelse return true;
}
return false;
}
Comment thread
cirospaciari marked this conversation as resolved.
Outdated

pub fn strongCount(rc: *const @This()) u32 {
return rc.strong.load(.seq_cst);
}

fn getRefCount(self: *T) *@This() {
return &@field(self, field_name);
}
};
}
Comment thread
robobun marked this conversation as resolved.
Outdated

/// A pointer to an object implementing `RefCount` or `ThreadSafeRefCount`
/// The benefit of this over `T*` is that instances of `RefPtr` are tracked.
///
Expand Down
102 changes: 102 additions & 0 deletions test/js/web/fetch/fetch-proxy-tls-intern-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Regression test: segfault at 0x0 in create_ssl_context_from_bun_options during
// proxy tunnel setup.
//
// Root cause: SSLConfig.GlobalRegistry is a weak dedup cache but did not hold a
// strong ref on its entries. When the last external holder deref'd a config
// (HTTP thread) while a new fetch() with identical tls options interned the same
// content (JS thread), intern() could return a pointer whose refcount had already
// hit 0. The returned pointer was then destroyed concurrently, and the proxy
// tunnel later dereferenced freed cert/key memory -> strlen(NULL) -> segfault.
//
// Fix: registry now holds a +1 ref on every entry, so intern() always sees a
// live object. Entries are evicted when the external refcount drops to zero via
// a 2->1 transition check under the registry mutex.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
//
// This test stresses the intern/deref race by firing overlapping waves of proxy
// requests with identical tls options. Each completing request derefs the
// config; each starting request interns an identical one.

import { expect, test } from "bun:test";
import { tls as tlsCert } from "harness";
import { once } from "node:events";
import net from "node:net";

async function createConnectProxy() {
const server = net.createServer(client => {
client.once("data", head => {
const text = head.toString("latin1");
const nl = text.indexOf("\r\n");
const [, hostPort] = text.slice(0, nl).split(" ");
const colon = hostPort.lastIndexOf(":");
const host = hostPort.slice(0, colon);
const port = Number(hostPort.slice(colon + 1));

const upstream = net.connect(port, host, () => {
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
// Forward any bytes that arrived after the CONNECT header in the same packet
const headerEnd = text.indexOf("\r\n\r\n");
const extra = head.subarray(headerEnd + 4);
if (extra.length > 0) upstream.write(extra);
client.pipe(upstream);
upstream.pipe(client);
});
upstream.on("error", () => client.destroy());
client.on("error", () => upstream.destroy());
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as net.AddressInfo;
return { server, url: `http://127.0.0.1:${port}` };
}

test("concurrent proxy fetches with identical tls options do not race SSLConfig intern/deref", async () => {
using backend = Bun.serve({
port: 0,
tls: tlsCert,
fetch() {
return new Response("ok");
},
});

const proxy = await createConnectProxy();
const target = `https://127.0.0.1:${backend.port}/`;

// The tls option object is rebuilt on every fetch call, so each call allocates
// a fresh SSLConfig and hits GlobalRegistry.intern(). Identical content means
// they all dedup to the same registry entry.
// keepalive:false forces each request to drop its ref immediately on
// completion instead of parking the socket in the keepalive pool (which
// would hold an extra ref and mask the race).
const makeRequest = () =>
fetch(target, {
proxy: proxy.url,
keepalive: false,
tls: {
ca: tlsCert.cert,
rejectUnauthorized: false,
},
}).then(r => r.text());

try {
// Prime the registry so subsequent waves hit the found_existing path.
expect(await makeRequest()).toBe("ok");

// Fire overlapping waves: start a new wave while the previous is still
// settling. This maximises the window where one request's deref (2->1,
// eviction attempt) races a new request's intern (find existing, ref).
const concurrency = 8;
const waves = 6;
let inFlight: Promise<string[]> = Promise.resolve([]);
for (let w = 0; w < waves; w++) {
const prev = inFlight;
inFlight = Promise.all(Array.from({ length: concurrency }, makeRequest));
const results = await prev;
for (const r of results) expect(r).toBe("ok");
}
const last = await inFlight;
for (const r of last) expect(r).toBe("ok");
} finally {
proxy.server.close();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});