Skip to content

sql: drain queries awaited in the same tick as close()/end() - #33740

Open
robobun wants to merge 7 commits into
mainfrom
claude/farm/4485e07a/sql-close-drains-same-tick-queries
Open

sql: drain queries awaited in the same tick as close()/end()#33740
robobun wants to merge 7 commits into
mainfrom
claude/farm/4485e07a/sql-close-drains-same-tick-queries

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

A graceful sql.close() / sql.end() is documented to wait for pending queries, but a query created and awaited in the same synchronous block as the close() call is rejected with ERR_*_CONNECTION_CLOSED and never sent to the server, even with an explicit grace period (close({ timeout: 5 })).

await using sql = new SQL(...);
await sql\`SELECT 1\`;                         // pool is warm and idle
const q = sql\`SELECT ...\`;
await Promise.all([q.then(r => r), sql.end()]); // q rejects: Connection closed

The same program with q.execute() before end(), or with end() one macrotask later, drains correctly. The contract depends on an internal microtask race the caller cannot observe.

Cause

Query.then()/catch()/finally() route through #runAsync(), which did await Promise.$resolve() before calling the pool handler. So the pool enqueue landed one microtask after .then() returned. A same-tick pool.close() runs first, samples hasPendingQueries() (waiting queue empty, totalQueries 0), takes the close-immediately branch, and sets closed = true. The deferred enqueue then hits connect(): if (this.closed) return onConnected(connectionClosedError(), null).

Query.execute() calls #run(), which invokes the handler synchronously, so close() sees the query as pending. That is why lane D worked.

Fix

  • src/js/internal/sql/query.ts: drop the await Promise.$resolve() from #runAsync() so .then()/.catch()/.finally() hand the query to the pool synchronously, the same way .execute() does. Re-entry (from bindQuery's query.finally() and similar) is already guarded by the SQLQueryStatus.executed flag set immediately above.
  • src/js/internal/sql/shared.ts: BaseSQLAdapter.close() checked if (timeout), so the documented { timeout: 0 } force-close fell through to the graceful-wait branch; it only rejected same-tick queries because nothing was enqueued yet. With queries now enqueued synchronously that accidental cover is gone, so check timeout != null to reach the existing timeout === 0 fast-close path. This keeps the Connection destroyed with query before tests passing for the documented reason.

Tests

  • test/js/sql/sqlite-sql.test.ts: close() drains a query awaited in the same tick (SQLite, no container).
  • test/js/sql/sql.test.ts: Connection end does not cancel a query awaited in the same tick and a { timeout: 5 } variant, next to the existing .execute() case.
  • test/js/sql/sql-mysql.test.ts: matching MySQL case.
  • test/js/sql/sql-close-pending-connection.test.ts: close({ timeout: 0 }) force-closes with a query in flight, and close({ timeout: null }) still waits for it, for postgres and mysql against mock servers (the timeout: 0 cases hang without the shared.ts change). These are the cases from sql: make close({ timeout: 0 }) force-close immediately with queries in flight #32039, which made the same shared.ts change on its own and is closed in favour of this PR.

Fail-before verified with USE_SYSTEM_BUN=1 and with src/ stashed under the debug build:

SQLiteError: Connection closed
  code: "ERR_SQLITE_CONNECTION_CLOSED"

All existing non-container test/js/sql suites pass with the fix (the one pre-existing debug-ASAN timeout in properly finalizes prepared statements also reproduces on main).

Fixes #32038

Query.then()/catch()/finally() deferred the pool hand-off by one microtask
via `await Promise.$resolve()`, so a close()/end() in the same synchronous
block ran first, saw hasPendingQueries() == false, and rejected the
already-awaited query with ERR_*_CONNECTION_CLOSED without ever sending it.
q.execute() (which enqueues synchronously) did not have this problem.

Query.then() now hands the query to the pool synchronously, the same way
execute() does; the SQLQueryStatus.executed flag already guards re-entry
from the then()/finally() the pool itself calls during bindQuery.

This exposed a latent truthiness bug in BaseSQLAdapter.close(): the
documented `{ timeout: 0 }` force-close went through the graceful-wait
branch because `if (timeout)` is false for 0. It happened to reject
same-tick queries only because nothing was enqueued yet. With queries
now enqueued synchronously that no longer holds, so close() checks
`timeout != null` to reach the existing `timeout === 0` fast path.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c106f8d0-83a8-4b97-9b06-9698506f88f5

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 81872d3.

📒 Files selected for processing (6)
  • src/js/internal/sql/query.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-close-pending-connection.test.ts
  • test/js/sql/sql-mysql.test.ts
  • test/js/sql/sql.test.ts
  • test/js/sql/sqlite-sql.test.ts

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

@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:36 AM PT - Aug 13th, 2026

@robobun, your commit 81872d3a062479abf6cf529940f207100215555c passed in Build #94367! 🎉


🧪   To try this PR locally:

bunx bun-pr 33740

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

bun-33740 --bun

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.SQL: close({ timeout: 0 }) does not close immediately when queries are pending #32038 - This PR directly fixes the if (timeout) falsy-zero bug in BaseSQLAdapter.close() by changing it to if (timeout != null), making close({ timeout: 0 }) correctly take the force-close path

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

Fixes #32038

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. sql: make close({ timeout: 0 }) force-close immediately with queries in flight #32039 - Also fixes Bun.SQL: close({ timeout: 0 }) does not close immediately when queries are pending #32038 with the same if (timeout)if (timeout != null) change in BaseSQLAdapter.close(); sql: drain queries awaited in the same tick as close()/end() #33740 is a superset that additionally removes the microtask deferral in Query.#runAsync()

🤖 Generated with Claude Code

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

The shared.ts change here is identical to #32039 (both gate on timeout != null so { timeout: 0 } reaches the documented fast-close path). That change is required here: once .then() enqueues synchronously, the existing Connection destroyed with query before tests in sql.test.ts / sql-mysql.test.ts would otherwise start passing for the wrong reason (graceful drain instead of forced close).

#32039 has a dedicated mock-server test for the { timeout: 0 } behaviour that this PR does not duplicate; either can land first.

…ug builds

The 10000-iteration loop in "properly finalizes prepared statements"
takes ~11s on debug+ASAN builds and times out under the default 5s
budget (also on main). Use 1000 iterations under isDebug, matching the
pattern in test/js/web/html/FormData.test.ts and elsewhere.
Comment thread src/js/internal/sql/query.ts Outdated
The body was byte-identical after dropping the microtask yield.

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

Thanks for collapsing #runAsync() into a #run() delegate — that addresses my earlier nit. I'm deferring the final call to a human since removing the microtask deferral changes when the pool handler runs relative to .then() callers, which is subtle enough to warrant a second pair of eyes.

Extended reasoning...

Overview

Two source changes plus tests across three adapters:

  • src/js/internal/sql/query.ts: #runAsync() no longer does await Promise.$resolve() before invoking the pool handler; it now delegates directly to #run(), so .then()/.catch()/.finally() enqueue the query synchronously (matching .execute()).
  • src/js/internal/sql/shared.ts: close() now checks timeout != null instead of truthy timeout, so { timeout: 0 } reaches the documented force-close branch.
  • New tests in sql.test.ts, sql-mysql.test.ts, sqlite-sql.test.ts, plus an unrelated debug-build iteration shrink in sqlite-sql.test.ts.

My earlier inline nit (the #run()/#runAsync() duplication) was addressed in 062e3cf and the thread is resolved.

Security risks

None. No auth, crypto, or untrusted-input parsing is touched; the timeout != null change only affects which branch of an already-validated close path is taken.

Level of scrutiny

Medium. The line count is tiny, but dropping the microtask deferral is a timing/ordering change inside a lazy-Promise subclass's .then() path that fans out to every SQL adapter's pool handler. The original await Promise.$resolve() may have been incidental, or it may have been load-bearing for some ordering assumption (e.g., letting .then() return before the handler can synchronously re-enter or call back into user code). The PR description argues re-entry is guarded by the executed status flag and the existing test suite passes, which is reassuring, but this is exactly the kind of change where a maintainer familiar with the pool's history should confirm nothing else depended on the deferral.

Other factors

  • Overlaps with #32039 on the timeout != null fix; the author has already noted either can land first.
  • Tests are well-placed next to the existing .execute() cases and the SQLite one runs without a container.
  • No bugs surfaced by the automated review.

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

No issues found and the dedup nit is addressed, but removing the microtask deferral from Query.then() changes execution timing on the hot path for every SQL adapter — worth a human sanity-check on re-entrancy (e.g. bindQuery's query.finally() now recursing synchronously) before landing.

Extended reasoning...

Overview

Two source changes plus tests:

  • src/js/internal/sql/query.ts: #runAsync() now delegates directly to #run(), dropping the await Promise.$resolve() that previously deferred the pool hand-off by one microtask. This means .then()/.catch()/.finally() on a Query now invoke the pool handler synchronously before returning, matching .execute().
  • src/js/internal/sql/shared.ts: BaseSQLAdapter.close() gates on timeout != null instead of if (timeout), so the documented { timeout: 0 } force-close reaches the fast-close branch.
  • Tests added across SQLite / Postgres / MySQL for the same-tick-close case, plus a debug-build iteration reduction in sqlite-sql.test.ts.

My earlier inline nit (dedupe #runAsync/#run) was addressed in 062e3cf.

Security risks

None. No auth, crypto, or untrusted-input parsing is touched; the timeout != null change only affects control flow inside an already-validated numeric option.

Level of scrutiny

Moderate–high. The shared.ts one-liner is trivially correct. The query.ts change, however, alters observable timing on the single code path every awaited SQL query flows through, across all three adapters. Removing a deliberate-looking microtask hop from a Promise subclass's .then() is the kind of change where second-order effects (re-entrancy, ordering assumptions in the pool) are easy to miss:

  • bindQuery() calls query.finally(...), which now synchronously re-enters #runAsyncAndCatch()#run() while the outer handler(this, handle) call is still on the stack. The SQLQueryStatus.executed guard makes this a no-op, and the PR description calls this out — but it's exactly the sort of invariant a maintainer familiar with the pool should confirm.
  • Any caller that relied (intentionally or not) on the query being enqueued after the current microtask now sees it enqueued before.

Other factors

  • Overlaps with open PR #32039 on the timeout != null change; the author has explained the relationship in-thread.
  • The PR description is thorough, fail-before is verified, and tests cover the fix across every adapter — the diligence is high.
  • No CODEOWNERS entry covers these files.

Given the subtlety of the timing change on a shared hot path, deferring to a human reviewer rather than auto-approving.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the napi > napi_wrap > has the right lifetime failure on Windows 2019 x64 is hitting every concurrent PR build right now (70430, 70431, 70437, 70439, 70440, 70441, 70444, 70446, 70448, 70449), including branches that touch zlib, semver, url, sockets, and http, none of which share code with this diff. The previous build (70417) failed only on a darwin-14-aarch64-test-bun job that expired waiting for an agent.

This diff touches src/js/internal/sql/{query,shared}.ts and the corresponding test/js/sql/ files only. All test/js/sql/ lanes that have completed are green. Ready for review.

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

Thanks for addressing the dedup nit. I'm deferring to a human here since removing the microtask deferral changes dispatch timing for every await sql call across all three adapters — the reasoning looks sound, but this and the overlap with #32039 are worth a maintainer's eyes.

Extended reasoning...

Overview

Two source changes plus tests:

  • src/js/internal/sql/query.ts: #runAsync() no longer does await Promise.$resolve() before invoking the pool handler; it now delegates directly to #run(), so .then()/.catch()/.finally() enqueue the query synchronously (same as .execute()).
  • src/js/internal/sql/shared.ts: BaseSQLAdapter.close() gates on timeout != null instead of if (timeout), so { timeout: 0 } reaches the documented force-close branch.
  • New tests in sql.test.ts, sql-mysql.test.ts, sqlite-sql.test.ts, plus a debug-build iteration shrink in sqlite-sql.test.ts.

My earlier inline nit (dedup #runAsync with #run) was addressed in 062e3cf and the thread is resolved.

Security risks

None. No untrusted input parsing, auth, crypto, or filesystem paths are touched. The timeout != null change only affects which internal close branch is taken.

Level of scrutiny

Moderate–high. The diff is small and the root-cause analysis is thorough, but the query.ts change alters microtask ordering for the hot path shared by every await sql\...`across Postgres/MySQL/SQLite. The handler now runs *synchronously inside*.then(), which means pool.connect()onConnectedhandle.run()can all fire beforesuper.$thenis called. Re-entry viabindQuery's query.finally()is guarded bySQLQueryStatus.executed, and the PR description explains why the shared.tschange is required to keep the existingConnection destroyed with query before` tests correct — but timing changes in shared promise machinery are exactly where subtle regressions hide, so a maintainer familiar with the pool lifecycle should confirm.

