Skip to content

sql: reject close() timeouts that overflow setTimeout's millisecond range#32100

Open
robobun wants to merge 2 commits into
mainfrom
farm/32148b3c/sql-close-timeout-overflow
Open

sql: reject close() timeouts that overflow setTimeout's millisecond range#32100
robobun wants to merge 2 commits into
mainfrom
farm/32148b3c/sql-close-timeout-overflow

Conversation

@robobun

@robobun robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32096

Repro

const sql = new Bun.SQL(url, { max: 1 });
const query = sql`select pg_sleep(2)`.catch(e => e.code);
await Bun.sleep(100);
await sql.close({ timeout: 2 ** 31 }); // resolves after ~1 ms
console.log(await query);              // ERR_POSTGRES_CONNECTION_CLOSED
TimeoutOverflowWarning: 2147483648000 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.

Cause

close({ timeout }) takes seconds, but every close site (the pool close shared by the postgres and mysql adapters, reserved_sql.close, transaction_sql.close) validated the upper bound against the seconds value and then armed the timer in milliseconds:

if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw ... }
...
setTimeout(..., timeout * 1000);

setTimeout clamps delays above 2 ** 31 - 1 ms to 1 ms, so any timeout in (2147483.647, 2 ** 31] seconds passed validation and force-closed after about 1 ms, cancelling in-flight queries instead of waiting.

Fix

  • One shared validateCloseTimeout in internal/sql/shared.ts, bounded at (2 ** 31 - 1) / 1000 seconds, replaces the inline checks. The error message now states the real bound: must be a non-negative number no greater than 2147483.647 seconds. After the rebase onto sql: consolidate JS pool/connection plumbing into BasePooledConnection in shared.ts #32145 (which consolidated the postgres and mysql adapters' pool plumbing into shared.ts), that means the consolidated pool close() in shared.ts plus reserved_sql.close and transaction_sql.close in sql.ts.
  • In reserved_sql.close and transaction_sql.close, validation ran after acceptQueries was already cleared, so a rejected close left the handle refusing all further queries. Validation now runs before the state mutation; the pool close already had the right order.

While verifying the reserved path I hit an unrelated pre-existing hang (reserved.close() then sql.close() never resolves, with no timeout option at all); filed as #32099, not touched here.

Verification

test/js/sql/sql-close-timeout-overflow.test.ts:

  • postgres and mysql pool close() reject 2 ** 31, 2147483.648, and Infinity with ERR_INVALID_ARG_VALUE, accept 2147483.647 (the exact max), 60, and 0.5, and still reject -1 and non-numeric input. Pool connections are lazy, so these run without a server.
  • sqlite transaction: tx.close({ timeout: 2 ** 31 }) rejects and the transaction still runs queries and commits.
  • reserved connection (docker-gated, postgres): the rejected close leaves the connection usable and releasable.

The server-free tests fail on the unfixed build (6 fail: the close resolves instead of rejecting, and the sqlite transaction gets rolled back with ERR_SQLITE_CONNECTION_CLOSED) and pass with the fix (18 pass). Re-verified after rebasing onto #32145.

The new test file is separate from sql.test.ts because that file is wrapped in isDockerEnabled() and skips entirely without docker; these tests need no server.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:45 PM PT - Jun 12th, 2026

@robobun, your commit 7fadfe2 has 1 failures in Build #62127 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32100

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

bun-32100 --bun

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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: e747f992-9f41-4ac1-91b8-f5abb3c237f9

📥 Commits

Reviewing files that changed from the base of the PR and between 3b87de4 and 7fadfe2.

📒 Files selected for processing (3)
  • src/js/bun/sql.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-close-timeout-overflow.test.ts

Walkthrough

Introduces validateCloseTimeout and MAX_CLOSE_TIMEOUT, centralizes timeout coercion/validation for SQL close() flows, applies it in pool/transaction/reserved close paths, and adds tests asserting overflow and edge-case rejection while preserving connection usability.

