Skip to content

net: throw on an invalid socket handler config instead of aborting#31817

Open
alii wants to merge 6 commits into
mainfrom
ali/socket-handler-validate-before-protect
Open

net: throw on an invalid socket handler config instead of aborting#31817
alii wants to merge 6 commits into
mainfrom
ali/socket-handler-validate-before-protect

Conversation

@alii

@alii alii commented Jun 4, 2026

Copy link
Copy Markdown
Member

Bun.listen() / Bun.connect() with an invalid socket-handler config (a non-function handler, e.g. socket: { data: 123 }) aborted the debug process instead of throwing: validation returned an error after the Handlers was constructed, so its Drop ran unprotect() on a Handlers that protect() had never rooted, tripping debug_assert!(protection_count > 0). (In release the assert is gone, but the same path still unprotects callbacks that were never protected — an unbalanced GC unprotect.)

Validate every handler before constructing the Handlers, so an invalid config returns ERR_INVALID_ARG_TYPE while there is nothing to drop or unprotect ("validate early, allocate late"). Valid configs are unchanged.

Verified on a debug build: a bad handler now throws a catchable ERR_INVALID_ARG_TYPE (no abort), a valid listener still opens/accepts. Adds a regression test that runs in a subprocess, so the pre-fix abort surfaces as a non-zero exit (the test fails on pre-fix main, passes after).

@alii

alii commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Updated 8:40 PM PT - Jun 16th, 2026

@robobun, your commit fca1fae has 2 failures in Build #63052 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31817

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

bun-31817 --bun

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Socket handler initialization now validates callback fields before constructing Handlers, requiring at least one of on_data or on_writable to be callable. Assignment only copies callable callbacks. A regression test ensures invalid handlers throw ERR_INVALID_ARG_TYPE and do not abort the process.

Changes

Socket handler validation

Layer / File(s) Summary
Handler callback validation refactor
src/runtime/socket/Handlers.rs
Handlers::from_generated adds validate_callback! to ensure provided callback fields are functions and that at least one of on_data or on_writable is callable before constructing Handlers. Replaces prior per-field validation with assign_callback! that assigns only callable callbacks.
Invalid handler error test
test/js/bun/net/socket.test.ts
Regression test spawns a subprocess running Bun.listen with invalid socket handler shapes, captures thrown errors, and asserts both errors have code ERR_INVALID_ARG_TYPE and the subprocess exits with success.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preventing aborts by throwing on invalid socket handler config instead.
Description check ✅ Passed The description covers both required sections: it explains what the PR does (validates handlers before constructing Handlers) and how verification was done (debug build testing and regression test).
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.

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

🤖 Adopted and verified on a debug build:

  • Pre-fix: Bun.listen({ socket: { data: 123 } }) / Bun.connect({ socket: {} }) abort — panic: assertion failed: self.protection_count > 0 from Handlers::Dropunprotect(). Reproduced on current main.
  • Post-fix: both paths throw catchable ERR_INVALID_ARG_TYPE with the same messages as before; valid configs unchanged.
  • Extended the regression test to cover both validation error paths; fails pre-fix / passes post-fix on the debug build. Closed fix(socket): don't unprotect never-protected handlers on socket config validation errors #31360 as superseded.

Merge conflict with #31155 resolved in fca1fae: folded the four new handler callbacks (session, keylog, serverName, alpnCallback) into the validate-before-construct scheme. Verified on a debug build that all four throw cleanly on invalid values and pass through on valid ones, and the regression test still fails pre-fix / passes post-fix.

CI on fca1fae (build 63052): 284 pass, 2 fail — neither touches this PR:

  • test/js/web/websocket/autobahn.test.ts on darwin 14 aarch64: docker-compose cannot start the Autobahn container — "The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)"exec format error. test/docker/ infrastructure from the main merge, not socket code.
  • test/js/bun/util/v8-heap-snapshot.test.ts SIGKILL on ubuntu 25.04 x64: OOM-killer on a memory-heavy test (single lane).

socket.test.ts passes on every lane. Safe to merge past both.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(socket): don't unprotect never-protected handlers on socket config validation errors #31360 - Both fix the same crash where Handlers::Drop calls unprotect() on never-protected handlers during socket config validation, using the same "validate before constructing Handlers" approach in Handlers.rs

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — validation is hoisted before construction so the error path no longer drops a never-protected Handlers; behavior on valid configs is unchanged and there's a subprocess regression test.