Other factors

  • Overlaps with open PR #32039 (same timeout != null fix, with a dedicated mock-server test this PR doesn't duplicate). A human should decide sequencing.
  • Bug-hunting system found nothing; tests are added alongside existing coverage in the right files and the author verified fail-before with USE_SYSTEM_BUN=1.
  • CI build #70449 was still in progress at last update.

@coderabbitai coderabbitai Bot mentioned this pull request Aug 8, 2026
11 tasks
Comment on lines +131 to +132
// Enqueue synchronously (same as execute()) so a same-tick close() sees
// the query as pending; #run()'s executed-status guard handles re-entry.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the 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.

I reviewed this and didn't find any bugs. The two source changes are small and well-reasoned, but removing the microtask deferral in Query.#runAsync() changes when the pool handler runs relative to user code calling .then(), so a human sign-off on the ordering change would be worthwhile. There's also an open comment-cop flag on the two-line comment at query.ts:131-132 that a maintainer should adjudicate (it reads as a false positive to me — the comment explains a non-obvious choice, not a workaround).

What was reviewed

  • #runAsync() now delegates to #run(); re-entry via bindQuery's query.finally() is guarded by the executed status flag set before the handler runs.
  • close({ timeout }): timeout != null correctly routes 0 to the force-close branch and still treats null/undefined as drain-with-no-timer; the new mock-server tests cover both.
  • #runAsyncAndCatch()'s runPromise !== this guard still holds — the async wrapper returns a fresh promise even when #run() returns this.
