Skip to content

sql: make forced close() resolve while a pool connection is mid-handshake - #32097

Merged
alii merged 1 commit into
mainfrom
farm/80b76cdf/sql-close-mid-handshake
Jun 11, 2026
Merged

sql: make forced close() resolve while a pool connection is mid-handshake#32097
alii merged 1 commit into
mainfrom
farm/80b76cdf/sql-close-mid-handshake

Conversation

@robobun

@robobun robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #32095. A forced pool close (sql.close({ timeout: "0" })) never resolved when a pool connection had been accepted at the TCP level but the database handshake had not completed yet (a database that is still starting up). The pending queries were rejected with ERR_POSTGRES_CONNECTION_CLOSED / ERR_MYSQL_CONNECTION_CLOSED, but the promise returned by close() stayed pending forever. With a nonzero connectionTimeout the close blocked until the connect timer fired (up to 30s by default); with connectionTimeout: 0 it hung forever.

import { SQL } from "bun";
import net from "node:net";

// server that accepts and never answers
const first = Promise.withResolvers<void>();
const server = net.createServer(() => first.resolve());
await new Promise<void>(r => server.listen(0, "127.0.0.1", r));
const port = (server.address() as net.AddressInfo).port;

const sql = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1, connectionTimeout: 0 });
const query = sql`SELECT 1`.catch(e => console.log("query rejected:", e.code));
await first.promise;

await sql.close({ timeout: "0" }); // never resolves
console.log("closed"); // never reached

Root cause

The pool's #close() resolves its per-connection promise from the onFinish hook, which only fires from the JS onClose/onConnected callbacks. For a mid-handshake connection those callbacks never came:

  • uws does not dispatch on_close when an application closes a socket whose TCP connect has not resolved yet (POLL_TYPE_SEMI_SOCKET, see us_internal_socket_close_raw in packages/bun-usockets/src/socket.c), so closing an in-flight connect produces no event at all.
  • postgres: disconnect() gated on Status::Connected, so a close during Connecting/SentStartupMessage did not touch the socket. The connect kept going and even sent the startup message after close() returned.
  • mysql: do_close closed the socket unconditionally but got no event back for in-flight connects and never changed the status, so is_active() stayed true and kept the event loop referenced forever.
  • mysql (js): the pool only stored the native handle in #onConnected, so connection.connection?.close() was a no-op for every pending connection.

The fix

  • src/sql_jsc/postgres/PostgresSQLConnection.rs: close() now fails the connection directly when the status is Connecting/SentStartupMessage (outside VM shutdown). fail() consumes and fires the JS onclose callback, rejects pending queries, and closes whatever socket is live; since no socket event will do it, the poll ref is released explicitly. Shutdown paths and connected/terminal states keep going through disconnect() unchanged.
  • src/sql_jsc/mysql/JSMySQLConnection.rs: do_close does the same for Connecting/Handshaking/Authenticating/AuthenticationAwaitingPk; connected/terminal states keep the existing clean_queue_and_close path.
  • src/js/internal/sql/mysql.ts: #startConnection stores the native handle at creation (like the postgres adapter already did) so a forced close can reach it.
  • src/js/internal/sql/postgres.ts + mysql.ts: if the pool is force-closed in the microtask window before the native handle is assigned, close the handle as soon as it materializes so onFinish settles.

fail() is idempotent (early-returns once the status is Failed), so the established-socket case, where closing does dispatch on_close synchronously, does not double-fire. The valkey client already works around the same uws behavior (src/runtime/valkey_jsc/valkey.rs close()), so this brings the SQL drivers in line; no other NsHandler consumer is affected.

How did you verify your code works?

test/js/sql/sql-close-pending-connection.test.ts (no docker needed): for each driver, a TCP server accepts and never answers, and the test asserts close({ timeout: "0" }) resolves, both while a connection is mid-handshake and when close is called before the native handle is stored. All four tests hang (5s timeout) on the unfixed build and pass with the fix.

Also verified:

  • the issue's repro prints closed and the process exits on its own (no lingering poll ref), for both drivers
  • with connectionTimeout: 2, forced close resolves in ~40ms instead of ~2s
  • normal lifecycle against real postgres and mariadb servers (query, reserve, transaction, graceful close, forced close while connected, forced close with a query in flight, onconnect/onclose counts) is unchanged
  • process exit while a connection is mid-handshake stays clean under the ASAN debug build
  • test/js/sql/sql-connect-error-reporting.test.ts and test/js/sql/sql-onconnect-onclose-throw.test.ts still pass

…hake

A forced pool close (close({ timeout: "0" })) never resolved when a
connection had been accepted at the TCP level but the database handshake
had not completed yet. uws skips the on_close dispatch for sockets whose
connect never finished (POLL_TYPE_SEMI_SOCKET), so the socket-close ->
on_close -> fail chain that normally fires the JS onclose callback never
ran, and the onFinish promise in the pool's #close() stayed pending
forever.

