You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
sql.end() never settles when a reservation is taken or released around the
shutdown, against a database that is fully reachable throughout.
Two orderings, one symptom. In both, the reservation itself settles and the
shutdown does not.
A reservation taken in the same tick as the shutdown.sql.end() yields
once before it does anything, so a sql.reserve() issued in the same tick
reaches the pool first and takes a connection the shutdown has already
counted. The reservation resolves; sql.end() is still pending after 20
seconds.
A reservation held across the shutdown and released afterwards. sql.end() is registered while the reservation is held, and release() is
called half a second later. The release returns the connection to the pool
rather than closing it, so the shutdown is never completed by it. sql.end()
is still pending after 20 seconds.
Neither is affected by the work on #164: both reproduce identically on main
and on the branch of #222. They are recorded here because a shutdown audit on
that branch enumerated them, and they are the remaining ways this client hands
back a promise it will never settle.
scripts/migrate.ts and scripts/reconcile.ts both await closeSql() in the finally of their entry points, and src/lib/db/postgres-store.ts reserves a
coordination connection per repository reconciliation, so a shutdown that
overlaps a reservation leaves those processes unable to exit.
Reproduced 2 of 2 in each ordering, on each build.
Expected Behavior
sql.end() settles whatever a reservation does around it. A reservation taken
after the shutdown has begun is refused, as an ordinary query in that position
already is; a reservation released after the shutdown has begun closes its
connection rather than returning it to a pool that is shutting down.
Save the probe below beside a node_modules that resolves postgres to a
checkout's install, and run both modes:
node probe-reserve-holes.mjs same-tick 20000
node probe-reserve-holes.mjs released 20000
// Two ways a reserve() leaves sql.end() itself permanently pending, against a// REACHABLE server. The observation windows exist so the probe terminates; they// are not assertions of a timing margin.importnetfrom"node:net";importfsfrom"node:fs";importpathfrom"node:path";import{fileURLToPath}from"node:url";importpostgresfrom"postgres";consthere=path.dirname(fileURLToPath(import.meta.url));constPG_PORT=Number(process.env.PG_PORT||32802);constMODE=process.argv[2]??"same-tick";constWINDOW_MS=Number(process.argv[3]??20000);constt0=Date.now();constlog=(...a)=>console.log(`+${String(Date.now()-t0).padStart(6)}ms`, ...a);// A plain forwarder, so the server is genuinely reachable throughout.constsrv=net.createServer((c)=>{c.on("error",()=>{});constu=net.connect(PG_PORT,"127.0.0.1");u.on("error",()=>{});c.on("data",d=>u.write(d));u.on("data",d=>c.write(d));c.on("close",()=>u.destroy());u.on("close",()=>c.end());});awaitnewPromise(r=>srv.listen(0,"127.0.0.1",r));constport=srv.address().port;constsql=postgres({host: "127.0.0.1", port,database: "probe",user: "probe",password: "probe",max: 2});awaitsql`select 1 as ok`;// the pool is known live before anything is shut downlog("warm-up query ok");letendSettled=null,reserveSettled=null;if(MODE==="same-tick"){sql.end().then(()=>{endSettled=Date.now();log("sql.end(): RESOLVED");},e=>{endSettled=Date.now();log("sql.end(): REJECTED "+e.message);});sql.reserve().then(()=>{reserveSettled=Date.now();log("reserve(): RESOLVED");},e=>{reserveSettled=Date.now();log("reserve(): REJECTED "+e.message);});log("end() and reserve() issued in the same tick");}else{constr=awaitsql.reserve();log("reserve() resolved and held");sql.end().then(()=>{endSettled=Date.now();log("sql.end(): RESOLVED");},e=>{endSettled=Date.now();log("sql.end(): REJECTED "+e.message);});awaitnewPromise(x=>setTimeout(x,500));r.release();log("release() called");reserveSettled=Date.now();}constdeadline=Date.now()+WINDOW_MS;while(Date.now()<deadline&&!endSettled)awaitnewPromise(x=>setTimeout(x,200));console.log(`RESULT mode=${MODE} end=${endSettled ? "settled" : `PENDING>${WINDOW_MS}ms`} `+`reserve=${reserveSettled ? "settled" : `PENDING>${WINDOW_MS}ms`}`);process.exit(0);
+ 80ms warm-up query ok
+ 80ms end() and reserve() issued in the same tick
+ 81ms reserve(): RESOLVED
RESULT mode=same-tick end=PENDING>20000ms reserve=settled
+ 45ms warm-up query ok
+ 46ms reserve() resolved and held
+ 548ms release() called
RESULT mode=released end=PENDING>20000ms reserve=settled
RESULT build=PRE mode=same-tick end=PENDING>20000ms reserve=settled
RESULT build=PRE mode=released end=PENDING>20000ms reserve=settled
Environment / Context
Node 24.17.0, Linux, postgres:17-alpine for the server.
postgres 3.4.9 with patches/postgres@3.4.9.patch applied, as installed by pnpm install --frozen-lockfile.
The client is built with library defaults except max: 2.
Discovered During
A shutdown-uniformity audit on the branch of #222, which fixes #164. Enumerated
by an adversary agent and reproduced independently by the session lead, against
both builds, with the runs quoted above.
Suggested Fix
Unverified, and the two orderings need different repairs even though they share
a cause: nothing on the reservation path consults the pool's shutdown state.
For the same-tick ordering, reserve() pushes into the pool's queries
without checking ending, where the ordinary query path in handler() already
refuses. Note that #222 adds that check for the post-shutdown case; it does
not help here, because a same-tick reservation wins the await 1 at the top of end() and so arrives before ending is assigned.
For the release ordering, the patched release() in the package's src/index.js clears the reservation and calls onopen(c), which returns the
connection to the pool. Every other route that finishes with a connection goes
through ending ? terminate() : onopen(connection); this one does not.
Description
sql.end()never settles when a reservation is taken or released around theshutdown, against a database that is fully reachable throughout.
Two orderings, one symptom. In both, the reservation itself settles and the
shutdown does not.
sql.end()yieldsonce before it does anything, so a
sql.reserve()issued in the same tickreaches the pool first and takes a connection the shutdown has already
counted. The reservation resolves;
sql.end()is still pending after 20seconds.
sql.end()is registered while the reservation is held, andrelease()iscalled half a second later. The release returns the connection to the pool
rather than closing it, so the shutdown is never completed by it.
sql.end()is still pending after 20 seconds.
Neither is affected by the work on #164: both reproduce identically on
mainand on the branch of #222. They are recorded here because a shutdown audit on
that branch enumerated them, and they are the remaining ways this client hands
back a promise it will never settle.
scripts/migrate.tsandscripts/reconcile.tsboth awaitcloseSql()in thefinallyof their entry points, andsrc/lib/db/postgres-store.tsreserves acoordination connection per repository reconciliation, so a shutdown that
overlaps a reservation leaves those processes unable to exit.
Reproduced 2 of 2 in each ordering, on each build.
Expected Behavior
sql.end()settles whatever a reservation does around it. A reservation takenafter the shutdown has begun is refused, as an ordinary query in that position
already is; a reservation released after the shutdown has begun closes its
connection rather than returning it to a pool that is shutting down.
Reproduction Steps
Start a reachable database:
Save the probe below beside a
node_modulesthat resolvespostgresto acheckout's install, and run both modes:
Observed on the branch of #222 (
9682e6a):mainat0b306d8, re-runningpnpm install --frozen-lockfilefirst so the pre-fix(patch): settle and space a connect-phase socket death #222 patch is installed. Identical:
Environment / Context
postgres:17-alpinefor the server.postgres3.4.9 withpatches/postgres@3.4.9.patchapplied, as installed bypnpm install --frozen-lockfile.max: 2.Discovered During
A shutdown-uniformity audit on the branch of #222, which fixes #164. Enumerated
by an adversary agent and reproduced independently by the session lead, against
both builds, with the runs quoted above.
Suggested Fix
Unverified, and the two orderings need different repairs even though they share
a cause: nothing on the reservation path consults the pool's shutdown state.
For the same-tick ordering,
reserve()pushes into the pool'squerieswithout checking
ending, where the ordinary query path inhandler()alreadyrefuses. Note that #222 adds that check for the post-shutdown case; it does
not help here, because a same-tick reservation wins the
await 1at the top ofend()and so arrives beforeendingis assigned.For the release ordering, the patched
release()in the package'ssrc/index.jsclears the reservation and callsonopen(c), which returns theconnection to the pool. Every other route that finishes with a connection goes
through
ending ? terminate() : onopen(connection); this one does not.