Extended reasoning...

Overview

Two source changes in src/js/internal/sql/: (1) Query.#runAsync() drops await Promise.$resolve() and now delegates to #run(), so .then()/.catch()/.finally() hand the query to the pool synchronously (matching .execute()); (2) BaseSQLAdapter.close() gates the timeout branch on timeout != null instead of truthiness, so the documented { timeout: 0 } reaches the force-close path. Tests are added for SQLite, Postgres, and MySQL, plus mock-server coverage in sql-close-pending-connection.test.ts for both { timeout: 0 } and { timeout: null } with a query in flight.

Security risks

None. No untrusted input parsing, no auth/crypto. The changes are internal query-lifecycle ordering and an option-presence check.

Level of scrutiny

Medium-high. The timeout != null fix is a textbook falsy-zero bug and low risk. Removing the microtask deferral is a small diff but a real behavioral change: the pool handler (which can call connection.bindQuery, which calls query.finally() back on the same query) now runs synchronously inside the user's .then() call. The re-entrancy is handled by the SQLQueryStatus.executed bit set before the handler is invoked, and .execute() has always taken this synchronous path, so the code path itself is proven — but the change in when it runs relative to surrounding user code is the kind of thing that can surface subtle ordering bugs that unit tests don't catch. That warrants a human look.

