Files

117 lines
4.5 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// 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.
import { SQL } from "bun";
import { heapStats } from "bun:jsc";
import { expect, test } from "bun:test";
import { listeningServer, pgAuthenticationOk, pgReadyForQuery, pgSSLResponse } from "./wire-frames";
// PostgresSQLConnection.deinit() must free the per-connection SSL SocketContext
// (tls_ctx). Previously it freed tls_config but leaked tls_ctx, so every
// TLS-enabled Postgres connection that reached deinit() leaked the SSL_CTX
// plus the uSockets context wrapper. In addition, updateHasPendingActivity()
// never dropped to zero for `.failed` connections (and the socket-close path
// overwrites `.disconnected` -> `.failed`), so the JS wrapper was kept alive
// forever and deinit() was never reached — leaking the entire native
// connection (tls_ctx included) on every close.
//
// This test doesn't need a real Postgres server: the tls_ctx is allocated up
// front in PostgresSQLConnection.call() as soon as sslmode != disable, before
// any TLS handshake. A minimal mock server refuses SSL ('N') but then
// immediately sends AuthenticationOk + ReadyForQuery so the client reaches
// `.connected`, letting close() -> disconnect() -> GC -> finalize() ->
// deinit() exercise the teardown path.
async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise<number> {
Bun.gc(true);
let count = heapStats().objectTypeCounts["PostgresSQLConnection"] || 0;
// Use wall-clock time — Bun.gc(true) under ASAN can take >100ms per call,
// so a fixed-iteration loop would wildly overshoot maxWait.
const deadline = performance.now() + maxWait;
while (count > 2 && performance.now() < deadline) {
await Bun.sleep(20);
Bun.gc(true);
count = heapStats().objectTypeCounts["PostgresSQLConnection"] || 0;
}
return count;
}
test("Postgres connections with sslmode != disable are finalized after close", async () => {
// 'N' (SSL refused) + AuthenticationOk + ReadyForQuery('I')
const handshake = Buffer.concat([pgSSLResponse("N"), pgAuthenticationOk(), pgReadyForQuery("I")]);
const { server, port } = await listeningServer(socket => {
socket.once("data", () => socket.write(handshake));
socket.on("error", () => {});
});
try {
async function once() {
const sql = new SQL({
url: `postgres://[email protected]:${port}/db?sslmode=prefer`,
max: 1,
idleTimeout: 0,
connectionTimeout: 5,
});
try {
await sql.connect();
} catch {}
await sql.close({ timeout: 0 }).catch(() => {});
}
const iterations = 20;
for (let i = 0; i < iterations; i++) {
await once();
}
// Without the fix, hasPendingActivity stays true for every closed
// connection and none of the PostgresSQLConnection wrappers are ever
// collected, so objectTypeCounts["PostgresSQLConnection"] stays at
// `iterations` (plus any baseline). With the fix they are all finalized
// and the count drops to at most a couple still pending finalization.
const remaining = await countPostgresConnectionsAfterGC();
expect(remaining).toBeLessThanOrEqual(2);
} finally {
server.close();
}
}, 60_000);
// Same scenario but with the server refusing SSL while the client requires it,
// so the connection fails before ever reaching `.connected`. Previously these
// failed connections also stayed alive forever via hasPendingActivity.
test("Postgres connections that fail TLS negotiation are finalized", async () => {
const { server, port } = await listeningServer(socket => {
socket.once("data", () => socket.write(pgSSLResponse("N")));
socket.on("error", () => {});
});
try {
async function once() {
const sql = new SQL({
url: `postgres://[email protected]:${port}/db?sslmode=require`,
tls: true,
max: 1,
idleTimeout: 0,
connectionTimeout: 5,
});
try {
await sql`select 1`;
} catch {}
await sql.close({ timeout: 0 }).catch(() => {});
}
const iterations = 20;
for (let i = 0; i < iterations; i++) {
await once();
}
const remaining = await countPostgresConnectionsAfterGC();
expect(remaining).toBeLessThanOrEqual(2);
} finally {
server.close();
}
}, 60_000);