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 7dc59a8b0290..afcfdfd54875 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -6,6 +6,8 @@ const { SQLQueryFlags, symbols: { _strings, _values }, } = require("internal/sql/query"); +// 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 { @@ -609,9 +611,7 @@ abstract class BasePooledConnection | null = null; @@ -641,7 +641,7 @@ abstract class BasePooledConnection JsResult { + let now = Timespec::now(TimespecMockMode::ForceRealTime).ms(); + Ok(JSValue::js_number(now as f64)) + } } 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 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(); + } +});