Skip to content

Bun.connect: make end()/shutdown() a write-side half-close; fix timeout() docs - #35947

Closed
robobun wants to merge 5 commits into
mainfrom
farm/4aafad89/socket-end-shutdown-half-close
Closed

robobun wants to merge 5 commits into
mainfrom
farm/4aafad89/socket-end-shutdown-half-close

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

What

Socket.end() and Socket.shutdown() on Bun.connect/Bun.listen sockets did not match their documented contract:

Call Documented Actual (before) Actual (after)
end() SHUT_WR, stay readable close(2), nothing received SHUT_WR, stay readable
shutdown() SHUT_RDWR shutdown(SHUT_WR) shutdown(SHUT_WR)
shutdown(false) SHUT_RDWR shutdown(SHUT_WR) shutdown(SHUT_WR)
shutdown(true) SHUT_WR, stay readable shutdown(SHUT_RD), nothing received shutdown(SHUT_WR)

So an app that follows the docs for a request-then-read-reply protocol loses the peer's entire reply with end() or shutdown(true).

Cause

  • Socket.prototype.shutdown mapped arg.to_boolean() to shutdown_read() (SHUT_RD) vs shutdown() (SHUT_WR) (socket_body.rs).
  • Socket.prototype.end set END_AFTER_FLUSH, and internal_flush() turned that into mark_inactive() which calls close_and_detach(Normal) (close(2)), not a half-close.

Fix

  • shutdown() ignores its argument and always issues SHUT_WR. SHUT_RD has no safe user-facing use (the peer cannot observe it and uSockets fabricates a local end).
  • internal_flush() replaces mark_inactive() with socket.shutdown() for the end-after-flush arm. The socket stays readable; when the peer's FIN arrives uSockets' existing eof && is_shut_down branch in loop.c closes it and on_close runs the same teardown that used to run here.
  • node:net's _final already called shutdown() directly, so its half-close behavior is unchanged. The server onconnection drop path (blockList / maxConnections) switches from clientHandle.end() to clientHandle.close() since it wants an immediate close, matching Node's clientHandle.close().
  • bun.d.ts: rewrite the shutdown() and timeout() JSDoc to describe the actual behavior; delete the self-contradictory end(): void "Close the socket immediately" overload. timeout() is notify-only (the handler must close the socket itself) and rounds up to the 4-second LIBUS_TIMEOUT_GRANULARITY sweep.

Verification

Fail-before (released bun):

(fail) end() sends FIN and keeps the read side open      { received: 0,     readyState: -1, writeAfter: -1 }
(fail) shutdown(true) sends FIN and keeps the read side  { received: 0,     readyState:  1, writeAfter:  1 }
(pass) shutdown() sends FIN and keeps the read side open
(pass) shutdown(false) sends FIN and keeps the read side open

Pass-after (bun bd test):

(pass) end() sends FIN and keeps the read side open
(pass) shutdown() sends FIN and keeps the read side open
(pass) shutdown(false) sends FIN and keeps the read side open
(pass) shutdown(true) sends FIN and keeps the read side open

test/js/bun/net/, test/js/node/net/, test/js/node/tls/, test/js/node/http/, test/js/node/http2/ all match main (pre-existing local failures only). test-net-server-drop-connections.js, test-net-server-max-connections.js, test-net-server-blocklist.js, test-net-server-max-connections-close-makes-more-available.js pass. bun-types integration test passes.

Supersedes #35932 (docs-only approach).


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts

…ut() docs

