Files
bun-src/test/js/sql/sql-mysql-cached-error.test.ts
2026-08-27 21:09:14 +00:00

74 lines
3.8 KiB
TypeScript

// Regression test: MySQLConnection.handlePreparedStatement stored an ErrorPacket whose
// error_message was a Data{ .temporary = ... } slice pointing into the socket read buffer.
// The statement is cached in the connection's statements map with status = .failed, so
// re-running the same failing query would read the stale slice after subsequent packets
// overwrote the buffer.
import { SQL } from "bun";
import { expect, test } from "bun:test";
import { describeWithContainer, isDockerEnabled } from "harness";
if (isDockerEnabled()) {
describeWithContainer("mysql", { image: "mysql_plain" }, container => {
test("MySQL: cached failed prepared statement error_message is not a dangling slice", async () => {
await container.ready;
await using sql = new SQL({
url: `mysql://root@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
// Long bogus identifiers so the server's echoed error_message exceeds the 15-byte
// inline-string threshold and is heap-backed, and so the two messages differ at
// bytes the second packet would overwrite in the read buffer. MySQL truncates the
// "near '...'" clause to ~80 chars, so keep these short enough to appear in full.
const longA = Buffer.alloc(50, "A").toString();
const longZ = Buffer.alloc(50, "Z").toString();
// First failing query → statement cached as .failed with error_message.
const err1 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x);
expect(err1).toBeInstanceOf(Error);
expect(err1.code).toBe("ERR_MYSQL_SYNTAX_ERROR");
expect(err1.errno).toBe(1064);
expect(err1.message).toContain(longA);
// Different failing query → server sends a different ERROR packet that overwrites
// the connection read buffer where err1's message slice used to point.
const errOverwrite = await sql`other ${1} ${sql.unsafe(longZ)}`.catch((x: any) => x);
expect(errOverwrite).toBeInstanceOf(Error);
expect(errOverwrite.message).toContain(longZ);
expect(errOverwrite.message).not.toBe(err1.message);
// Same as the first failing query → hits the cached .failed statement and calls
// stmt.error_response.toJS(). Before the fix this read the overwritten buffer and
// returned bytes from errOverwrite's packet; after the fix it returns the original.
// Com_stmt_prepare (read via .simple() so the status query itself does not prepare)
// must not increment across this call — proving the third query was served from
// Bun's failed-statement cache, not re-prepared on the server. A fresh prepare
// would return an identical error for identical SQL and silently satisfy every
// assertion below without exercising the cached-slice path.
const [{ Value: preparesBefore }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple();
// err1 and errOverwrite each reached COM_STMT_PREPARE, so the counter is
// already non-zero here; if it were 0 the "no increment" check below would
// be vacuous because the prepared path was never taken.
expect(Number(preparesBefore)).toBeGreaterThan(0);
const err2 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x);
const [{ Value: preparesAfter }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple();
expect({
code: err2.code,
errno: err2.errno,
sqlState: err2.sqlState,
message: err2.message,
preparesAfter: Number(preparesAfter),
}).toEqual({
code: err1.code,
errno: err1.errno,
sqlState: err1.sqlState,
message: err1.message,
preparesAfter: Number(preparesBefore),
});
expect(err2.message).toContain(longA);
expect(err2.message).not.toContain(longZ);
});
});
}