From c9be90141a7dddfa7a4776f592a88abd4c2c5334 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 17 May 2026 21:50:16 +0000 Subject: [PATCH 1/2] socket: don't assert in Handlers::unprotect when never protected Handlers::from_generated returns early with an error when the socket handlers object has no data/drain callback or a non-callable handler. At that point protect() has not been called, so protection_count is 0. Unlike the Zig original where the stack value is simply abandoned on error, Rust runs Drop, which calls unprotect() and trips the debug_assert!(protection_count > 0). unprotect() on JSValue::ZERO (and on unprotected cells) is already a no-op on the C++ side, so release builds were unaffected. Skip the unprotect body when protection_count is 0 in debug builds instead of asserting. --- src/runtime/socket/Handlers.rs | 6 +++++- test/js/bun/net/socket.test.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 8867b87a3f6..ea00857750e 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -392,7 +392,11 @@ impl Handlers { #[cfg(debug_assertions)] { - debug_assert!(self.protection_count > 0); + // `Drop` runs even on the early-error returns in `from_generated` + // (before `protect()`); nothing to unprotect in that case. + if self.protection_count == 0 { + return; + } self.protection_count -= 1; } self.on_open.unprotect(); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 31836ab4f29..c2a1e338881 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -821,6 +821,27 @@ it("should throw on empty unix path from truthy non-string value", () => { expect(() => Bun.connect({ unix: [] as any, socket })).toThrow("SocketOptions.unix must be a string"); }); +it("should throw when socket handlers have no data or drain callback", () => { + // Handlers validation fails before protect() is called; Drop must not + // assert on an unprotected Handlers. + expect(() => Bun.listen({ hostname: "localhost", port: 0, socket: {} as any })).toThrow( + 'Expected at least "data" or "drain" callback', + ); + expect(() => Bun.connect({ hostname: "localhost", port: 0, socket: {} as any })).toThrow( + 'Expected at least "data" or "drain" callback', + ); +}); + +it("should throw when a socket handler is not a function", () => { + const socket = { open() {}, close: "not a function" } as any; + expect(() => Bun.listen({ hostname: "localhost", port: 0, socket })).toThrow( + 'Expected "onClose" callback to be a function', + ); + expect(() => Bun.connect({ hostname: "localhost", port: 0, socket })).toThrow( + 'Expected "onClose" callback to be a function', + ); +}); + it("reading .listener on a closed client socket does not use-after-free handlers", async () => { // Client-mode Handlers is heap-allocated per-connect and freed in // markInactive once the socket closes. `socket.listener` read From 4d87604dbc2eb4677428da179ee1b5b3d53bc55d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 17 May 2026 23:14:17 +0000 Subject: [PATCH 2/2] ci: retrigger