From fc8058154dcb58f46b0ffbe0c24fb13e710a4d1c Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 00:04:08 +0000 Subject: [PATCH 1/4] sql: measure the connect-retry budget on the monotonic clock BasePooledConnection bounded connect retries with Date.now(), which bun:test's setSystemTime() pins (and useFakeTimers() freezes), so a pool pointed at a server that is not accepting connections redialed forever instead of failing after connectionTimeout, and a clock moved ahead ended the budget after one attempt. Add a monotonicNowMs() binding (Timespec::now(ForceRealTime)) exported from internal/timers and use it for connectStartedAt and the budget check. --- src/js/internal/sql/shared.ts | 11 +- src/js/internal/timers.ts | 9 ++ src/runtime/timer/Timer.rs | 14 +++ .../sql/sql-connect-error-reporting.test.ts | 102 +++++++++++++++++- 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 7dc59a8b0290..bc91401236f9 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -6,6 +6,7 @@ const { SQLQueryFlags, symbols: { _strings, _values }, } = require("internal/sql/query"); +const { monotonicNowMs } = require("internal/timers"); declare global { interface NumberConstructor { @@ -609,9 +610,9 @@ abstract class BasePooledConnection | null = null; @@ -641,7 +642,7 @@ abstract class BasePooledConnection JsResult { + let now = Timespec::now(TimespecMockMode::ForceRealTime).ms(); + Ok(JSValue::js_number(now as f64)) + } } diff --git a/test/js/sql/sql-connect-error-reporting.test.ts b/test/js/sql/sql-connect-error-reporting.test.ts index 6f6df881a1df..97bd61ee5205 100644 --- a/test/js/sql/sql-connect-error-reporting.test.ts +++ b/test/js/sql/sql-connect-error-reporting.test.ts @@ -23,9 +23,18 @@ // Uses plain TCP servers / closed ports so the tests run without Docker. import { SQL } from "bun"; -import { expect, test } from "bun:test"; +import { expect, setSystemTime, test } from "bun:test"; import type net from "node:net"; -import { closedPort, listeningServer, pgAuthenticationOk, pgErrorResponse, pgReadyForQuery } from "./wire-frames"; +import { + closedPort, + listeningServer, + mysqlHandshakeV10, + mysqlOkPacket, + mysqlReadPackets, + pgAuthenticationOk, + pgErrorResponse, + pgReadyForQuery, +} from "./wire-frames"; // connectionTimeout (seconds, fractional allowed) bounds the connect-retry // budget; keep it short in tests that expect the failure to surface. Tests @@ -48,6 +57,20 @@ function postgresAuthOkAndReady(socket: net.Socket) { socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); } +/** Minimal MySQL server: greet, then answer the HandshakeResponse with OK. */ +function mysqlGreetAndAuthOk(socket: net.Socket) { + let buffered = Buffer.alloc(0); + let authenticated = false; + socket.write(mysqlHandshakeV10()); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => { + if (authenticated) return; + authenticated = true; + socket.write(mysqlOkPacket(seq + 1)); + }); + }); +} + test("postgres: connection refused is reported distinctly and fails fast", async () => { const port = await closedPort(); const start = Date.now(); @@ -336,3 +359,78 @@ test("mysql: connectionTimeout: 0 disables connect retries", async () => { server.close(); } }); + +// The retry budget is measured on the monotonic clock, so bun:test's +// setSystemTime() (which pins Date.now() at the given instant) neither keeps +// the pool retrying past connectionTimeout nor ends the budget early. +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +test("postgres: the retry budget elapses while Date.now() is pinned by setSystemTime()", async () => { + const { port, server } = await listeningServer(socket => socket.destroy()); + setSystemTime(new Date("2020-01-01T00:00:00Z")); + try { + // with the budget measured via Date.now() this never rejects: every + // attempt sees 0ms elapsed and the pool redials forever + const err = await connectError(`postgres://postgres@127.0.0.1:${port}/postgres`, 0.25); + expect(err.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + } finally { + setSystemTime(); + server.close(); + } +}); + +test("postgres: setSystemTime() jumping ahead during a connect cycle does not end the retry budget", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + if (++connections === 1) { + // the pool recorded the start of this cycle before the jump; a Date.now() + // budget would now see a day of the default 30s budget gone and give up + setSystemTime(new Date(Date.now() + ONE_DAY_MS)); + socket.destroy(); + return; + } + socket.once("data", () => postgresAuthOkAndReady(socket)); + }); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); + try { + await db.connect(); + expect(connections).toBe(2); + } finally { + setSystemTime(); + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("mysql: the retry budget elapses while Date.now() is pinned by setSystemTime()", async () => { + const { port, server } = await listeningServer(socket => socket.destroy()); + setSystemTime(new Date("2020-01-01T00:00:00Z")); + try { + const err = await connectError(`mysql://root@127.0.0.1:${port}/mysql`, 0.25); + expect(err.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + } finally { + setSystemTime(); + server.close(); + } +}); + +test("mysql: setSystemTime() jumping ahead during a connect cycle does not end the retry budget", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + if (++connections === 1) { + setSystemTime(new Date(Date.now() + ONE_DAY_MS)); + socket.destroy(); + return; + } + mysqlGreetAndAuthOk(socket); + }); + const db = new SQL({ url: `mysql://root@127.0.0.1:${port}/mysql`, max: 1 }); + try { + await db.connect(); + expect(connections).toBe(2); + } finally { + setSystemTime(); + await db.close({ timeout: 0 }); + server.close(); + } +}); From da5ee40f63e281b697c17054f28706648b69e749 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 04:51:19 +0000 Subject: [PATCH 2/4] ci: retrigger From 2d69e8183e4f1916432cbae86b77464eb2f07559 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 13 Aug 2026 05:07:37 +0000 Subject: [PATCH 3/4] sql: shorten the connectStartedAt field comment --- src/js/internal/sql/shared.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index bc91401236f9..d95e56047f59 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -610,9 +610,7 @@ abstract class BasePooledConnection | null = null; From b3995f231cddcfe0761a236c0d2044820445d2d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:59:33 +0000 Subject: [PATCH 4/4] sql: bind monotonicNowMs directly, add a fake-timers test for the binding Requiring internal/timers from internal/sql/shared pulled validators, internal/shared and primordials into every Bun.SQL load for one native function; bind it where it is used instead, like the adapters do. Expose the binding through bun:internal-for-testing and assert that useFakeTimers() neither restarts nor advances it, which is the only thing separating it from the mockable timerClockMs next to it. --- src/js/internal-for-testing.ts | 1 + src/js/internal/sql/shared.ts | 3 +- src/js/internal/timers.ts | 9 ------ .../bun/test/fake-timers/fake-timers.test.ts | 30 +++++++++++++++++++ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 9b1fed3e91d9..796f3da71983 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -557,6 +557,7 @@ export const arrayBufferViewHasBuffer = $newCppFunction( export const timerInternals = { timerClockMs: $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.timerClockMs", 0), + monotonicNowMs: $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.monotonicNowMs", 0), }; // Raw datagram descriptor helpers for tests that need an unbound fd (which diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index d95e56047f59..afcfdfd54875 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -6,7 +6,8 @@ const { SQLQueryFlags, symbols: { _strings, _values }, } = require("internal/sql/query"); -const { monotonicNowMs } = require("internal/timers"); +// not Date.now(): bun:test's setSystemTime() / useFakeTimers() override that inside the engine +const monotonicNowMs = $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.monotonicNowMs", 0); declare global { interface NumberConstructor { diff --git a/src/js/internal/timers.ts b/src/js/internal/timers.ts index 886c629613fb..ef48040fc6b8 100644 --- a/src/js/internal/timers.ts +++ b/src/js/internal/timers.ts @@ -4,14 +4,6 @@ const NumberIsFinite = Number.isFinite; const TIMEOUT_MAX = 2 ** 31 - 1; -/** - * Monotonic milliseconds for deadlines the runtime itself measures. Not - * `Date.now()` / `performance.now()`: bun:test's `setSystemTime()` and - * `useFakeTimers()` override both inside the engine (capturing the function - * does not help), which would freeze or skew an internal deadline. - */ -const monotonicNowMs = $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.monotonicNowMs", 0); - function getTimerDuration(msecs, name) { validateNumber(msecs, name); if (msecs < 0 || !NumberIsFinite(msecs)) { @@ -37,5 +29,4 @@ export default { // tests that inspect socket[kTimeout]. kTimeout: Symbol.for("::buntimeout::"), getTimerDuration, - monotonicNowMs, }; diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 9b78720b9c1c..3e03dc64c91a 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -1,4 +1,5 @@ import { RedisClient, SQL } from "bun"; +import { timerInternals } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { bunEnv, bunExe } from "harness"; import { spawnSync as childProcessSpawnSync } from "node:child_process"; @@ -399,6 +400,35 @@ describe("runtime timeouts are not fake timers", () => { } }); }); + +// Deadlines the runtime measures in JS (Bun.SQL's connect-retry budget) read +// monotonicNowMs(), which stays on the real clock while Date.now(), +// performance.now() and the mockable timer clock follow the fake one. +describe("monotonicNowMs() is not a mocked clock", () => { + const ONE_HOUR = 60 * 60 * 1000; + + test("useFakeTimers() neither restarts it nor advances it", () => { + const beforeFakeTimers = timerInternals.monotonicNowMs(); + vi.useFakeTimers(); + const atActivation = timerInternals.monotonicNowMs(); + vi.advanceTimersByTime(ONE_HOUR); + const afterAdvance = timerInternals.monotonicNowMs(); + expect({ + mockedTimerClock: timerInternals.timerClockMs(), + performanceNow: performance.now(), + // the mocked clocks restart from 0 on activation; a mocked reading here + // would be below the reading taken before useFakeTimers() + restarted: atActivation < beforeFakeTimers, + advancedByTheHour: afterAdvance - atActivation >= ONE_HOUR, + }).toEqual({ + mockedTimerClock: ONE_HOUR, + performanceNow: ONE_HOUR, + restarted: false, + advancedByTheHour: false, + }); + }); +}); + // Bun.cron() is mockable, so a job created under fake timers lives in the fake // heap, and useRealTimers() / clearAllTimers() drop it with the rest. Like a // dropped setInterval it has to end up stopped, rather than holding the process