// 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); }); }