Other factors

  • My earlier nit (dedupe #runAsync with #run) was addressed in 062e3cf; that thread is resolved.
  • The async wrapper still returns a fresh promise (never this), so #runAsyncAndCatch()'s runPromise !== this check and its .catch(() => {}) unhandled-rejection guard are unchanged in effect.
  • comment-cop flagged the two-line comment on #runAsync() today; it's short and explains why an async method enqueues synchronously, which seems like exactly the non-obvious decision worth a comment. A maintainer should decide whether to trim it or dismiss the flag.
  • The isDebug ? 1000 : 10000 iteration reduction in sqlite-sql.test.ts addresses a pre-existing debug-ASAN timeout the PR description mentions; unrelated to the fix but reasonable.

…sql-close-drains-same-tick-queries

main landed the same isDebug iterations tweak in sqlite-sql.test.ts (#37109), so that hunk is now upstream.

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

I reviewed this PR and the bug-hunting pass found no issues. Because the core change removes the microtask deferral in Query#runAsync() — shifting when every awaited query is handed to the pool from one-microtask-later to synchronously inside .then() — a human look at the timing/re-entrancy implications would still be worthwhile.

What was reviewed:

  • Re-entry from bindQuery's query.finally() during the now-synchronous handler call — guarded by the SQLQueryStatus.executed mask in #run().
  • #runAsyncAndCatch()'s runPromise !== this guard — still holds because the async wrapper returns a fresh promise even when #run() returns this.
  • close({ timeout: 0 }) vs close({ timeout: null }) after the timeout != null gate — both paths covered by the new mock-server tests; imported wire-frames.ts helpers all exist.
Extended reasoning...

Overview

Two source changes: src/js/internal/sql/query.ts collapses #runAsync() into a one-line async delegator to #run(), dropping the await Promise.$resolve() that deferred the pool hand-off by one microtask; src/js/internal/sql/shared.ts changes if (timeout) to if (timeout != null) in BaseSQLAdapter.close() so { timeout: 0 } reaches the documented force-close branch. Four test files add coverage across SQLite, Postgres, MySQL, and mock wire servers.

Security risks

None identified. No parsing of untrusted input, no auth/TLS/crypto surface, no new external I/O beyond localhost mock servers in tests.

Level of scrutiny

Moderate-to-high. The shared.ts change is a clear falsy-zero fix, but the query.ts change alters execution ordering for every Bun.SQL query awaited via .then()/.catch()/.finally() across all three adapters. The removed await Promise.$resolve() was a deliberate deferral; the PR argues convincingly that the executed status flag makes synchronous dispatch safe, and I traced the bindQuery → query.finally() → #run() re-entry to confirm it short-circuits, but this is exactly the kind of microtask-ordering change where an unforeseen caller could be relying on the old timing.

Other factors

  • My earlier duplication nit was addressed (062e3cf).
  • There is a fresh unresolved comment-cop inline on query.ts:132; it looks like a false positive on a two-line explanatory comment, but the author hasn't responded to it yet.
  • Test coverage is thorough (fail-before verified, all three drivers, mock-server force-close and drain cases), which raises confidence — but does not make this a mechanical change.

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: close({ timeout: 0 }) does not close immediately when queries are pending

1 participant