Socket.end() previously issued close(2) once its buffer was flushed, and
Socket.shutdown(true) issued shutdown(SHUT_RD). Both discard the peer's
reply after a request/reply half-close, which is what the documented
contract (and Node's net.Socket.end/setTimeout) promise to keep readable.

end() and shutdown() (with any argument) now both flush and issue
shutdown(SHUT_WR). The socket stays readable until the peer closes, at
which point uSockets' existing eof-on-shut-down branch closes it and
on_close runs the teardown. node:net's _final already relied on the
no-arg shutdown() being SHUT_WR, so its behavior is unchanged; the
server drop path (blockList / maxConnections) switches to close() since
it wants an immediate close, not a half-close.

Also rewrite the timeout() JSDoc: it is notify-only (the handler must
close the socket itself) and rounds up to the 4-second sweep
granularity.
@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:42 PM PT - Jul 26th, 2026

❌ @robobun, your commit c3fe786 has 1 failures in Build #82542 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+570.5 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+532.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35947

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

bun-35947 --bun

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; diff is green.

Reproduced with test/js/bun/net/socket.test.ts -t "write-side half-close" (3 of 5 cells fail on released bun, 5 of 5 pass on this branch).

Review follow-ups: Socket[Symbol.dispose] now binds to close (79a9da2); shutdown()/timeout()/close() JSDoc aligned with the new end() semantics (c3fe786).

CI (build 82542): no socket/net/tls/http/http2 failures. The red is:

  • binary-size lane: comparing against stale canary #79916; unrelated PRs (e.g. build 82503) show the identical +528-576 KB deltas. This diff is ~0 net lines of compiled Rust.
  • terminal-platform-gaps, no-orphans, fs.watch, 20144, fetch-leak: all tagged flaky by the annotation tool (known on main), none touch sockets.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR updates Bun.Socket declarations and runtime behavior so end() and shutdown() half-close the write side while keeping reads open. Rejected server connections now close accepted handles directly, with tests covering all shutdown variants.

Socket half-close behavior

Layer / File(s) Summary
Socket API contract
packages/bun-types/bun.d.ts
Updates end() parameters and return type, and documents timeout and write-side shutdown semantics.
Runtime half-close implementation
src/runtime/socket/socket_body.rs, test/js/bun/net/socket.test.ts
Uses native shutdown after pending flushes and verifies end(), shutdown(), and both boolean forms preserve read access while rejecting later writes.
Rejected connection teardown
src/js/node/net.ts
Closes accepted handles for blocklisted connections and connections exceeding maxConnections.

Possibly related PRs

  • oven-sh/bun#35932: Updates overlapping Socket.end()/shutdown() half-close behavior and related tests.

Suggested reviewers: alii, cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title is concise and accurately summarizes the main behavior and docs changes in the PR.
Description check ✅ Passed The description covers the PR purpose and verification steps, though it uses different headings than the template.

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

@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 3394-3400: Extend the half-close call matrix used by the
concurrent tests around halfClose to include end("final") alongside the existing
end() and shutdown variants. Ensure this case uses the same assertion,
confirming the full PAYLOAD reply is received while writeAfter remains -1 and
readyState remains 1.
🪄 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: 5f2c29ca-8746-4e1b-be8c-655e3662e677

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 35770e9.

📒 Files selected for processing (4)
  • packages/bun-types/bun.d.ts
  • src/js/node/net.ts
  • src/runtime/socket/socket_body.rs
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/socket/socket_body.rs:2983-2991 — Socket[Symbol.dispose] is still wired to end (sockets.classes.ts:164-166), so with end() now a SHUT_WR half-close, using socket = await Bun.connect(...) no longer unconditionally releases the fd/poll_ref on scope exit — teardown is deferred until the peer sends FIN back, and a hung or allowHalfOpen peer leaves the socket alive with the event-loop ref held. This PR already made the exact same fix for the net.ts drop-path callers (clientHandle.end() → clientHandle.close(), "since it wants an immediate close"); @@dispose needs the same treatment — point it at close.

    Extended reasoning...

    What changed and what didn't

    src/runtime/socket/sockets.classes.ts:164-166 (untouched by this PR) binds Socket[Symbol.dispose] to the end host function:

    "@@dispose": {
      fn: "end",
      length: 0,
    },

    Before this PR, end() set END_AFTER_FLUSH, and the end-after-flush arm of internal_flush() called self.mark_inactive() → close_and_detach(Normal). That is a full close(2): it clears IS_ACTIVE, downgrades the Strong this_value, decrements active_connections, and — critically — poll_ref.unref(js_loop_ctx()) (socket_body.rs:1330). So using client = await Bun.connect(...) unconditionally released the fd and dropped the event-loop ref on scope exit, satisfying the Explicit Resource Management contract.

    This PR rewires that arm (socket_body.rs:2983-2991) to self.socket.get().shutdown() (SHUT_WR) instead of mark_inactive(). IS_ACTIVE stays set, poll_ref stays reffed, and teardown is deferred until the peer's FIN arrives and uSockets' eof && is_shut_down branch in loop.c closes the socket, at which point on_close → mark_inactive() finally runs.

    Why this is a regression for Symbol.dispose

    The half-close is correct for the explicit socket.end() request-then-read-reply use case the PR targets. But Symbol.dispose has a different contract: it is unconditional resource release on scope exit. After this change, using cleanup is contingent on peer cooperation:

    • A peer with allowHalfOpen: true that never answers the FIN leaves the client socket open indefinitely.
    • A hung/partitioned peer leaves the fd allocated and poll_ref held, so the process cannot exit.
    • test/js/bun/net/socket.test.ts alone has ~8+ tests written as using client = await Bun.connect(...) (e.g. lines 578, 1887, 2526, 2578, 3002, 3072, 3145, 3188) whose cleanup now depends on the server closing its side.

    Step-by-step proof

    1. using client = await Bun.connect({ hostname, port, socket: {...} }) — connects to a server with allowHalfOpen: true that never calls end().
    2. Scope exits → client[Symbol.dispose]() → end host fn (sockets.classes.ts:165) → end() at socket_body.rs:3107 sets END_AFTER_FLUSH and calls internal_flush().
    3. internal_flush() sees can_end_after_flush() true, clears the flag, calls self.socket.get().shutdown() — kernel sends FIN (SHUT_WR). Returns without calling mark_inactive().
    4. IS_ACTIVE is still set, poll_ref is still reffed, the Strong this_value is still upgraded.
    5. Server has allowHalfOpen: true and never sends FIN back → uSockets never sees eof on the client fd → the eof && is_shut_down branch in loop.c never fires → on_close never runs.
    6. The socket, its fd, and its event-loop ref survive the using block indefinitely. Before this PR, step 3 called mark_inactive() and the resource was gone regardless of what the peer did.

    Sibling-caller precedent already in this PR

    The PR description explicitly calls out the analogous fix in src/js/node/net.ts:

    The server onconnection drop path (blockList / maxConnections) switches from clientHandle.end() to clientHandle.close() since it wants an immediate close, matching Node's clientHandle.close().

    @@dispose has the identical requirement — immediate, unconditional release — and is another caller of the changed end() helper. Per REVIEW.md: "Fix the whole class in the same PR — grep for every sibling site sharing the pattern … every caller of a changed helper." Note also that the third @@dispose in the same file (line 358, the UDP socket) already maps to close.

    Fix

    One line in src/runtime/socket/sockets.classes.ts:

    "@@dispose": {
      fn: "close",   // was "end"
      length: 0,
    },

    This keeps the new half-close semantics for explicit socket.end() calls while restoring the unconditional-release guarantee for using.

Comment thread packages/bun-types/bun.d.ts Outdated
end() is now a write-side half-close, so dispose must call close() for
unconditional release on scope exit (same reasoning as the net.ts drop
path). Also fix the shutdown() JSDoc: after SHUT_WR the peer's FIN is
delivered as close, not end.

@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.

Beyond the two doc nits inline, the main correctness question — whether the new end() → shutdown(SHUT_WR) path leaks the fd/poll_ref when the peer's FIN arrived first on an allowHalfOpen socket (uSockets' own TODO at socket.c:706 flags this case) — was examined for both client and server sides and ruled out: the re-armed readable poll re-delivers EOF and loop.c:845 closes it.

