Skip to content

socket: don't assert in Handlers::unprotect when never protected#30948

Open
robobun wants to merge 2 commits into
mainfrom
farm/3e41b1ed/socket-handlers-drop-unprotected
Open

socket: don't assert in Handlers::unprotect when never protected#30948
robobun wants to merge 2 commits into
mainfrom
farm/3e41b1ed/socket-handlers-drop-unprotected

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 17, 2026

Fuzzilli found a debug-assertion crash in Bun.listen/Bun.connect when the socket handlers object fails validation.

Repro

Bun.listen({ socket: {} });
panic: assertion failed: self.protection_count > 0 (src/runtime/socket/Handlers.rs:395:13)

Root cause

Handlers::from_generated returns early with an error when the socket handlers object has no data/drain callback or contains a non-callable handler. At that point protect() has not been called, so protection_count is still 0.

In the Zig original, the stack result is simply abandoned on the early error return. In Rust, Drop for Handlers runs → unprotect()debug_assert!(self.protection_count > 0) fails.

Release builds were unaffected: the assertion is #[cfg(debug_assertions)]-only, and JSValue::unprotect() on ZERO / unprotected cells is a no-op on the C++ side.

Fix

Skip the unprotect body when protection_count == 0 in debug builds. There is nothing to unprotect, and Drop can only run once so the double-unprotect case the assertion guarded against cannot arise.

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.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 17, 2026

Updated 7:15 PM PT - May 17th, 2026

@robobun, your commit 4d87604 has 1 failures in Build #55599 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30948

That installs a local version of the PR into your bun-30948 executable, so you can run:

bun-30948 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0dceaa3b-b9f6-454b-b47b-13868a6a5371

📥 Commits

Reviewing files that changed from the base of the PR and between c7a7579 and c9be901.

📒 Files selected for processing (2)
  • src/runtime/socket/Handlers.rs
  • test/js/bun/net/socket.test.ts

Walkthrough

This PR adds a defensive safeguard to socket handler unprotection and introduces validation tests for socket handler arguments. The runtime fix prevents calling unprotect when the protection counter is zero, while new test cases verify that Bun.listen and Bun.connect reject incomplete or malformed socket handler objects.

Changes

Socket Handler Safety and Validation

Layer / File(s) Summary
Protection count safeguard
src/runtime/socket/Handlers.rs
Handlers::unprotect replaces debug-only assertion with an early return when protection_count is zero, preventing unprotect operations in that state.
Socket handler argument validation tests
test/js/bun/net/socket.test.ts
New tests verify that Bun.listen and Bun.connect throw validation errors when socket handlers lack required fields (data or drain) or include non-function close handlers.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main fix: preventing an assertion error in Handlers::unprotect when protection_count is 0, which matches the primary change in the codebase.
Description check ✅ Passed The description provides clear problem statement, reproduction steps, root cause analysis, and the fix explanation, but it is missing the 'How did you verify your code works?' section from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun crashes when JackdClient becomes out of scope #21524 - Crash in Handlers.unprotect when a socket client (jackd/JackdClient) goes out of scope before the connection is fully established; stack trace shows the segfault at Bun__JSValue__unprotect called from Handlers.Scope.exit, exactly the code path this PR patches.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #21524

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — minimal debug-only fix for the Drop-before-protect assertion, with regression tests for both error paths.

Extended reasoning...

Overview

This PR fixes a debug-assertion panic in Handlers::unprotect() (src/runtime/socket/Handlers.rs) that fires when Bun.listen/Bun.connect is called with an invalid socket handlers object. The Rust port introduced a Drop impl that runs unprotect() even on the early-error returns in from_generated (before protect() is called), tripping debug_assert!(self.protection_count > 0). The fix replaces the assertion with an early return when protection_count == 0, and adds two tests covering both validation-error paths (no data/drain callback; non-callable handler).

Security risks

None. This is a debug-assertions-only control-flow change in an error path. Release builds are entirely unaffected (the modified block is gated by #[cfg(debug_assertions)]). No auth, crypto, untrusted input parsing, or memory-safety surface is touched.

Level of scrutiny

Low. The change is 4 lines in a #[cfg(debug_assertions)] block plus two straightforward expect().toThrow() tests. I verified that Handlers::unprotect() is private and only called from Drop for Handlers (Handlers.rs:443), so the PR's claim that the double-unprotect case the assertion guarded against cannot arise is correct — Drop runs at most once. When protection_count == 0, all callback fields are either JSValue::ZERO or never-protected values, so skipping the per-field unprotect() calls is correct (and release builds already rely on JSValue::unprotect() being a no-op for these, which this PR does not change).

Other factors

The PR description clearly explains the Zig→Rust porting divergence (stack value abandoned vs. Drop running). The new tests directly exercise the repro from the Fuzzilli finding. No CODEOWNERS apply to these paths, no outstanding reviewer comments, and the bug-hunting system found nothing.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — minimal debug-only fix with regression tests; the unrelated spawn-stdout-iterate-leak CI failure looks like flake.

Extended reasoning...

Overview

This PR touches two files: a 4-line change in src/runtime/socket/Handlers.rs replacing a debug_assert!(self.protection_count > 0) with an early return when the count is zero, and two new regression tests in test/js/bun/net/socket.test.ts that exercise the early-error paths in Handlers::from_generated (empty handlers object, non-callable handler).

The root cause is well-explained: when from_generated returns Err before calling protect(), Rust still runs Drop for Handlers on the partially-built result, which calls unprotect() with protection_count == 0 and trips the assertion. The Zig original simply abandoned the stack value with no destructor.

Security risks

None. The modified code is inside a #[cfg(debug_assertions)] block, so release builds are byte-for-byte unchanged. In debug builds the early return skips JSValue::unprotect() calls that were already no-ops (the values are either JSValue::ZERO or never-protected cells, both of which JSC's gcUnprotect handles as no-ops). No GC-rooting imbalance is introduced.

Level of scrutiny

Low. The change is mechanical and tightly scoped. unprotect() is a private method called only from Drop, which runs exactly once per instance, so the double-unprotect scenario the original assertion guarded against cannot occur — protection_count == 0 at Drop time can only mean protect() was never called. The early return is the correct behavior. There is a minor debug/release asymmetry (debug returns early, release falls through to no-op unprotect calls), but both are semantically equivalent.

Other factors

The new tests follow the established pattern in the same file (e.g., the adjacent "empty hostname" / "empty unix path" tests use the same synchronous expect(() => Bun.connect(...)).toThrow() form for validation errors). Error message strings match the source exactly. The single CI failure (spawn-stdout-iterate-leak.test.ts on x64-asan) is in an unrelated subsystem and not touched by this PR. No CODEOWNERS cover these paths, and no outstanding reviewer comments exist.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 18, 2026

CI status on build #55599 (re-roll): all 73 individual jobs passed, including debian-13-x64-asan-test-bun where the spawn-stdout-iterate-leak flake from build #55584 did not recur.

The aggregate buildkite/bun shows failing only because darwin-14-aarch64-test-bun never reported a status (agent expired/never picked up the job — same lane expired on #55584 too). No test failures.

This change is debug-assertions-only in socket Handlers::unprotect() plus two validation tests in socket.test.ts; it has no overlap with spawn/FileReader or any darwin-specific path. Ready for review/merge pending the infra lane.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant