Files

95 lines
4.1 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// 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);
});
}