Extended reasoning...

The two findings this run are docs-only nits on text this PR added or made stale; they're posted inline. The substantive runtime concern for this change is the peer-FIN-first ordering on half-open sockets, since internal_flush() no longer reaches close_and_detach() directly and instead relies on uSockets' eof && is_shut_down branch to run teardown. That path was traced for both client-initiated and server-accepted sockets and holds. Not approving because this is a semantic change to Socket.end() and @@dispose on a core networking primitive — worth a human look.

Comment thread packages/bun-types/bun.d.ts Outdated
Comment thread packages/bun-types/bun.d.ts
close() described itself as a wrapper around end()/shutdown(), which now
read as half-close-only. timeout() recommended end() to close on timeout
(now a half-close that waits on the peer) and overstated the sweep
granularity as a lower bound.

@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.

All three rounds of feedback are addressed and the diff looks clean, but I'm deferring: this changes the user-visible semantics of Socket.end() from an immediate close to a half-close whose fd/poll_ref/Strong stay live until the peer FINs, drops the shutdown(true) → SHUT_RD path entirely, and rebinds @@dispose. That's a Bun-native API contract change a maintainer should sign off on.

What was reviewed:

  • internal_flush end-after-flush arm and its other callers (drain, write, flush()) — the flag is cleared before shutdown(), so repeat calls are idempotent and the deleted flush() guard comment's scenario no longer applies.
  • node:net _final already used shutdown() directly, so Node-compat half-close is unchanged; the two onconnection drop sites correctly want close().
  • JSDoc for shutdown()/timeout()/close()/@@dispose now matches the runtime paths after c3fe786.
