Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions test/js/sql/sql-mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,34 @@ if (isDockerEnabled()) {
expect(dupSecond[0]).toEqual({ x: 1, y: 2, z: 3, w: 4 });
});

test("returns every column of a wide prepared-statement result", async () => {
// The "more than the max inline capacity" tests above go through
// sql.unsafe(), the text protocol. A parameterized query runs as a
// prepared statement, whose binary rows carry a NULL bitmap of
// (columns + 9) / 8 bytes. 500 columns with 64-byte names (the MySQL
// identifier limit) cover a 63-byte bitmap and a row structure far
// past the inline property capacity.
await using db = new SQL({ ...getOptions(), max: 1, idleTimeout: 5 });
using sql = await db.reserve();

const columns = Array.from({ length: 500 }, (_, i) => `col_${i}_`.padEnd(64, "x"));
const filled = columns.filter((_, i) => i % 7 === 0);
const t = "wide_" + randomUUIDv7("hex").replaceAll("-", "");
await sql.unsafe(
`CREATE TEMPORARY TABLE ${t} (id INT PRIMARY KEY, ${columns.map(name => `${name} TINYINT`).join(", ")})`,
);
await sql.unsafe(
`INSERT INTO ${t} (id, ${filled.join(", ")}) VALUES (1, ${filled.map((_, i) => i).join(", ")})`,
);

const expected = Object.fromEntries(columns.map(name => [name, null]));
filled.forEach((name, i) => (expected[name] = i));
expect(await sql`SELECT * FROM ${sql(t)} WHERE id = ${1}`).toEqual([{ id: 1, ...expected }]);
expect(await sql`SELECT * FROM ${sql(t)} WHERE id = ${1}`.values()).toEqual([
[1, ...columns.map(name => expected[name])],
]);
});

test("Handles numeric column names", async () => {
// deliberately out of order
const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 0 as "0"`;
Expand Down
62 changes: 62 additions & 0 deletions test/regression/issue/28632.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Child process for 28632.test.ts: re-executes one prepared statement against
// a wide temporary table and prints the RSS growth as its last line.
// argv: <mysql url>
import { SQL } from "bun";

const [url] = process.argv.slice(2);

// The server re-sends one column definition per column with every
// COM_STMT_EXECUTE response, and the adapter keeps an owned copy of each
// column name (`name_or_index`). The original bug leaked that copy on every
// re-decode, so the leak per query scales with the column count and the name
// length. MySQL caps identifiers at 64 bytes; 500 columns stay under MariaDB's
// table-definition size limit.
const COLUMNS = 500;
const columns = Array.from({ length: COLUMNS }, (_, i) => `col_${i}_`.padEnd(64, "x"));
// The first batches let the JIT tiers, the statement cache and the heap reach
// a steady state, so the delta measures only what the later queries keep.
const WARMUP_QUERIES = 100;
const MEASURED_QUERIES = 300;
const BATCH = 50;

const rss: () => number =
process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function"
? (Bun.unsafe.memoryFootprint as () => number)
: process.memoryUsage.rss;

// `max: 1` pins the connection, so the temporary table is visible to every
// query below and disappears with the connection.
await using sql = new SQL({ url, max: 1 });
await sql.unsafe(
`CREATE TEMPORARY TABLE leak_test_28632 (id INT PRIMARY KEY, ${columns.map(name => `${name} TINYINT`).join(", ")})`,
);
await sql`INSERT INTO leak_test_28632 (id) VALUES (${1})`;

// `.values()` skips the per-row object build, which is not on the
// column-definition path under test and doubles the debug-build time at 500
// columns. Every result is checked so a broken query path fails the test
// instead of measuring an idle process.
const query = () => sql`SELECT * FROM leak_test_28632 WHERE id = ${1} LIMIT 1`.values();
function check(rows: unknown[][]) {
if (rows.length !== 1 || rows[0].length !== COLUMNS + 1 || rows[0][0] !== 1) {
throw new Error("unexpected result: " + JSON.stringify(rows).slice(0, 200));
}
}

// Prepare the statement with a lone query first. A failed prepare rejects here
// instead of stalling the queued copies behind it.
check(await query());

// The queries in a batch queue on the single connection. Each batch ends with
// a full GC so RSS reflects native allocations rather than JS garbage.
async function run(count: number) {
for (let done = 0; done < count; done += BATCH) {
for (const rows of await Promise.all(Array.from({ length: BATCH }, query))) check(rows);
Bun.gc(true);
}
}

await run(WARMUP_QUERIES);
const before = rss();
await run(MEASURED_QUERIES);
console.log(JSON.stringify({ deltaMiB: (rss() - before) / 1024 / 1024 }));
112 changes: 26 additions & 86 deletions test/regression/issue/28632.test.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,27 @@
// https://github.com/oven-sh/bun/issues/28632
import { SQL } from "bun";
import { beforeAll, expect, test } from "bun:test";
import { describeWithContainer, isASAN, isDebug, isDockerEnabled, rss } from "harness";

if (isDockerEnabled()) {
describeWithContainer(
"issue #28632: MySQL adapter should not leak memory on repeated queries",
{
image: "mysql_plain",
concurrent: true,
},
container => {
let sql: SQL;

beforeAll(async () => {
await container.ready;
sql = new SQL({
url: `mysql://root@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
});

