From a9b5f5cbd13c6ffb515e49b2e414c7149d9ecb1d Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 14 Jan 2026 12:53:04 -0800 Subject: [PATCH] fix(sql): prevent hang in sequential MySQL transactions with returned array queries (#26048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix a hang in sequential MySQL transactions where an INSERT is awaited followed by a SELECT returned in an array - The issue occurred because `handleResultSetOK`'s defer block only called `queue.advance()` without flushing, causing queries added during the JS callback to not be properly sent - Changed to call `flushQueue()` instead of just `advance()` to ensure data is actually sent to the server Fixes #26030 ## Test plan - Added regression test `test/regression/issue/26030.test.ts` with three test cases: - `Sequential transactions with INSERT and returned SELECT should not hang` - reproduces the exact pattern from the bug report - `Sequential transactions with returned array of multiple queries` - tests returning multiple queries in array - `Many sequential transactions with awaited INSERT and returned SELECT` - stress tests with 5 sequential transactions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude Opus 4.5 --- src/sql/mysql/MySQLConnection.zig | 10 ++- test/regression/issue/26030.test.ts | 133 ++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 test/regression/issue/26030.test.ts diff --git a/src/sql/mysql/MySQLConnection.zig b/src/sql/mysql/MySQLConnection.zig index 39933e8e3e..5a4fc375db 100644 --- a/src/sql/mysql/MySQLConnection.zig +++ b/src/sql/mysql/MySQLConnection.zig @@ -646,7 +646,7 @@ pub fn handleCommand(this: *MySQLConnection, comptime Context: type, reader: New .failed => { const connection = this.getJSConnection(); defer { - this.queue.advance(connection); + this.flushQueue() catch {}; } this.#flags.is_ready_for_query = true; this.queue.markAsReadyForQuery(); @@ -933,7 +933,11 @@ fn handleResultSetOK(this: *MySQLConnection, request: *JSMySQLQuery, statement: const connection = this.getJSConnection(); debug("handleResultSetOK: {d} {}", .{ status_flags.toInt(), is_last_result }); defer { - this.queue.advance(connection); + // Use flushQueue instead of just advance to ensure any data written + // by queries added during onQueryResult is actually sent. + // This fixes a race condition where the auto flusher may not be + // registered if the queue's current item is completed (not pending). + this.flushQueue() catch {}; } this.#flags.is_ready_for_query = is_last_result; if (is_last_result) { @@ -977,7 +981,7 @@ fn handleResultSet(this: *MySQLConnection, comptime Context: type, reader: NewRe try err.decode(reader); defer err.deinit(); defer { - this.queue.advance(connection); + this.flushQueue() catch {}; } if (request.getStatement()) |statement| { statement.reset(); diff --git a/test/regression/issue/26030.test.ts b/test/regression/issue/26030.test.ts new file mode 100644 index 0000000000..5129b51a33 --- /dev/null +++ b/test/regression/issue/26030.test.ts @@ -0,0 +1,133 @@ +import { SQL, randomUUIDv7 } from "bun"; +import { beforeEach, expect, test } from "bun:test"; +import { describeWithContainer } from "harness"; + +describeWithContainer( + "mysql", + { + image: "mysql_plain", + env: {}, + args: [], + }, + container => { + const getOptions = () => ({ + url: `mysql://root@${container.host}:${container.port}/bun_sql_test`, + max: 1, + bigint: true, + }); + + beforeEach(async () => { + await container.ready; + }); + + // Regression test for https://github.com/oven-sh/bun/issues/26030 + // Bun hangs when executing multiple sequential MySQL transactions in a loop where: + // 1. An INSERT is awaited inside the transaction callback + // 2. A SELECT query (e.g., SELECT LAST_INSERT_ID()) is returned as an array without being awaited + test("Sequential transactions with INSERT and returned SELECT should not hang", async () => { + await using sql = new SQL(getOptions()); + const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase(); + + // Create a table similar to the reproduction case + await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} ( + id INT AUTO_INCREMENT PRIMARY KEY, + contract_name VARCHAR(255), + amount INT + )`; + + try { + const rows = [ + { contract_name: "Contract A", amount: 100000 }, + { contract_name: "Contract B", amount: 200000 }, + { contract_name: "Contract C", amount: 300000 }, + ]; + + const contractIds: number[] = []; + + for (const row of rows) { + // This is the pattern from the bug report: + // - INSERT is awaited + // - SELECT LAST_INSERT_ID() is returned as array (not awaited individually) + const [[result]] = await sql.begin(async tx => { + await tx` + INSERT INTO ${sql(random_name)} (contract_name, amount) + VALUES (${row.contract_name}, ${row.amount}) + `; + // Return array with non-awaited query - this triggers the hang + return [tx`SELECT LAST_INSERT_ID() as id`]; + }); + + contractIds.push(Number(result.id)); + } + + // Verify all transactions completed + expect(contractIds.length).toBe(3); + expect(contractIds[0]).toBe(1); + expect(contractIds[1]).toBe(2); + expect(contractIds[2]).toBe(3); + + // Verify data in database + const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`; + expect(Number(count[0].count)).toBe(3); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(random_name)}`; + } + }); + + test("Sequential transactions with returned array of multiple queries", async () => { + await using sql = new SQL(getOptions()); + const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase(); + + await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} ( + id INT AUTO_INCREMENT PRIMARY KEY, + value INT + )`; + + try { + for (let i = 0; i < 3; i++) { + const results = await sql.begin(async tx => { + await tx`INSERT INTO ${sql(random_name)} (value) VALUES (${i * 10})`; + // Return multiple queries as array + return [tx`SELECT LAST_INSERT_ID() as id`, tx`SELECT COUNT(*) as count FROM ${sql(random_name)}`]; + }); + + expect(results.length).toBe(2); + } + + const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`; + expect(Number(count[0].count)).toBe(3); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(random_name)}`; + } + }); + + test("Many sequential transactions with awaited INSERT and returned SELECT", async () => { + await using sql = new SQL(getOptions()); + const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase(); + + await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) + )`; + + try { + // Multiple sequential transactions with awaited INSERT and returned SELECT + for (let i = 0; i < 5; i++) { + const [[result]] = await sql.begin(async tx => { + // First insert + await tx`INSERT INTO ${sql(random_name)} (name) VALUES (${"item_" + i})`; + // Return array with SELECT + return [tx`SELECT LAST_INSERT_ID() as id`]; + }); + + expect(Number(result.id)).toBe(i + 1); + } + + const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`; + expect(Number(count[0].count)).toBe(5); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(random_name)}`; + } + }); + }, +);