Extended reasoning...

Overview

The PR retargets Bun.connect/Bun.listen Socket.end() and Socket.shutdown() to be true write-side half-closes (SHUT_WR) instead of the previous close(2) / SHUT_RD behavior, updates bun.d.ts JSDoc for end/shutdown/timeout/close/@@dispose to match, rebinds [Symbol.dispose] from end to close, swaps two clientHandle.end() calls in node:net's onconnection drop path to close(), and adds a 5-cell half-close test matrix. Five files touched; the runtime change is ~10 lines in socket_body.rs.

Security risks

None identified. No untrusted-input parsing, no auth/TLS verification changes; the TLS path's shutdown() already meant SHUT_WR so its wire behavior is unchanged.

Level of scrutiny

High — this is a behavior change to a public Bun-native API. Previously end() reached mark_inactive() → close_and_detach(Normal), releasing the fd, unreffing the poll keepalive, and dropping the Strong this_value immediately. Now it issues SHUT_WR and defers all of that to on_close, which only fires when the peer's FIN arrives. On a hung or vanished peer that can be much later (or effectively never without TCP keepalive). The PR compensates for the two in-tree callers that wanted an immediate release (@@dispose, the net.ts drop path), and node:net _final was already on shutdown(), but third-party code that relied on end() fully closing will see a lifetime change. Dropping shutdown(true) → SHUT_RD and making halfClose a documented no-op is also an API-surface decision. These are reasonable calls that align with the docs and Node semantics, but they're the kind of contract change a maintainer should approve.

Other factors

All prior review threads (CodeRabbit's end(data) matrix, comment-cop's three long-comment flags, and my three JSDoc-accuracy nits) are resolved in 490f009/ae6d4fc/79a9da2/c3fe786, and the current diff reflects each fix. The bug-hunting pass on c3fe786 found nothing. The new test awaits real events (server end handler, client close), asserts the exact {received, writeAfter, readyState} triple, uses it.concurrent, and per robobun fails on released bun / passes on the branch. CI build 82515's failures are unrelated per robobun's triage. I checked the other internal_flush() call sites (on_writable/drain, post-write, JS flush()): clearing END_AFTER_FLUSH before shutdown() prevents a second SHUT_WR on a later drain, and on_close still runs mark_inactive() so the teardown ordering is preserved.

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Additional evidence for this change: the close(2) in end() also discards outbound bytes that write() already accepted. This is a separate consequence from the "peer's reply is lost" case in the table above, and it is silent.

