Files
bun-src/test/js/sql/sql-mysql-query-string-leak.test.ts
T
2026-08-27 21:09:14 +00:00

95 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MySQLQuery.init() used to `query.ref()` the bun.String it was handed, but
// the only caller (JSMySQLQuery.createInstance) already passes a +1-ref'd
// string from `JSValue.toBunString()`. That left every query string at
// refcount 2 after construction; MySQLQuery.cleanup() only deref'd once, so
// the underlying WTFStringImpl for every MySQL query string was leaked.
//
// This test runs a batch of large unique query strings against a real MySQL
// server, lets the MySQLQuery wrappers be finalized, and checks RSS didn't
// retain the query-string bytes.
import { expect, test } from "bun:test";
import { bunEnv, bunExe, describeWithContainer, isASAN, isDockerEnabled, tempDir } from "harness";
if (isDockerEnabled()) {
describeWithContainer("mysql", { image: "mysql_plain" }, container => {
test("MySQL: query string is not leaked across query lifecycle", async () => {
await container.ready;
const url = `mysql://root@${container.host}:${container.port}/bun_sql_test`;
using dir = tempDir("mysql-query-string-leak", {
"fixture.js": /* js */ `
const { SQL } = require("bun");
const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss;
const sql = new SQL({ url: process.env.MYSQL_URL, max: 1 });
// Warm up: first query allocates connection buffers, JIT, etc.
await sql.unsafe("select 1").simple();
// Each query string is ~512 KiB and unique (so JSC can't dedupe/intern
// them) and goes through the full create -> run -> finalize lifecycle.
// 200 iterations x 512 KiB = ~100 MiB of string payload.
const ITERATIONS = 200;
const CHUNK = 512 * 1024;
Bun.gc(true);
const rssBefore = rss();
for (let i = 0; i < ITERATIONS; i++) {
const pad = Buffer.alloc(CHUNK, 0x61 + (i % 26)).toString("latin1");
// Embed the bulk as a comment so the server's reply is a trivial OK
// regardless of content; suffix makes every string unique.
const q = "select 1 /* " + pad + " " + i + " */";
await sql.unsafe(q).simple();
if ((i & 15) === 15) Bun.gc(true);
}
await sql.close({ timeout: 0 }).catch(() => {});
// Give the MySQLQuery wrappers a chance to be finalized so cleanup()
// runs and drops its (single) ref on each query string.
for (let i = 0; i < 8; i++) {
await new Promise(r => setImmediate(r));
Bun.gc(true);
}
const rssAfter = rss();
const deltaMiB = (rssAfter - rssBefore) / 1024 / 1024;
console.log(JSON.stringify({ rssBefore, rssAfter, deltaMiB }));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "fixture.js"],
env: { ...bunEnv, MYSQL_URL: url },
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
timeout: 120_000,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
let parsed: { deltaMiB: number };
try {
parsed = JSON.parse(stdout.trim());
} catch {
throw new Error(`fixture did not emit JSON\nstdout:\n${stdout}\nstderr:\n${stderr}`);
}
// With the leak, every one of the ~200 x 512 KiB query strings is retained
// (plus per-string overhead), so RSS grows by >= ~100 MiB. With the fix the
// strings are freed as each MySQLQuery is finalized and growth stays small.
// ASAN's quarantine retains freed allocations (default 256 MB) and a real
// server adds wire-buffer + encode churn on top of the string churn, so the
// delta runs higher under bun-asan even with the fix; widen the threshold
// there. The non-ASAN bound is the discriminating check.
expect(parsed.deltaMiB).toBeLessThan(isASAN ? 384 : 50);
expect(exitCode).toBe(0);
// 200 × 512 KiB round-trips to a real MySQL server plus ~20 Bun.gc(true)
// calls in an ASAN debug subprocess can take tens of seconds; the 5s
// default is too tight.
}, 120_000);
});
}