From ecb375a771a9db6133162834773d6936c014073e Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 16 Jun 2026 15:16:28 -0700 Subject: [PATCH 1/5] postgres: release the speculative query ref on every do_run error exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit do_run takes a speculative this.ref_() before dispatching. The simple-query execute_query failure path correctly released it (release_statement + deref) before throwing, but three other error exits did not: - statements.get_or_put failure (allocator) - writer.write(SYNC) failure - final requests.write_item failure (extended-protocol) Each leaked the query ref (and the latter two also leaked the statement ref). The omissions were inherited from the Zig original. Same error is thrown at the same point; the query/statement just no longer leak. Extracted the cleanup into two closures applied at all 8 error-return sites for consistency. Net -29 lines. These are allocator-failure / write-failure paths only — not JS-observable on the success path. From #31664 (closed). --- src/sql_jsc/postgres/PostgresSQLQuery.rs | 101 ++++++++--------------- 1 file changed, 36 insertions(+), 65 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index c2c3b45768b6..815952aff23f 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -501,6 +501,26 @@ impl PostgresSQLQuery { let writer = connection.writer(); // We need a strong reference to the query so that it doesn't get GC'd this.ref_(); + // Shared cleanup for every error-return path below: drop any statement + // ref this query took plus the speculative `ref_()` above. + let release_query_ref = || { + this.release_statement(); + // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. + unsafe { Self::deref(this_ptr) }; + }; + // Shared error tail: throw `err` as a postgres error unless an exception + // is already pending. + let throw_write_error = |msg: &[u8], err: AnyPostgresError| -> JsError { + if !global_object.has_exception() { + return global_object.throw_value(postgres_error_to_js( + global_object, + Some(msg), + err, + )); + } + JsError::Thrown + }; + if this.flags.get().simple { bun_core::scoped_log!(Postgres, "executeQuery"); @@ -520,20 +540,8 @@ impl PostgresSQLQuery { let can_execute = !connection.has_query_running(); if can_execute { if let Err(err) = PostgresRequest::execute_query(query_str.slice(), writer) { - // fail to run do cleanup — sole owner just created above - // (rc=1); `release_statement` decrements → 0 frees. - this.release_statement(); - // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. - unsafe { Self::deref(this_ptr) }; - - if !global_object.has_exception() { - return Err(global_object.throw_value(postgres_error_to_js( - global_object, - Some(b"failed to execute query"), - err, - ))); - } - return Err(JsError::Thrown); + release_query_ref(); + return Err(throw_write_error(b"failed to execute query", err)); } { let mut f = connection.flags.get(); @@ -552,12 +560,7 @@ impl PostgresSQLQuery { .with_mut(|q| q.write_item(this_ptr)) .is_err() { - // fail to run do cleanup — sole owner just created above - // (rc=1); `release_statement` decrements → 0 frees. - this.release_statement(); - // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. - unsafe { Self::deref(this_ptr) }; - + release_query_ref(); return Err(global_object.throw_out_of_memory()); } @@ -630,6 +633,7 @@ impl PostgresSQLQuery { Ok(v) => v, Err(err) => { drop(signature); + release_query_ref(); return Err( global_object.throw_error(err.into(), "failed to allocate statement") ); @@ -670,21 +674,11 @@ impl PostgresSQLQuery { columns_value, writer, ) { - // fail to run do cleanup — drop the ref we took above. - this.release_statement(); - // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. - unsafe { Self::deref(this_ptr) }; - - if !global_object.has_exception() { - return Err(global_object.throw_value( - postgres_error_to_js( - global_object, - Some(b"failed to bind and execute query"), - err, - ), - )); - } - return Err(JsError::Thrown); + release_query_ref(); + return Err(throw_write_error( + b"failed to bind and execute query", + err, + )); } { let mut f = connection.flags.get(); @@ -726,17 +720,8 @@ impl PostgresSQLQuery { .with_mut(|m| m.remove(&signature_hash)); } drop(signature); - this.release_statement(); - // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. - unsafe { Self::deref(this_ptr) }; - if !global_object.has_exception() { - return Err(global_object.throw_value(postgres_error_to_js( - global_object, - Some(b"failed to prepare and query"), - err, - ))); - } - return Err(JsError::Thrown); + release_query_ref(); + return Err(throw_write_error(b"failed to prepare and query", err)); } { let mut f = connection.flags.get(); @@ -767,17 +752,8 @@ impl PostgresSQLQuery { .with_mut(|m| m.remove(&signature_hash)); } drop(signature); - this.release_statement(); - // SAFETY: undoes the speculative `this.ref_()` above; count was ≥2, never frees here. - unsafe { Self::deref(this_ptr) }; - if !global_object.has_exception() { - return Err(global_object.throw_value(postgres_error_to_js( - global_object, - Some(b"failed to write query"), - err, - ))); - } - return Err(JsError::Thrown); + release_query_ref(); + return Err(throw_write_error(b"failed to write query", err)); } if let Err(err) = writer.write(&protocol::SYNC) { if connection_entry_value.is_some() { @@ -786,14 +762,8 @@ impl PostgresSQLQuery { .with_mut(|m| m.remove(&signature_hash)); } drop(signature); - if !global_object.has_exception() { - return Err(global_object.throw_value(postgres_error_to_js( - global_object, - Some(b"failed to flush"), - err, - ))); - } - return Err(JsError::Thrown); + release_query_ref(); + return Err(throw_write_error(b"failed to flush", err)); } { let mut f = connection.flags.get(); @@ -854,6 +824,7 @@ impl PostgresSQLQuery { .with_mut(|q| q.write_item(this_ptr)) .is_err() { + release_query_ref(); return Err(global_object.throw_out_of_memory()); } this.this_value.with_mut(|r| r.upgrade(global_object)); From 8fe4fd279e9839bb8ebcb65e20a783bbff370e14 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 17 Jun 2026 11:47:40 -0700 Subject: [PATCH 2/5] postgres: ref poll_ref only after do_run enqueues the request --- src/sql_jsc/postgres/PostgresSQLQuery.rs | 28 ++++++--- .../sql-postgres-run-error-pollref-fixture.ts | 58 +++++++++++++++++++ .../sql-postgres-run-error-pollref.test.ts | 32 ++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 test/js/sql/sql-postgres-run-error-pollref-fixture.ts create mode 100644 test/js/sql/sql-postgres-run-error-pollref.test.ts diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index 815952aff23f..1564973a2cfc 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -479,15 +479,6 @@ impl PostgresSQLQuery { }; let connection: &PostgresSQLConnection = &connection; - // `KeepAlive::ref_` takes an `EventLoopCtx` (manual vtable in `bun_io`), not a - // `*mut VirtualMachine`. `global_object.bun_vm()` and `get_vm_ctx(.Js)` both - // resolve to the same singleton JS VM, so route through the global hook — - // identical to `PostgresSQLConnection::vm_ctx`. - connection.poll_ref.with_mut(|r| { - r.ref_(bun_io::posix_event_loop::get_vm_ctx( - bun_io::AllocatorType::Js, - )) - }); let query = arguments[1]; if !query.is_object() { @@ -564,6 +555,16 @@ impl PostgresSQLQuery { return Err(global_object.throw_out_of_memory()); } + // Request is enqueued: keep the event loop alive until the server + // responds. KeepAlive is a flag (not a count), so taking this any + // earlier would leave it stuck Active on the synchronous-error + // returns above. + connection.poll_ref.with_mut(|r| { + r.ref_(bun_io::posix_event_loop::get_vm_ctx( + bun_io::AllocatorType::Js, + )) + }); + this.this_value.with_mut(|r| r.upgrade(global_object)); js::target_set_cached(this_value, global_object, query); if this.status.get() == Status::Running { @@ -827,6 +828,15 @@ impl PostgresSQLQuery { release_query_ref(); return Err(global_object.throw_out_of_memory()); } + // Request is enqueued: keep the event loop alive until the server + // responds. See the matching call in the simple-query branch above + // for why this must come after every fallible step. + connection.poll_ref.with_mut(|r| { + r.ref_(bun_io::posix_event_loop::get_vm_ctx( + bun_io::AllocatorType::Js, + )) + }); + this.this_value.with_mut(|r| r.upgrade(global_object)); js::target_set_cached(this_value, global_object, query); diff --git a/test/js/sql/sql-postgres-run-error-pollref-fixture.ts b/test/js/sql/sql-postgres-run-error-pollref-fixture.ts new file mode 100644 index 000000000000..d6a32696f2b2 --- /dev/null +++ b/test/js/sql/sql-postgres-run-error-pollref-fixture.ts @@ -0,0 +1,58 @@ +// After the connection reaches Connected with no in-flight requests the +// poll_ref keepalive is Inactive. PostgresSQLQuery.do_run used to ref it +// before any validation; when the run then failed synchronously (here: a +// boxed Boolean binding is rejected by the Postgres type mapper inside +// Signature::generate) none of the error returns unref'd it, so the event +// loop stayed pinned and the process hung. +// +// The fixture prints "rejected:" once the query has been rejected, +// unrefs the mock-server handles and then falls through. With no pending +// work it must exit on its own (exit code 0, no signal). + +import net from "node:net"; + +function pkt(type: string, body: Buffer): Buffer { + const header = Buffer.alloc(5); + header.write(type, 0); + header.writeInt32BE(body.length + 4, 1); + return Buffer.concat([header, body]); +} + +const authenticationOk = pkt("R", Buffer.from([0, 0, 0, 0])); +const readyForQuery = pkt("Z", Buffer.from("I")); + +const server = net.createServer(socket => { + socket.unref(); + let startup = true; + socket.on("data", () => { + if (startup) { + startup = false; + socket.write(Buffer.concat([authenticationOk, readyForQuery])); + } + }); +}); +await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); +server.unref(); +const port = (server.address() as net.AddressInfo).port; + +const sql = new Bun.SQL({ + url: `postgres://u@127.0.0.1:${port}/db`, + max: 1, + idleTimeout: 0, + maxLifetime: 0, + connectionTimeout: 30, +}); + +await sql.connect(); + +// sql.connect() resolves from the onconnect microtask, which runs inside the +// native on_data handler. That handler unconditionally re-derives poll_ref +// from the request queue right before returning, so the leak is only visible +// once do_run runs on a later turn where no on_data epilogue follows it. +await new Promise(resolve => setImmediate(resolve)); + +// new Boolean(...) is a cell whose JSType is BooleanObject; the Postgres +// binding type mapper rejects it synchronously inside Signature::generate, +// so run() throws before the request is ever enqueued. +const err = await sql`SELECT ${new Boolean(true)}`.catch(e => e); +console.log("rejected:" + (err?.code ?? err?.name ?? String(err))); diff --git a/test/js/sql/sql-postgres-run-error-pollref.test.ts b/test/js/sql/sql-postgres-run-error-pollref.test.ts new file mode 100644 index 000000000000..2828584ff620 --- /dev/null +++ b/test/js/sql/sql-postgres-run-error-pollref.test.ts @@ -0,0 +1,32 @@ +// PostgresSQLQuery.do_run refs the connection's poll_ref KeepAlive. KeepAlive +// is a two-state flag, not a counter, so when this query is the only in-flight +// work the call flips Inactive -> Active. When do_run then returns early with +// a synchronous error (bad binding, signature-generation failure, OOM during +// enqueue, ...) the poll_ref must not be left Active: nothing else on the +// connection will touch it until the next server message, so the event loop +// stays pinned and the process never exits. +// +// The fixture connects to a mock server, lets the connection go idle, then +// issues a query whose binding is rejected synchronously before anything is +// written. It must print the rejection and exit on its own. + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import path from "node:path"; + +test("postgres: synchronous do_run failure does not pin the event loop", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-run-error-pollref-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + void stderr; + + expect(stdout).toBe("rejected:ERR_INVALID_ARG_TYPE\n"); + // exited on its own, not killed by the runner's timeout + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); From 7d0f2e8de06268518170936d5c79a2a981663cf6 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 17 Jun 2026 12:21:33 -0700 Subject: [PATCH 3/5] test(sql): use real postgres container for the poll_ref regression test --- .../sql-postgres-run-error-pollref-fixture.ts | 34 +++--------------- .../sql-postgres-run-error-pollref.test.ts | 35 +++++++++++-------- 2 files changed, 24 insertions(+), 45 deletions(-) diff --git a/test/js/sql/sql-postgres-run-error-pollref-fixture.ts b/test/js/sql/sql-postgres-run-error-pollref-fixture.ts index d6a32696f2b2..40cdcbad77e4 100644 --- a/test/js/sql/sql-postgres-run-error-pollref-fixture.ts +++ b/test/js/sql/sql-postgres-run-error-pollref-fixture.ts @@ -5,38 +5,12 @@ // Signature::generate) none of the error returns unref'd it, so the event // loop stayed pinned and the process hung. // -// The fixture prints "rejected:" once the query has been rejected, -// unrefs the mock-server handles and then falls through. With no pending -// work it must exit on its own (exit code 0, no signal). - -import net from "node:net"; - -function pkt(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} - -const authenticationOk = pkt("R", Buffer.from([0, 0, 0, 0])); -const readyForQuery = pkt("Z", Buffer.from("I")); - -const server = net.createServer(socket => { - socket.unref(); - let startup = true; - socket.on("data", () => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - } - }); -}); -await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); -server.unref(); -const port = (server.address() as net.AddressInfo).port; +// The fixture prints "rejected:" once the query has been rejected and +// then falls through. With no pending work it must exit on its own (exit +// code 0, no signal). const sql = new Bun.SQL({ - url: `postgres://u@127.0.0.1:${port}/db`, + url: process.env.DATABASE_URL!, max: 1, idleTimeout: 0, maxLifetime: 0, diff --git a/test/js/sql/sql-postgres-run-error-pollref.test.ts b/test/js/sql/sql-postgres-run-error-pollref.test.ts index 2828584ff620..0eafdd266a7d 100644 --- a/test/js/sql/sql-postgres-run-error-pollref.test.ts +++ b/test/js/sql/sql-postgres-run-error-pollref.test.ts @@ -6,27 +6,32 @@ // connection will touch it until the next server message, so the event loop // stays pinned and the process never exits. // -// The fixture connects to a mock server, lets the connection go idle, then +// The fixture connects to a real Postgres, lets the connection go idle, then // issues a query whose binding is rejected synchronously before anything is // written. It must print the rejection and exit on its own. import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, describeWithContainer } from "harness"; import path from "node:path"; -test("postgres: synchronous do_run failure does not pin the event loop", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-run-error-pollref-fixture.ts")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); +describeWithContainer("postgres", { image: "postgres_plain" }, container => { + test("postgres: synchronous do_run failure does not pin the event loop", async () => { + await container.ready; + const url = `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; + + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-run-error-pollref-fixture.ts")], + env: { ...bunEnv, DATABASE_URL: url }, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - void stderr; + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + void stderr; - expect(stdout).toBe("rejected:ERR_INVALID_ARG_TYPE\n"); - // exited on its own, not killed by the runner's timeout - expect(proc.signalCode).toBeNull(); - expect(exitCode).toBe(0); + expect(stdout).toBe("rejected:ERR_INVALID_ARG_TYPE\n"); + // exited on its own, not killed by the runner's timeout + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }); }); From df97fee047b959df8f890c7ff2c1a4a0f60b73d2 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 17 Jun 2026 12:45:31 -0700 Subject: [PATCH 4/5] test(sql): surface stderr in the poll_ref fixture failure diff --- test/js/sql/sql-postgres-run-error-pollref.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/js/sql/sql-postgres-run-error-pollref.test.ts b/test/js/sql/sql-postgres-run-error-pollref.test.ts index 0eafdd266a7d..4a1cf7f39dba 100644 --- a/test/js/sql/sql-postgres-run-error-pollref.test.ts +++ b/test/js/sql/sql-postgres-run-error-pollref.test.ts @@ -27,11 +27,15 @@ describeWithContainer("postgres", { image: "postgres_plain" }, container => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - void stderr; - expect(stdout).toBe("rejected:ERR_INVALID_ARG_TYPE\n"); - // exited on its own, not killed by the runner's timeout - expect(proc.signalCode).toBeNull(); - expect(exitCode).toBe(0); + // signalCode null = exited on its own, not killed by the runner's timeout. + // stderr is matched as any(String) so ASAN/debug noise doesn't flake it but + // its actual content still shows up in the failure diff. + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "rejected:ERR_INVALID_ARG_TYPE\n", + stderr: expect.any(String), + exitCode: 0, + signalCode: null, + }); }); }); From f2b76baf21d6c248fe522d398a44a480b3db0cd0 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 17 Jun 2026 13:22:37 -0700 Subject: [PATCH 5/5] test(sql): move the poll_ref regression into sql-onconnect-onclose-throw --- .../sql/sql-onconnect-onclose-throw.test.ts | 31 ++++++++++++++ .../sql-postgres-run-error-pollref-fixture.ts | 32 --------------- .../sql-postgres-run-error-pollref.test.ts | 41 ------------------- 3 files changed, 31 insertions(+), 73 deletions(-) delete mode 100644 test/js/sql/sql-postgres-run-error-pollref-fixture.ts delete mode 100644 test/js/sql/sql-postgres-run-error-pollref.test.ts diff --git a/test/js/sql/sql-onconnect-onclose-throw.test.ts b/test/js/sql/sql-onconnect-onclose-throw.test.ts index 175b32e6dc89..790f5cd9d307 100644 --- a/test/js/sql/sql-onconnect-onclose-throw.test.ts +++ b/test/js/sql/sql-onconnect-onclose-throw.test.ts @@ -69,6 +69,37 @@ if (isDockerEnabled()) { expect(stdout).toBe('query: [{"x":1}]\nonclose: Connection closed\nuncaught: boom from onclose\nended\n'); expect(exitCode).toBe(0); }); + + // PostgresSQLQuery.do_run refs the connection's poll_ref KeepAlive (a + // two-state flag, not a counter). When do_run returns early with a + // synchronous error before enqueueing — here a boxed Boolean binding + // rejected inside Signature::generate — the poll_ref must not be left + // Active, or the event loop stays pinned and the process never exits. The + // setImmediate forces do_run onto a later turn so on_data's epilogue + // doesn't mask the leak. + test("a synchronous do_run failure does not pin the event loop", async () => { + await container.ready; + const url = `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; + const fixture = /* ts */ ` +const sql = new Bun.SQL({ + url: process.env.FIXTURE_URL, + max: 1, + idleTimeout: 0, + maxLifetime: 0, + connectionTimeout: 30, +}); +await sql.connect(); +await new Promise(r => setImmediate(r)); +const err = await sql\`SELECT \${new Boolean(true)}\`.catch(e => e); +console.log("rejected:" + (err?.code ?? err?.name ?? String(err))); +`; + const { stdout, stderr, exitCode } = await runFixture(fixture, { FIXTURE_URL: url }); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "rejected:ERR_INVALID_ARG_TYPE\n", + stderr: expect.any(String), + exitCode: 0, + }); + }); }); describeWithContainer("mysql", { image: "mysql_plain" }, container => { diff --git a/test/js/sql/sql-postgres-run-error-pollref-fixture.ts b/test/js/sql/sql-postgres-run-error-pollref-fixture.ts deleted file mode 100644 index 40cdcbad77e4..000000000000 --- a/test/js/sql/sql-postgres-run-error-pollref-fixture.ts +++ /dev/null @@ -1,32 +0,0 @@ -// After the connection reaches Connected with no in-flight requests the -// poll_ref keepalive is Inactive. PostgresSQLQuery.do_run used to ref it -// before any validation; when the run then failed synchronously (here: a -// boxed Boolean binding is rejected by the Postgres type mapper inside -// Signature::generate) none of the error returns unref'd it, so the event -// loop stayed pinned and the process hung. -// -// The fixture prints "rejected:" once the query has been rejected and -// then falls through. With no pending work it must exit on its own (exit -// code 0, no signal). - -const sql = new Bun.SQL({ - url: process.env.DATABASE_URL!, - max: 1, - idleTimeout: 0, - maxLifetime: 0, - connectionTimeout: 30, -}); - -await sql.connect(); - -// sql.connect() resolves from the onconnect microtask, which runs inside the -// native on_data handler. That handler unconditionally re-derives poll_ref -// from the request queue right before returning, so the leak is only visible -// once do_run runs on a later turn where no on_data epilogue follows it. -await new Promise(resolve => setImmediate(resolve)); - -// new Boolean(...) is a cell whose JSType is BooleanObject; the Postgres -// binding type mapper rejects it synchronously inside Signature::generate, -// so run() throws before the request is ever enqueued. -const err = await sql`SELECT ${new Boolean(true)}`.catch(e => e); -console.log("rejected:" + (err?.code ?? err?.name ?? String(err))); diff --git a/test/js/sql/sql-postgres-run-error-pollref.test.ts b/test/js/sql/sql-postgres-run-error-pollref.test.ts deleted file mode 100644 index 4a1cf7f39dba..000000000000 --- a/test/js/sql/sql-postgres-run-error-pollref.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// PostgresSQLQuery.do_run refs the connection's poll_ref KeepAlive. KeepAlive -// is a two-state flag, not a counter, so when this query is the only in-flight -// work the call flips Inactive -> Active. When do_run then returns early with -// a synchronous error (bad binding, signature-generation failure, OOM during -// enqueue, ...) the poll_ref must not be left Active: nothing else on the -// connection will touch it until the next server message, so the event loop -// stays pinned and the process never exits. -// -// The fixture connects to a real Postgres, lets the connection go idle, then -// issues a query whose binding is rejected synchronously before anything is -// written. It must print the rejection and exit on its own. - -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, describeWithContainer } from "harness"; -import path from "node:path"; - -describeWithContainer("postgres", { image: "postgres_plain" }, container => { - test("postgres: synchronous do_run failure does not pin the event loop", async () => { - await container.ready; - const url = `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; - - await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-run-error-pollref-fixture.ts")], - env: { ...bunEnv, DATABASE_URL: url }, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - // signalCode null = exited on its own, not killed by the runner's timeout. - // stderr is matched as any(String) so ASAN/debug noise doesn't flake it but - // its actual content still shows up in the failure diff. - expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ - stdout: "rejected:ERR_INVALID_ARG_TYPE\n", - stderr: expect.any(String), - exitCode: 0, - signalCode: null, - }); - }); -});