Setup: a Bun.listen server writes 8 MiB through the documented write() -> short-write -> drain loop. The sum of the write() return values reaches 8388608, so every byte is in the kernel send queue. The app then calls socket.end(). The peer is a python3 stdlib reader draining at ~2 MB/s that sends one byte every 100 ms.

Plain TCP, bun 1.4.0-canary, Linux, 3/3:

end()      + chatty peer: client received 4515328 / 6105122 / 6513152 of 8388608, ConnectionResetError
end()      + quiet peer:  client received 8388608 of 8388608, clean EOF
shutdown() + chatty peer: client received 8388608 of 8388608, clean EOF
node:net on bun, end() + chatty peer: 8388608 of 8388608, clean EOF

TLS (Bun.listen({tls}), python3 ssl client), same rig:

end()      + chatty peer: client received 6062080 of 8388608, ConnectionResetError
end()      + quiet peer:  client received 8388608 of 8388608
shutdown() + chatty peer: client received 8388608 of 8388608

In every lossy run bun's close handler fired clean and synchronously inside end(), and no error handler fired. The application is told the write succeeded and the socket closed normally, so it never learns that 2 to 4 MB never arrived.

Why this happens: close(2) on a socket that has unread inbound data, or that receives a segment after the close, makes the kernel answer with RST and discard the orphan's send queue. A quiet peer never triggers it, which is why this does not show up in a simple request/response test.

This PR fixes it by construction: internal_flush() now issues SHUT_WR instead of close_and_detach(Normal), and shutdown() was already the control that delivers all 8 MiB in the same program. TLS is covered too, since socket.get().shutdown() routes to us_internal_ssl_shutdown (close_notify, fd stays open) rather than the raw close.

Repro, plain TCP:

import { spawn } from "node:child_process";
const TOTAL = 8 << 20, CH = new Uint8Array(65536).fill(97); let off = 0;
const pump = s => {
  while (off < TOTAL) {
    const want = Math.min(65536, TOTAL - off);
    const n = s.write(CH.subarray(0, want));
    if (n < 0) return;
    off += n;
    if (n < want) return;
  }
  if (!s.ended) { s.ended = 1; console.log("server: all", off, "accepted; end()"); s.end(); }
};
const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: {
  open: pump, drain: pump, data() {},
  close() { console.log("server: close (clean)"); },
  error(s, e) { console.log("server: error", String(e)); } } });
const py = `import socket,time
s=socket.create_connection(("127.0.0.1",${server.port})); n=0; last=time.time(); err=None; s.settimeout(0.05)
while True:
    try:
        d=s.recv(65536)
        if not d: break
        n+=len(d); time.sleep(len(d)/2e6)
    except socket.timeout: pass
    except OSError as e: err=repr(e); break
    if time.time()-last>0.1:
        try: s.send(b"p"); last=time.time()
        except OSError as e: err=repr(e); break
print("client: received", n, "of", ${TOTAL}, "err=", err)`;
spawn("python3", ["-c", py], { stdio: "inherit" }).on("exit", () => { server.stop(true); process.exit(0); });

I closed #35932 (the docs-only approach) in favour of this PR. One note on the shutdown(true) change: it removes the only way to reach SHUT_RD from JS. That looks right to me for the reason you give, and no test or src/js caller depends on it, but it is the one part of this diff that takes capability away rather than adding it, so it is worth a maintainer's explicit yes.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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.

2 participants