Files
bun-src/test/js/sql/sql-statement-cache-hash-collision.test.ts
T

130 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-08-27 21:09:14 +00:00
// https://github.com/oven-sh/bun/issues/32741
//
// The per-connection prepared-statement cache was keyed on a wyhash of
// `signature.name` (= query text + one suffix per bound param), so two
// distinct queries whose names collide under that hash shared one server-side
// statement. The cache now keys on the name bytes themselves; these are the
// constructed colliding inputs that broke the old key.
import { SQL } from "bun";
import { expect, test } from "bun:test";
import { describeWithContainer } from "harness";
import { constructStdCollision } from "../../cli/install/wyhash-std-collision";
// signature.name = SQL text + ".null" for one null-bound param. The free
// 8-byte word sits inside a string literal, so the two queries return
// different `v` values while their names collide under std.Wyhash(0).
function collidingQueryPair(placeholder: string, freeB = "BBBBBBBB") {
// Every printable ASCII char that is valid inside a single-quoted SQL
// string literal. A wide charset lets the collision search land an in-set
// kill word in far fewer iterations, which matters under debug JSC.
let charset = "";
for (let c = 0x20; c < 0x7f; c++) if (c !== 0x27 && c !== 0x5c) charset += String.fromCharCode(c);
const paramSuffix = ".null";
const r = constructStdCollision({
seed: 0n,
prefixStr: "SELECT '",
suffixStr: `' AS v, ${placeholder} AS p${paramSuffix}`,
charset,
freeA: "AAAAAAAA",
freeB,
padFillCh: "x",
});
const sqlA = r.str1.slice(0, -paramSuffix.length);
const sqlB = r.str2.slice(0, -paramSuffix.length);
const enc = (s: string) => new TextEncoder().encode(s);
// Self-verify the pair collides under `Bun.hash.wyhash` (seed 0), the hash
// that keyed the cache before the fix; that collision is what makes this
// pair the regression input.
if (Bun.hash.wyhash(enc(r.str1), 0n) !== Bun.hash.wyhash(enc(r.str2), 0n)) {
throw new Error("constructed pair does not collide under wyhash");
}
return { sqlA, sqlB };
}
async function assertDistinctStatements(sql: SQL, label: string, placeholder: string) {
const { sqlA, sqlB } = collidingQueryPair(placeholder);
expect(sqlA).not.toBe(sqlB);
expect(sqlA).toContain("AAAAAAAA");
expect(sqlB).toContain("BBBBBBBB");
// Query A populates the statement cache; query B, byte-distinct but
// wyhash-colliding, must prepare its own statement and return its own value.
const [[rA], [rB], [rC]] = await Promise.all([
sql.unsafe(sqlA, [null]),
sql.unsafe(sqlB, [null]),
sql.unsafe(`SELECT 'CONTROL' AS v, ${placeholder} AS p`, [null]),
]);
expect({ a: rA.v.includes("AAAAAAAA"), b: rB.v.includes("BBBBBBBB"), c: rC.v }).toEqual({
a: true,
b: true,
c: "CONTROL",
});
// Before the fix, rB.v contained "AAAAAAAA" (A's statement was reused).
expect(rB.v).not.toContain("AAAAAAAA");
// Re-running A still hits its cached statement (nothing was evicted) and
// still returns A's own value.
const [rA2] = await sql.unsafe(sqlA, [null]);
expect(rA2.v).toBe(rA.v);
// Re-running B hits B's own cached statement and still returns B's value.
const [rB2] = await sql.unsafe(sqlB, [null]);
expect(rB2.v).toBe(rB.v);
console.log(`${label}: A="${rA.v.includes("AAAAAAAA") ? "A" : "?"}", B="${rB.v.includes("BBBBBBBB") ? "B" : "?"}"`);
}
describeWithContainer("mysql", { image: "mysql_plain" }, container => {
test("MySQL: hash-colliding prepared statements are not confused", async () => {
await container.ready;
await using sql = new SQL({
url: `mysql://root@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
await assertDistinctStatements(sql, "mysql", "?");
});
});
describeWithContainer("postgres", { image: "postgres_plain" }, container => {
test("Postgres: hash-colliding prepared statements are not confused", async () => {
await container.ready;
await using sql = new SQL({
url: `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
await assertDistinctStatements(sql, "postgres", "$1");
});
test("Postgres: a hash-colliding query that fails to parse does not evict or free the cached statement", async () => {
await container.ready;
await using sql = new SQL({
url: `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
// The bad query's 8-byte free word breaks out of the string literal, so
// Postgres rejects it at Parse; its name wyhash-collides with the good
// query's, which is the input that confused the old hash-keyed cache.
const { sqlA: good, sqlB: bad } = collidingQueryPair("$1", "'||qq9z(");
const [rGood] = await sql.unsafe(good, [null]);
expect(rGood.v).toContain("AAAAAAAA");
// Before the collision fix this resolved with the good query's row
// instead of erroring, because it reused the cached Prepared statement.
const err = await sql.unsafe(bad, [null]).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/syntax error|unterminated|multiple commands/i);
Bun.gc(true);
// The connection must survive the parse failure: the good query still
// hits its cached statement and returns its own row, and a fresh query
// still works.
const [rGood2] = await sql.unsafe(good, [null]);
const [rCtl] = await sql.unsafe(`SELECT 'CONTROL' AS v, $1 AS p`, [null]);
expect({ good: rGood2.v.includes("AAAAAAAA"), control: rCtl.v }).toEqual({ good: true, control: "CONTROL" });
});
});