95 lines
4.1 KiB
TypeScript
95 lines
4.1 KiB
TypeScript
// 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);
|
|||
|
|
});
|
|||
|
|
}
|