Changes

SQL close() timeout overflow fix

Layer / File(s) Summary
Shared timeout validator
src/js/internal/sql/shared.ts
New MAX_CLOSE_TIMEOUT constant and validateCloseTimeout(timeout) enforce non-negative, finite, millisecond-safe upper bound and return the validated timeout in seconds; exported from shared module.
Adapter integrations
src/js/bun/sql.ts, src/js/internal/sql/shared.ts
Reserved connection and transaction close() paths now call validateCloseTimeout(timeout) instead of inline Number() coercion and 2 ** 31 checks; pool close({ timeout }) also uses the helper.
Timeout overflow and edge-case tests
test/js/sql/sql-close-timeout-overflow.test.ts
Tests parameterize Postgres/MySQL pools and cover overflow values (2 ** 31, 2147483.648, Infinity) rejected with ERR_INVALID_ARG_VALUE, boundary/valid values accepted, message content checked, and SQLite/Reserved flows verified usable after rejected closes.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#32145: Refactors pooled-connection/pool close lifecycle plumbing in the same shared module.

Suggested reviewers

  • alii
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing validation to reject close() timeouts that would overflow setTimeout's millisecond range.
Description check ✅ Passed The PR description comprehensively covers the issue, root cause, fix implementation, and verification with detailed test scenarios, exceeding the template requirements.
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.