- postgres: disconnect() only tore down Status::Connected sockets, so a
  close during Connecting/SentStartupMessage left the in-flight socket
  alive (it even sent the startup message after close). close() now fails
  the connection directly for those states, which consumes and fires the
  JS onclose callback, rejects pending queries, closes whatever socket is
  live, and releases the poll ref that no socket event will release.
- mysql: do_close closed the socket but got no event back for in-flight
  connects and left the status Connecting, keeping the event loop
  referenced forever. It now fails the connection directly for the
  pre-connected states.
- mysql (js): the pool never stored the native handle until onConnected,
  so close() on a pending connection was a no-op; store it at creation
  like the postgres adapter does.
- both (js): if the pool is force-closed before the native handle is
  assigned (createConnection resolves one microtask later), close the
  handle as soon as it materializes so onFinish settles.

The same uws quirk was already worked around in the valkey client
(src/runtime/valkey_jsc/valkey.rs close()).

Fixes #32095
@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:56 AM PT - Jun 11th, 2026

@robobun, your commit 8fc74b1 has 5 failures in Build #61873 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32097

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

bun-32097 --bun

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes a bug where SQL.close() with zero timeout hangs indefinitely when a pooled connection is mid-handshake. Native connection close paths now fail in-flight connections immediately, and JS pooled wrappers explicitly close newly created connections if the pool was force-closed during creation. Comprehensive tests cover both Postgres and MySQL.

Changes

SQL pool forced close during handshake

Layer / File(s) Summary
Native connection close handling for in-flight states
src/sql_jsc/mysql/JSMySQLConnection.rs, src/sql_jsc/postgres/PostgresSQLConnection.rs
MySQL and Postgres close() methods now match on connection status and immediately fail connections (triggering onclose callbacks) if they are in Connecting, Handshaking, or Authenticating states, rather than waiting for socket close events that may never arrive.
JS pooled connection close handling
src/js/internal/sql/mysql.ts, src/js/internal/sql/postgres.ts
#startConnection methods now detect whether the pool was force-closed (onFinish !== null) after the native handle is created and explicitly close that handle to ensure onFinish callbacks settle promptly.
Forced close mid-handshake test coverage
test/js/sql/sql-close-pending-connection.test.ts
New parameterized test suite for Postgres and MySQL verifies SQL.close({ timeout: "0" }) resolves immediately during mid-handshake by simulating a TCP server that accepts but never completes the handshake, and validates driver-specific connection-closed error codes and proper resource cleanup.

Possibly related issues

  • oven-sh/bun#32038: Addresses the same bug scenario—ensuring a forced/zero-timeout pool close tears down in-flight connections and settles onClose/onFinish callbacks.

Possibly related PRs

  • oven-sh/bun#32027: Modifies handshake/connecting-time close and error handling in Postgres and MySQL connection implementations so in-flight failure scenarios map to correct error codes and callback settlement.
  • oven-sh/bun#32028: Overlaps with pooled connection retry and shutdown-cancellation logic around intermediate connection states in the same pool/handshake lifecycle code.

Suggested reviewers

  • alii
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: enabling forced close() to resolve when a pool connection is mid-handshake.
Description check ✅ Passed The description fully covers both required template sections: clearly explains what the PR does and provides comprehensive verification steps.
Linked Issues check ✅ Passed All code changes directly address the root causes identified in issue #32095: Postgres/MySQL connection close now properly fails mid-handshake states, native handles are stored earlier, and onFinish settles immediately [#32095].
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the mid-handshake close hang: Postgres/MySQL driver connection close logic, JS pool connection initialization, and a targeted test case.

✏️ 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.

@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 `@src/sql_jsc/mysql/JSMySQLConnection.rs`:
- Around line 709-711: The match arm that groups S::Connecting | S::Handshaking
| S::Authenticating | S::AuthenticationAwaitingPk should be split so only
S::Connecting calls fail() directly; for the post-on_open states (Handshaking,
Authenticating, AuthenticationAwaitingPk) explicitly drop the socket ref taken
by on_open before invoking fail() — e.g., invoke the same DerefOnDrop release
logic used in on_close (or call the code path that releases this.ref_()) so the
refcount goes down prior to clean_queue_and_close/ fail_with_js_value; update
the match to call fail() after releasing the ref for those three states to avoid
the semi-socket refcount leak.
🪄 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: 60c2d765-7ea0-4e0e-90c3-8e8c6f32d694

📥 Commits

Reviewing files that changed from the base of the PR and between f8723b1 and 8fc74b1.

📒 Files selected for processing (5)
  • src/js/internal/sql/mysql.ts
  • src/js/internal/sql/postgres.ts
  • src/sql_jsc/mysql/JSMySQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/sql/sql-close-pending-connection.test.ts

Comment thread src/sql_jsc/mysql/JSMySQLConnection.rs

@cirospaciari cirospaciari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

good catch