Extended reasoning...

Overview

This PR fixes a debug-build abort in Handlers::from_generated (src/runtime/socket/Handlers.rs). Previously, the Handlers struct was constructed first and then callbacks were validated via assign_callback!; an invalid (non-callable, non-null) handler returned Err after the struct existed, so Dropunprotect() ran with protection_count == 0 and tripped a debug_assert!. The fix hoists all validation (per-callback type check + the "at least data or drain" requirement) above the struct literal, then assigns callable handlers afterwards. A subprocess regression test is added to test/js/bun/net/socket.test.ts.

The new "at least data or drain" check (!generated.on_data.is_callable() && !generated.on_writable.is_callable()) is equivalent to the old result.on_data.is_empty() && result.on_writable.is_empty() because the old code only assigned a field when is_callable() held. is_callable() is a pure type query, so calling it twice (once in validate, once in assign) has no side effects.

Security risks

None. This is input-validation ordering for user-supplied socket handler callbacks; no auth, crypto, filesystem, or network surface changes. The error messages and accepted inputs are byte-identical to before.

Level of scrutiny

Low–medium. ~30 lines moved within a single function with no semantic change on the happy path, plus a 25-line test. The transformation is mechanical (validate-then-construct instead of construct-then-validate) and the PR description explains the failure mode precisely. No CODEOWNERS coverage for these paths.

Other factors

The one inline finding is explicitly a pre-existing, cosmetic nit (internal handler names like onWritable in error strings instead of user-facing drain) carried verbatim from the old macro — non-blocking and orthogonal to the abort fix. CI build is in progress; no outstanding human reviewer comments.

Comment thread src/runtime/socket/Handlers.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean validate-before-construct reordering; my earlier nit was non-blocking and the rationale for keeping error text byte-identical is sound.

Extended reasoning...

Overview

This PR fixes a debug-build abort (and release-build unbalanced GC unprotect) when Bun.listen()/Bun.connect() is given an invalid socket handler config. The fix is a pure reordering inside Handlers::from_generated in src/runtime/socket/Handlers.rs: the per-callback type validation and the "at least data or drain" check are hoisted to run before the Handlers struct is constructed, so an early-return error never leaves a half-built Handlers for Dropunprotect() to trip over. The assign_callback! macro is correspondingly simplified to assign-if-callable. A subprocess regression test in test/js/bun/net/socket.test.ts covers both error paths.

Security risks

None. This is input-validation ordering on a config object; no auth, crypto, permissions, or untrusted-data parsing is involved. If anything, it removes an unbalanced unprotect() on the release path, which is strictly safer.

Level of scrutiny

Moderate — it touches the JSValue protect/unprotect lifecycle in core socket setup, but the change is mechanical: identical validation predicates (is_undefined_or_null, is_callable) and identical error strings, just evaluated earlier. The hoisted "at least one of data/drain" check (!generated.on_data.is_callable() && !generated.on_writable.is_callable()) is equivalent to the old post-assign is_empty() check given the per-field validation above has already passed. Valid-config behavior is unchanged, which robobun verified on a debug build along with confirming the regression test fails pre-fix and passes post-fix.

