Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -609,9 +611,7 @@ abstract class BasePooledConnection<ConnectionHandle extends { close(): void; fl
flags: number = 0;
/// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries
queryCount: number = 0;
/// when the current connect cycle started; 0 when not connecting. Connect
/// failures (server not yet accepting connections) are retried until
/// connectionTimeout elapses from this point.
/// monotonicNowMs() at the start of the current connect cycle; 0 when not connecting.
connectStartedAt: number = 0;
connectAttempts: number = 0;
retryTimer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -641,7 +641,7 @@ abstract class BasePooledConnection<ConnectionHandle extends { close(): void; fl
async #beginConnecting() {
// a fresh connect cycle (not a backoff retry) starts the retry budget
if (this.connectStartedAt === 0) {
this.connectStartedAt = Date.now();
this.connectStartedAt = monotonicNowMs();
this.connectAttempts = 0;
}
await this.startConnection();
Expand Down Expand Up @@ -742,7 +742,7 @@ abstract class BasePooledConnection<ConnectionHandle extends { close(): void; fl
if (connectionTimeout <= 0) {
return false;
}
return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout;
return this.connectStartedAt !== 0 && monotonicNowMs() - this.connectStartedAt < connectionTimeout;
}

/// Returns true if a scheduled connect retry was cancelled; in that case
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/timer/Timer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,4 +596,18 @@ pub(crate) mod internal_bindings {
// `js_number(f64)` (i64 → f64 is lossless for the millisecond range).
Ok(JSValue::js_number(now as f64))
}

/// Monotonic milliseconds for deadlines Bun's own JS measures (e.g. the SQL
/// connect-retry budget). bun:test's `setSystemTime()` / `useFakeTimers()`
/// override `Date.now()` and `performance.now()` inside the engine, so a
/// deadline measured with either freezes or jumps along with the mocked
/// clock. This is the real clock the timer heap is drained against.
Comment thread
robobun marked this conversation as resolved.
#[bun_jsc::host_fn]
pub(crate) fn monotonic_now_ms(
_global_this: &JSGlobalObject,
_call_frame: &CallFrame,
) -> JsResult<JSValue> {
let now = Timespec::now(TimespecMockMode::ForceRealTime).ms();
Ok(JSValue::js_number(now as f64))
}
}
30 changes: 30 additions & 0 deletions test/js/bun/test/fake-timers/fake-timers.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
102 changes: 100 additions & 2 deletions test/js/sql/sql-connect-error-reporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
});