@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/sql/sql-close-timeout-overflow.test.ts`:
- Around line 103-112: The test leaks the reserved connection if an assertion
fails because reserved.release() is called after assertions; wrap the test body
that performs assertions and queries in a try/finally (or register cleanup) so
reserved.release() runs on every exit path. Specifically, after obtaining the
connection via sql.reserve() and performing the reserved.close() call/assertions
and the subsequent reserved`select 1 as x` check, ensure reserved.release() is
placed inside a finally block (or registered before assertions) so it always
executes even when expect() throws.
🪄 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: 73ecfc54-3067-42f2-8da5-d1957fe3d6ac

📥 Commits

Reviewing files that changed from the base of the PR and between f8723b1 and 33c9204.

📒 Files selected for processing (5)
  • src/js/bun/sql.ts
  • src/js/internal/sql/mysql.ts
  • src/js/internal/sql/postgres.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-close-timeout-overflow.test.ts

Comment thread test/js/sql/sql-close-timeout-overflow.test.ts Outdated
@robobun robobun force-pushed the farm/32148b3c/sql-close-timeout-overflow branch from d5e4b90 to 3b87de4 Compare June 12, 2026 16:10

@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/js/internal/sql/shared.ts`:
- Around line 1222-1248: The timeout branch is incorrectly gated by truthiness
so a provided timeout of 0 is treated as "no timeout"; update the check to
detect presence (e.g., options?.timeout !== undefined) so that timeout =
validateCloseTimeout(timeout) is used for zero and the immediate-close behavior
(this.closed = true; if (timeout === 0 || !this.hasPendingQueries()) { await
this.#close(); return; }) runs; ensure the rest of the timeout flow
(Promise.withResolvers, setTimeout using timeout * 1000, timer.unref(),
this.onAllQueriesFinished clearing timer and calling
this.#close().finally(resolve)) remains unchanged.
🪄 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: b8d90858-952d-473d-8e46-8e1d73956e0a

📥 Commits

Reviewing files that changed from the base of the PR and between d5e4b90 and 3b87de4.

📒 Files selected for processing (3)
  • src/js/bun/sql.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-close-timeout-overflow.test.ts

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

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/js/internal/sql/shared.ts`:
- Around line 1222-1248: The timeout branch is incorrectly gated by truthiness
so a provided timeout of 0 is treated as "no timeout"; update the check to
detect presence (e.g., options?.timeout !== undefined) so that timeout =
validateCloseTimeout(timeout) is used for zero and the immediate-close behavior
(this.closed = true; if (timeout === 0 || !this.hasPendingQueries()) { await
this.#close(); return; }) runs; ensure the rest of the timeout flow
(Promise.withResolvers, setTimeout using timeout * 1000, timer.unref(),
this.onAllQueriesFinished clearing timer and calling
this.#close().finally(resolve)) remains unchanged.
🪄 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: b8d90858-952d-473d-8e46-8e1d73956e0a

📥 Commits

Reviewing files that changed from the base of the PR and between d5e4b90 and 3b87de4.

📒 Files selected for processing (3)
  • src/js/bun/sql.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-close-timeout-overflow.test.ts
🛑 Comments failed to post (1)
src/js/internal/sql/shared.ts (1)

1222-1248: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle timeout: 0 by presence, not truthiness.

Line 1223 and Line 1247 both gate on if (timeout), so sql.close({ timeout: 0 }) never enters the timeout path and does not take the intended immediate-close branch. That leaves 0 behaving like “no timeout provided” instead of “force close now”.

💡 Suggested fix
   async close(options?: { timeout?: number }): Promise<void> {
     if (this.closed) {
       return;
     }
 
     let timeout = options?.timeout;
-    if (timeout) {
+    const hasTimeout = timeout === 0 || !!timeout;
+    if (hasTimeout) {
       timeout = validateCloseTimeout(timeout);
 
       this.closed = true;
       if (timeout === 0 || !this.hasPendingQueries()) {
         // close immediately
@@
 
       return promise;
     } else {
       this.closed = true;
       if (!this.hasPendingQueries()) {
🤖 Prompt for 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.

In `@src/js/internal/sql/shared.ts` around lines 1222 - 1248, The timeout branch
is incorrectly gated by truthiness so a provided timeout of 0 is treated as "no
timeout"; update the check to detect presence (e.g., options?.timeout !==
undefined) so that timeout = validateCloseTimeout(timeout) is used for zero and
the immediate-close behavior (this.closed = true; if (timeout === 0 ||
!this.hasPendingQueries()) { await this.#close(); return; }) runs; ensure the
rest of the timeout flow (Promise.withResolvers, setTimeout using timeout *
1000, timer.unref(), this.onAllQueriesFinished clearing timer and calling
this.#close().finally(resolve)) remains unchanged.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Re the latest CodeRabbit finding on the timeout truthiness gate in shared.ts close(): that behavior is pre-existing, this PR only replaces the bound check inside the branch. close({ timeout: 0 }) being treated as "no timeout" is tracked in #32038 and has a dedicated fix open in #32039, so changing it here would be an out of scope behavior change that conflicts with that PR.

@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 — focused validation fix with thorough test coverage.

Extended reasoning...

Overview

This PR fixes a timeout-overflow bug in sql.close({ timeout }): the option is in seconds, but the deadline timer is armed in milliseconds, and the old upper-bound check (> 2 ** 31 seconds) allowed values whose millisecond product overflows setTimeout's 32-bit limit and gets clamped to 1 ms. The fix consolidates three identical inline checks into a shared validateCloseTimeout helper bounded at (2 ** 31 - 1) / 1000 seconds, and reorders validation in reserved_sql.close / transaction_sql.close to run before clearing acceptQueries, so a rejected close no longer poisons the handle. A new test file covers rejection of overflowing values, acceptance of the exact boundary, continued rejection of previously-invalid inputs, and the validate-before-mutate ordering across postgres/mysql/sqlite/reserved paths.

Security risks

None. This is input validation tightening on a numeric timeout option; no auth, crypto, network, or untrusted-input parsing is involved.

Level of scrutiny

Low-to-moderate. The change is small (~30 source lines), mechanical, and follows the existing validation pattern used for idleTimeout / connectionTimeout / maxLifetime in the same file. The only behavior changes are (1) rejecting timeouts in (2147483.647, 2^31] seconds that were previously accepted-but-broken, and (2) a more accurate error message — both strict improvements with no realistic compatibility impact.

Other factors

  • The bug-hunting system found no issues.
  • CodeRabbit's only comment (release the reserved connection on assertion failure) was addressed via await using and confirmed resolved.
  • No CODEOWNERS apply to these paths.
  • The reordering preserves semantics: validateCloseTimeout throws inside async functions, so callers still see a rejected promise; the split if (timeout) { validate } … if (timeout) { use } is equivalent to the prior single block for all inputs (0, falsy, truthy, string-coercible).
  • Test coverage is well-designed: pool tests need no server (lazy connect), the sqlite transaction path runs in-process, and only the reserved-connection test is docker-gated.

robobun added 2 commits June 12, 2026 18:12
…ange

The timeout option is in seconds but the deadline timer is armed in
milliseconds. The upper bound was checked against the seconds value
(2 ** 31), so timeouts in (2147483.647, 2 ** 31] passed validation and
setTimeout clamped the overflowing delay to 1 ms, force-closing the
pool and cancelling in-flight queries.

All four close() sites now share one validator bounded at
(2 ** 31 - 1) / 1000 seconds. On reserved connections and transactions,
validation now runs before acceptQueries is cleared so a rejected
close leaves the handle usable.

Fixes #32096
@robobun robobun force-pushed the farm/32148b3c/sql-close-timeout-overflow branch from 3b87de4 to 7fadfe2 Compare June 12, 2026 18:13

@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 — focused fix that corrects the seconds-vs-milliseconds bound and moves validation ahead of state mutation, with thorough test coverage.

Extended reasoning...

Overview

This PR fixes #32096: sql.close({ timeout }) validated the upper bound against the seconds value (2 ** 31) but then passed timeout * 1000 to setTimeout, so values in (2147483.647, 2 ** 31] seconds overflowed the 32-bit ms range and were clamped to 1 ms, force-closing immediately. The fix introduces a shared validateCloseTimeout in internal/sql/shared.ts bounded at (2 ** 31 - 1) / 1000 seconds and replaces the three inline checks (pool close in shared.ts, reserved_sql.close and transaction_sql.close in sql.ts). It also reorders the reserved/transaction close paths so validation runs before clearing acceptQueries, so a rejected close no longer leaves the handle unusable. A new test file covers overflow rejection, boundary acceptance, the error message, and post-rejection usability for sqlite transactions and (docker-gated) postgres reserved connections.

Security risks

None. This is pure input-validation tightening on a numeric option in JS-land; no auth, crypto, parsing of untrusted data, or native code is touched. The only behavior change is that previously-accepted absurdly large timeouts now reject with ERR_INVALID_ARG_VALUE instead of silently misbehaving.

Level of scrutiny

Low-to-moderate. The production diff is ~30 lines: a 10-line helper plus three call-site substitutions and two statement reorderings. The bound math ((2 ** 31 - 1) / 1000) is correct for setTimeout's documented limit, and the reordering is a clear improvement (validate-then-mutate). The split into two if (timeout) blocks preserves the existing control flow exactly aside from where the throw lands relative to the state flip.

Other factors

  • No CODEOWNERS for these paths.
  • The bug-hunting system found nothing.
  • The one CodeRabbit finding (reserved-connection cleanup on assertion failure) was addressed via await using and confirmed resolved.
  • The author explicitly scoped out the pre-existing timeout: 0 truthiness quirk (#32038/#32039) and the unrelated reserved-close hang (#32099), keeping this change minimal.
  • Tests verify both the new rejection boundary and that previously-rejected inputs (-1, non-numeric) stay rejected, and were reported passing on the fixed build and failing on the unfixed one.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: every lane that ran is green (284 jobs, including the ASAN lane). The only outstanding check is darwin-14-aarch64-test-bun, which has expired twice waiting for an agent and is requeued; the red check is queue starvation on that lane, not a test failure. The diff is ready for review.

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 upper bound is validated in seconds but armed in milliseconds

1 participant