@alii
alii merged commit 5c02cc1 into main Jun 11, 2026
79 checks passed
@alii
alii deleted the farm/80b76cdf/sql-close-mid-handshake branch June 11, 2026 22:13
robobun added a commit that referenced this pull request Jun 12, 2026
The re-integration commit hosts the #32041 try/finally and the #32097
forced-close settlement in one BasePooledConnection close handler, so pin
their interaction per driver: onclose throws while the pool is force-closed
mid-handshake, and close() must still resolve and the pending query must
still reject.
alii added a commit that referenced this pull request Jun 12, 2026
…n in shared.ts (#32145)

## What this does

Final slice from #31994 (closed as too big), on top of #32128, #32135,
#32141. Consolidates the JS adapters' duplicated pool/connection/query
plumbing from `src/js/internal/sql/{postgres,mysql,sqlite}.ts` into
`src/js/internal/sql/shared.ts` as a `BasePooledConnection` class
hierarchy. Net −995 lines across 4 files.

Two commits:

- **Cherry-pick from #31994** (1aa3dbb) — checks out
`src/js/internal/sql/` from `origin/claude/split/sql` as-is. This
intentionally clobbers two pool-lifecycle fixes that landed on `main`
since #31994's merge-base: #32041 (throwing onconnect/onclose corrupting
the pool) and #32097 (forced `close()` resolving mid-handshake).
- **Re-integrate #32041 + #32097 into the new structure** (6e6fd4f) —
re-hosts both fixes in `BasePooledConnection`:
- #32041: try/finally around the user `onconnect`/`onclose` calls in
`handleConnected` and `#finishClose` so a throwing callback never skips
pool bookkeeping; the `createPooledConnectionHandle` catch always defers
via `process.nextTick` (drops the per-driver `deferSyncCloseError` flag
— both drivers now match).
- #32097: `startConnection()` is `abstract Promise<void>` and both
subclasses await + assign `this.connection` at creation;
`#beginConnecting` awaits it and closes the handle if `onFinish` was set
(pool force-closed) in the microtask window before it materialized.

## Behavioral equivalence

The original `BasePooledConnection` extraction in #31994 carried a
per-file behavioral-equivalence audit against its merge-base
(placeholders, escaping, helper commands, error message text,
pool/transaction semantics all preserved) and was green on full CI build
61383. This PR re-applies that diff and adds the two missing fixes; the
load-bearing verification is below.

## Verification

- `bun bd`: builds clean.
- The three lifecycle test files (`sql-onconnect-onclose-throw.test.ts`,
`sql-close-pending-connection.test.ts`,
`sql-connect-error-reporting.test.ts`): 26 pass / 0 fail.
- **New tests in this PR**: `sql-onconnect-onclose-throw.test.ts` gains
a forced-close variant per driver (a throwing `onclose` while the pool
is force-closed mid-handshake, against a fake `net` server). The two
re-hosted fixes meet in `BasePooledConnection`'s close handler, and that
interaction had no coverage. Note these pass against `origin/main`'s
`src/js/internal/sql` too (verified): both underlying fixes already live
on main per-driver, so versus main this PR is equivalence-preserving by
design and no test can fail on one side only. The HEAD~1 stash test
below is the proof that the re-integration commit is load-bearing.
- **Load-bearing stash test**: built and ran the same three files at
HEAD~1 (the cherry-pick before re-integration) — 6 fail (`mysql: forced
close() resolves when called before the native handle is stored`
timeout; `postgres: pool calls from onclose are safe when connecting
fails synchronously` → `reentry threw: TypeError`; both drivers'
`throwing onclose still rejects pending queries on connect refused`
timeouts). With the re-integration: 26 pass. The diff is required for
both fixes.
- Full `test/js/sql/` against live
`postgres_plain`/`mysql_plain`/`*_tls` containers: 1526 pass / 7 fail /
6 errors locally. Every failure reproduces on `origin/main` in the same
environment — see Notes below.

## Notes on local-only test failures (none introduced by this PR)

- `sql-mysql-query-string-leak.test.ts` fails locally on a debug+ASAN
build for both `origin/main` (314.6 MiB) and this branch (317.2 MiB),
within noise of each other; both exceed the 256 MiB ASAN threshold. A
`heapStats()` probe of the same workload shows `MySQLQuery 2→1` after GC
— wrappers are finalized correctly. The test passes on `main`'s CI
(release build); the threshold is tuned for release RSS overhead, not
local debug.
- `sql.test.ts > query string memory leak test` (postgres): same
category.
- `sqlite-sql.test.ts > Query Normalization Fuzzing Tests > handles
exotic but valid SQL patterns`: 5.5s timeout on debug builds —
pre-existing per #31994's original verification notes.
- `describeWithContainer` cold-start races: `docker compose up -d
--wait` returns non-zero on an already-healthy container
(`test/docker/index.ts:157`), causing beforeEach hook timeouts in
`sql-mysql.test.ts` (TLS), `sql-mysql-bind-oob`, `sql-mysql.helpers`,
`sql-mysql.auth`. Pre-existing harness flake; clears on warm rerun.

The Rust-side changes from #32097 (`src/sql_jsc/`) were already on
`main` before this PR — only the JS hooks needed re-integrating.
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.

Bun.SQL: forced close() never resolves while a pool connection is mid-handshake

3 participants