test("prepared statement re-execution should not leak name_or_index", async () => {
// Create a wide table to amplify the per-column leak signal
await sql`DROP TABLE IF EXISTS leak_test_28632`;
await sql`CREATE TABLE leak_test_28632 (
primary_id VARCHAR(255) PRIMARY KEY,
column_alpha_bravo TEXT, column_charlie_delta TEXT, column_echo_foxtrot TEXT,
column_golf_hotel TEXT, column_india_juliet TEXT, column_kilo_lima TEXT,
column_mike_november TEXT, column_oscar_papa TEXT, column_quebec_romeo TEXT,
column_sierra_tango TEXT, column_uniform_victor TEXT, column_whiskey_xray TEXT,
column_yankee_zulu TEXT, column_one_two_three TEXT, column_four_five_six TEXT,
column_seven_eight TEXT, column_nine_ten TEXT, column_eleven_twelve TEXT,
column_thirteen_fourtn TEXT, column_fifteen_sixtn TEXT, column_seventeen TEXT,
column_eighteen TEXT, column_nineteen TEXT, column_twenty_extra TEXT,
column_twentyone TEXT, column_twentytwo TEXT, column_twentythree TEXT,
column_twentyfour TEXT, column_twentyfive TEXT, column_twentysix TEXT,
column_twentyseven TEXT, column_twentyeight TEXT, column_twentynine TEXT,
column_thirty_extra TEXT, column_thirtyone TEXT, column_thirtytwo TEXT,
column_thirtythree TEXT, column_thirtyfour TEXT, column_thirtyfive TEXT,
column_thirtysix TEXT, column_thirtyseven TEXT, column_thirtyeight TEXT,
column_thirtynine TEXT, column_forty_extra TEXT, column_fortyone TEXT,
column_fortytwo TEXT, column_fortythree TEXT, column_fortyfour TEXT,
column_fortyfive TEXT
)`;
await sql`INSERT INTO leak_test_28632 (primary_id) VALUES ('123')`;

// Warm up to stabilize RSS
for (let i = 0; i < 500; i++) {
await sql`SELECT * FROM leak_test_28632 WHERE primary_id = ${"123"} LIMIT 1`;
}
Bun.gc(true);
await Bun.sleep(50);
const rssAfterWarmup = rss();

// Run queries — each re-decodes 50 column definitions. Collect every 500
// so the RSS delta reflects the name_or_index leak rather than the
// controller's opportunistic-GC cadence (which no longer fires at this
// allocation volume): without this, peak uncollected garbage between
// opportunistic passes commits extra MarkedBlock pages that Bun.gc(true)
// at the end does not synchronously decommit.
for (let i = 0; i < 5000; i++) {
await sql`SELECT * FROM leak_test_28632 WHERE primary_id = ${"123"} LIMIT 1`;
if (i % 500 === 499) Bun.gc(true);
}
Bun.gc(true);
await Bun.sleep(50);
const rssAfterQueries = rss();

const growthMB = (rssAfterQueries - rssAfterWarmup) / 1024 / 1024;

// Without the fix, ~17MB growth (50 leaked name_or_index allocs × 5000 queries).
// With the fix, ~7MB (allocator noise + ASAN shadow memory). Under ASAN the
// Rust global allocator routes every alloc through the interceptor, so the
// per-query free/alloc churn (row cells, etc.) lands in ASAN's 256 MB
// quarantine and shows up as RSS even though nothing leaks — give it 3×
// headroom there. Debug builds enable ASAN by default on Linux/macOS, so
// treat them the same. The non-ASAN bound is what guards the actual
// regression.
expect(growthMB).toBeLessThan(isASAN || isDebug ? 36 : 12);

await sql`DROP TABLE IF EXISTS leak_test_28632`.catch(() => {});
});
},
);
}
import { test } from "bun:test";
import { describeWithContainer, expectRssDeltaBelow } from "harness";
import path from "node:path";

describeWithContainer(
"issue #28632: MySQL adapter should not leak memory on repeated queries",
{
image: "mysql_plain",
concurrent: true,
},
container => {
test("prepared statement re-execution should not leak name_or_index", async () => {
await container.ready;
const url = `mysql://root@${container.host}:${container.port}/bun_sql_test`;

// The fixture re-executes a prepared SELECT over a 500-column table with
// 64-byte column names. Unfixed: one 64-byte block leaks per column per
// query, so its 300 measured queries leak 150,000 blocks: at least 9.6 MiB
// under mimalloc (release), and 15 to 17 MiB measured under ASAN, where
// each block also carries a header and redzone. Fixed: 0 to 1 MiB (release)
// and 0 to 3 MiB (debug/ASAN, quarantine off) after the fixture's
// 100-query warm-up.
await expectRssDeltaBelow([path.join(import.meta.dir, "28632.fixture.ts"), url], { release: 5, debug: 8 });
});
},
);