Other factors

  • No CODEOWNERS cover the modified paths.
  • The bug-hunting system found no issues.
  • My only prior comment was a cosmetic nit about internal vs. user-facing handler names in error messages; the author declined with a reasonable rationale (keep byte-identical to released/Zig behavior, out of scope for a crash fix) and the thread is resolved.
  • A possible duplicate (#31360) was flagged by automation, but that is a merge-coordination question, not a correctness concern for this diff.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean validate-before-construct reorder; success path is unchanged and the error path now throws instead of tripping the Drop assert.

Extended reasoning...

Overview

This PR touches one function, Handlers::from_generated in src/runtime/socket/Handlers.rs, plus a subprocess regression test in test/js/bun/net/socket.test.ts. The change hoists handler-callback validation (each field is undefined/null or callable; at least one of data/drain is present) to before the Handlers struct is constructed, so an invalid config returns ERR_INVALID_ARG_TYPE while there is nothing to drop. Previously the error was returned after construction, so Dropunprotect() ran on a never-protect()ed Handlers, hitting debug_assert!(protection_count > 0) (debug abort) and unbalancing GC protect/unprotect in release.

Behavioral equivalence

For valid configs the success path is byte-identical: the same fields are assigned (is_callable() on a value already known to be undefined/null-or-callable is equivalent to the old !is_undefined_or_null() && is_callable() branch), then with_async_context_if_needed() and protect() run as before. For invalid configs the same error messages are produced (the literals were carried over verbatim — my earlier nit about internal vs user-facing names was reasonably declined as out of scope, and that thread is resolved). The "at least data or drain" check on !generated.on_*.is_callable() is equivalent to the old result.on_*.is_empty() since validation has already excluded non-callable non-nullish values by that point.

Security risks

None. This is input-validation reordering on a user-facing config object; no auth, crypto, permissions, or trust-boundary changes. The net effect is replacing a process abort with the intended catchable TypeError.

Level of scrutiny

Low–medium. The change is mechanical ("validate early, allocate late"), confined to a single constructor, and covered by a subprocess regression test that fails pre-fix and passes post-fix on a debug build (verified in the adoption comment). No CODEOWNERS entries match these paths. CI failures on this build are unrelated infrastructure issues (musl LTO data-layout link error, macOS x64 build script), not caused by this diff.

Other factors

The bug-hunting system found nothing. The only prior review feedback (mine) was a pre-existing cosmetic nit, now resolved. The extended test (99e7ea3) covers both error paths. This supersedes #31360 with the same approach plus broader test coverage.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/net/socket.test.ts`:
- Around line 1589-1594: The test currently destructures proc.stdout,
proc.stderr, and proc.exited into stdout, stderr, and exitCode but never asserts
stderr; add an explicit assertion to ensure no abort output by asserting stderr
is empty (e.g., expect(stderr).toBe("") ) before asserting exitCode, updating
the test around the proc destructuring and the expectations for stdout/exitCode
(variables: proc, stdout, stderr, exitCode) to make the no-abort contract
explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90a9e0d1-2b59-44bd-ac93-765a812dd8de

📥 Commits

Reviewing files that changed from the base of the PR and between ec0f11c and 110efe0.

📒 Files selected for processing (1)
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — validation moved before construction, error paths byte-identical, valid path unchanged; my earlier nit was non-blocking and the keep-as-is rationale is sound.

Extended reasoning...

Overview

This PR touches one function, Handlers::from_generated in src/runtime/socket/Handlers.rs, plus a regression test in test/js/bun/net/socket.test.ts. The change hoists the per-callback type checks and the "at least data or drain" check to before the Handlers struct is constructed, so an invalid config returns Err while there is nothing to Drop. Previously the error returned after construction, so Dropunprotect() ran on a Handlers that protect() had never rooted, tripping debug_assert!(protection_count > 0) (debug abort) and doing an unbalanced GC unprotect in release.

I verified the new logic is equivalent to the old on every path: the validation predicate (!is_undefined_or_null() && !is_callable()) is unchanged; the "at least one of" check now tests !generated.on_*.is_callable() instead of result.on_*.is_empty(), which is equivalent because after validation a non-undefined/null value is callable; and assign_callback! now gates on is_callable(), which is equivalent to the old !is_undefined_or_null() once validation has passed. Error message text is byte-identical to before. The valid-config path (with_async_context_if_neededprotect()Ok) is untouched.

Security risks

None. This is input validation ordering for a user-facing config object; no auth, crypto, permissions, or untrusted-data parsing is involved. The change strictly removes an unbalanced GC unprotect on the error path.

Level of scrutiny

Medium — it sits in GC-adjacent socket runtime code, but the change is a pure reordering that makes the error path strictly safer (nothing constructed → nothing to clean up). The diff is ~30 net lines in a single function, mechanical, and well-commented. A subprocess regression test covers both error paths and would catch a re-introduction of the abort.

Other factors

  • Bug-hunting system found nothing.
  • My prior inline comment was an explicitly non-blocking, pre-existing nit about internal vs. user-facing names in the error strings; robobun's reply (keep byte-identical with the Zig reference, rename is a separate user-visible change) is reasonable and the thread is resolved.
  • CI red is test/cli/install/bunx.test.ts failing simultaneously on all 15 lanes (network-dependent, reproduced on unpatched bun) and a Windows baseline-verify setup failure — neither touches the Bun.listen/Bun.connect handler-validation path; socket.test.ts passes everywhere.
  • No CODEOWNERS entry covers src/runtime/socket/Handlers.rs.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean validate-before-construct reordering with a regression test; error text unchanged.

Extended reasoning...

Overview

This PR reorders Handlers::from_generated in src/runtime/socket/Handlers.rs so that all socket-handler callbacks are validated before the Handlers struct is constructed. Previously, validation errors returned after the struct existed, so Drop ran unprotect() on a Handlers that protect() had never rooted — tripping debug_assert!(protection_count > 0) (debug abort) and doing an unbalanced GC unprotect in release. The fix splits the old assign_callback! macro into a pre-construction validate_callback! pass and a post-construction assign_callback! pass, and moves the "at least data or drain" check up as well. A subprocess regression test is added to test/js/bun/net/socket.test.ts.

Security risks

None. This is input-validation reordering inside the Bun.listen/Bun.connect config path. No new surface is exposed; the same inputs that threw before still throw with byte-identical messages, and valid configs are unchanged. No auth, crypto, filesystem, or network-trust logic is touched.

Level of scrutiny

Low–medium. The diff is small and mechanical: the validation predicates (is_undefined_or_null, is_callable) and error strings are carried over verbatim from the removed assign_callback! body, and the "at least data/drain" check is the equivalent !is_callable() && !is_callable() form of the old is_empty() && is_empty() check (equivalent because, after validation, a field is either undefined/null or callable). Once result is constructed there is no remaining fallible path before protect() + Ok(result), which is exactly the invariant the comment describes.

Other factors

  • Bug hunter found nothing. My only prior comment was a non-blocking nit about user-facing vs internal handler names in the error text, which was deliberately kept as-is to stay byte-identical with the Zig reference; that thread is resolved.
  • CodeRabbit's stderr-assertion suggestion was declined per the established void stderr convention in this file; resolved.
  • robobun verified on a debug build that both invalid-config paths now throw catchable ERR_INVALID_ARG_TYPE (pre-fix: abort), and that the new test fails pre-fix / passes post-fix. The two red CI groups were analysed as main-wide / network-cert flake unrelated to this change.
  • No CODEOWNERS match for the touched paths.

Resolved conflict in src/runtime/socket/Handlers.rs: #31155 added four new
handler callbacks (session, keylog, serverName, alpnCallback). Folded them
into the validate-before-construct scheme so they are type-checked before the
Handlers struct exists and assigned via the infallible single-arg macro,
keeping the error path abort-free.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean reorder of validation before construction; happy path is byte-identical and the error path no longer drops a never-protected Handlers.

Extended reasoning...

Overview

This PR touches one function, Handlers::from_generated in src/runtime/socket/Handlers.rs, plus a new subprocess regression test in test/js/bun/net/socket.test.ts. The change hoists all callback type validation (the per-field "must be a function" check and the "at least data or drain" check) to before the Handlers struct is constructed, so an invalid config returns Err while there is nothing to Drop. Previously the error returned after construction, so Drop → unprotect() ran on a struct that protect() had never rooted — tripping debug_assert!(protection_count > 0) in debug and doing an unbalanced GC unprotect in release.

Correctness

I verified the happy path is behaviorally identical: the new assign_callback! assigns when is_callable(), which is equivalent to the old "not undefined/null and callable" given validation already rejected non-null-non-callable values. The "at least one of" check moved from result.on_*.is_empty() to !generated.on_*.is_callable(), which is equivalent under the same precondition. After construction there are no remaining fallible paths before protect(), so a constructed Handlers is always protected before it can drop. The merge with #31155 correctly folded the four new TLS callbacks (session, keylog, serverName, alpnCallback) into both the validate and assign blocks.

Security risks

None. This is input-validation reordering with no change to what is accepted or rejected; error messages are byte-identical to released behavior.

Level of scrutiny

Moderate — it touches GC protect/unprotect bookkeeping, but the change strictly removes an unbalanced-unprotect path rather than adding new GC interaction. The diff is small (~70 lines in one function), mechanical, and well-commented.

Other factors

All inline review threads are resolved: my prior nit about internal vs. user-facing names in the error strings was deliberately deferred as out-of-scope (keeps the Rust port diffable against the Zig reference), and CodeRabbit's stderr-assertion suggestion was correctly declined per the file's established void stderr pattern for abort-regression subprocess tests. The regression test covers both validation error paths and would fail on pre-fix main. No bugs were found by the bug-hunting system.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants