151 lines
6.3 KiB
TypeScript
151 lines
6.3 KiB
TypeScript
// Fault-injection test: requires a server that refuses / drops / sends malformed
|
|
// frames, which a healthy container will not do on demand. DO NOT COPY THIS
|
|
// PATTERN — anything a real server can produce belongs in describeWithContainer.
|
|
// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline
|
|
// Buffer.alloc frame construction here.
|
|
//
|
|
// MySQLRequestQueue.clean() iterated the live queue while running reject
|
|
// callbacks. rejectWithJSValue() runs JS via event_loop.runCallback(), whose
|
|
// exit() drains microtasks when the outer entered_event_loop_count is 0.
|
|
// User code reachable from that drain can call MySQLConnection.close(),
|
|
// which re-enters clean(). The inner call deref()'d + discard()'d the same
|
|
// requests out from under the outer loop; when the outer loop resumed it
|
|
// called LinearFifo.discard(1) on an empty fifo (debug assert -> panic) and
|
|
// deref() on an already-deref'd request (release -> double free / UAF).
|
|
|
|
import { expect, test } from "bun:test";
|
|
import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness";
|
|
import path from "node:path";
|
|
|
|
// Absolute path so the spawned fixture (which lives in a temp dir) can import
|
|
// the shared frame builders instead of inlining Buffer construction.
|
|
const wireFrames = path.join(import.meta.dir, "wire-frames.ts");
|
|
|
|
// The failure mode is a debug assert in LinearFifo.discard() (and a UAF under
|
|
// ASAN); in release builds the underflow is UB and may not crash, so only run
|
|
// where it is observable.
|
|
test.skipIf(!isDebug && !isASAN)(
|
|
"MySQL: clean() is safe when reject callback re-enters via connection.close()",
|
|
async () => {
|
|
using dir = tempDir("mysql-clean-reentry", {
|
|
"fixture.ts": /* js */ `
|
|
import net from "node:net";
|
|
import { SQL } from "bun";
|
|
import { mysqlHandshakeV10, mysqlOkPacket } from ${JSON.stringify(wireFrames)};
|
|
|
|
let socketRef;
|
|
const server = net.createServer(socket => {
|
|
socketRef = socket;
|
|
let buffered = Buffer.alloc(0), authed = false;
|
|
socket.write(mysqlHandshakeV10());
|
|
socket.on("data", chunk => {
|
|
buffered = Buffer.concat([buffered, chunk]);
|
|
while (buffered.length >= 4) {
|
|
const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16);
|
|
if (buffered.length < 4 + len) break;
|
|
const seq = buffered[3];
|
|
buffered = buffered.subarray(4 + len);
|
|
if (!authed) { authed = true; socket.write(mysqlOkPacket(seq + 1)); }
|
|
// Never respond to queries -> they stay in the native request queue.
|
|
}
|
|
});
|
|
socket.on("error", () => {});
|
|
});
|
|
|
|
server.listen(0, "127.0.0.1");
|
|
await new Promise(r => server.on("listening", r));
|
|
const { port } = server.address();
|
|
|
|
const sql = new SQL({ url: \`mysql://[email protected]:\${port}/db\`, max: 1 });
|
|
|
|
// Obtain the native MySQLConnection (and observe when every query has
|
|
// actually been enqueued natively) by shadowing each query handle's
|
|
// .run(connection, query) with an own-property before the pool invokes it.
|
|
let nativeConnection;
|
|
let runCount = 0;
|
|
let protoRun;
|
|
const queries = [sql\`select 0\`, sql\`select 1\`, sql\`select 2\`, sql\`select 3\`, sql\`select 4\`];
|
|
for (const q of queries) {
|
|
q.values(); // force lazy creation of the native MySQLQuery handle
|
|
const handleSym = Object.getOwnPropertySymbols(q).find(s => s.description === "handle");
|
|
const handle = q[handleSym];
|
|
protoRun ??= Object.getPrototypeOf(handle).run;
|
|
Object.defineProperty(handle, "run", {
|
|
configurable: true,
|
|
writable: true,
|
|
value(connection, query) {
|
|
nativeConnection = connection;
|
|
runCount++;
|
|
return protoRun.call(this, connection, query);
|
|
},
|
|
});
|
|
}
|
|
|
|
const settled = queries.map(q =>
|
|
q.catch(err => {
|
|
// Re-enter clean() synchronously: this runs in the microtask drain that
|
|
// follows each rejectWithJSValue() call inside the outer clean().
|
|
try { nativeConnection?.close(); } catch {}
|
|
return err?.code ?? String(err);
|
|
})
|
|
);
|
|
|
|
// Wait until the pool has handed the native connection to every query
|
|
// (requires real event-loop ticks, not just microtasks).
|
|
while (runCount < queries.length) await new Promise(r => setImmediate(r));
|
|
|
|
// Replace the pool's JS onclose handler with a no-op so it does not
|
|
// pre-emptively reject the queries before native clean() runs. That leaves
|
|
// failWithJSValue's deferred clean() to do the rejecting at elc==0, where
|
|
// runCallback.exit() drains microtasks between requests and lets the
|
|
// .catch() handlers above re-enter clean().
|
|
nativeConnection.onclose = () => {};
|
|
|
|
// Drop the socket -> onClose -> failWithJSValue -> defer cleanQueueAndClose
|
|
// -> MySQLRequestQueue.clean() with all 5 requests still pending.
|
|
socketRef.destroy();
|
|
|
|
const codes = await Promise.all(settled);
|
|
|
|
// Yield to the event loop so the native clean() stack frame that invoked
|
|
// our .catch() handlers resumes and finishes (this is where the old code
|
|
// would discard(1) on the now-empty fifo and panic).
|
|
await new Promise(r => setImmediate(r));
|
|
|
|
console.log("ok", JSON.stringify(codes));
|
|
process.exit(0);
|
|
`,
|
|
});
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "fixture.ts"],
|
|
env: {
|
|
...bunEnv,
|
|
// A crash here writes a multi-GB core dump that outlives the default
|
|
// test timeout; the stderr panic trace is the useful signal.
|
|
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1",
|
|
BUN_ENABLE_CRASH_REPORTING: "0",
|
|
},
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 60_000,
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stderr).toBe("");
|
|
expect(stdout).toStartWith("ok ");
|
|
// Every pending query must have been rejected exactly once.
|
|
const codes = JSON.parse(stdout.slice(3));
|
|
expect(codes).toEqual([
|
|
"ERR_MYSQL_CONNECTION_CLOSED",
|
|
"ERR_MYSQL_CONNECTION_CLOSED",
|
|
"ERR_MYSQL_CONNECTION_CLOSED",
|
|
"ERR_MYSQL_CONNECTION_CLOSED",
|
|
"ERR_MYSQL_CONNECTION_CLOSED",
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
},
|
|
);
|