fix(pool): bound the on-release ping so a dead connection can't leak its permit#4350
Conversation
…its permit When a PoolConnection is dropped, the pool spawns a task that pings the connection before returning it to the idle queue. If the peer has gone away silently (no RST/FIN — e.g. a NAT/firewall dropped the flow), that ping is sent successfully and then waits forever for a response that never arrives. Because the ping runs inside the drop-spawned task, the unbounded wait strands that task's DecrementSizeGuard, permanently leaking the connection's pool permit. Once this has happened max_connections times, every subsequent acquire() fails with PoolTimedOut and the pool never recovers. Bound the on-release ping with a timeout, mirroring the existing close-on-drop path (CLOSE_ON_DROP_TIMEOUT). On timeout the connection is discarded via close_hard() (a local socket shutdown that cannot block), which releases the permit so the pool can open a replacement. Adds a regression test that drives a real PgPool against an in-process fake server which completes the handshake then goes silent; it needs no DATABASE_URL and runs on the tokio runtime cells.
|
Sorry about the noise, agent went a bit wild, I reigned it in and rewrote / went over this comment by hand. We hit the same failure in production: a server restart during a query execution causes the query to hang forever. We noticed this because we have a ~ hot loop that is constantly hitting postgres, so we have a high chance of having an in-flight query where the timing of an upstream restart causes it to hang. A gap found working on #4354 (now closed in favor of this PR):
The The fix is mechanical: give each the same bound and fall back to async fn close_bounded(self) {
let _ = crate::rt::timeout(RETURN_TO_POOL_PING_TIMEOUT, self.close()).await;
} |
Follow-up to the previous commit (transact-rs#4350), which bounds the on-release `ping()`. `return_to_pool` holds the pool permit across two more unbounded awaits on the same connection: - the `after_release` hook, which typically runs a query on the socket that may be dead; - `close()` on the pool-closed and beyond-`max_lifetime` paths. `close()` writes `Terminate` and waits for the peer to drop the connection, which a silent peer never does. We set `max_lifetime`, so the second path runs for every aged-out connection and leaks its permit exactly like the ping case. Offered upstream in transact-rs#4350 (comment).
Follow-up to the previous commit (transact-rs#4350), which bounds the on-release `ping()`. `return_to_pool` holds the pool permit across two more unbounded awaits on the same connection: - the `after_release` hook, which typically runs a query on the socket that may be dead; - `close()` on the pool-closed and beyond-`max_lifetime` paths. `close()` writes `Terminate` and waits for the peer to drop the connection, which a silent peer never does. We set `max_lifetime`, so the second path runs for every aged-out connection and would leak its permit exactly like the ping case. Offered upstream in transact-rs#4350 (comment).
Fixes #4349.
Problem
When a
PoolConnectionis dropped, the pool spawns a task (return_to_pool) thatpings the connection before returning it to the idle queue. If the peer has gone away silently — no RST/FIN, e.g. a NAT/firewall expired the flow, or a query was abandoned viatokio::time::timeout— the ping request is written successfully and then the read waits forever for a response that never comes.Because that wait happens inside the drop-spawned task, it strands the task's
DecrementSizeGuard, so the connection's pool permit is never released. Once this has happenedmax_connectionstimes, every subsequentacquire()fails withPoolTimedOutand the pool never recovers without a process restart. There are no error logs, because nothing errors — the future simply never completes.#4349 has the full write-up and a standalone reproduction.
Fix
Bound the on-release
ping()with a timeout, following the pattern already used for theclose_on_droppath (CLOSE_ON_DROP_TIMEOUTintake_and_close). On timeout the connection is treated as unresponsive and discarded withclose_hard()— a local socket shutdown that cannot itself block on the dead peer — which releases the permit so the pool can open a replacement. No public API change; the bound is a siblingconstof the existing one (5s).The
close_on_droppath was already bounded this way; this closes the same gap on the default return-to-pool path.Test
tests/postgres/pool.rsdrives a realPgPoolagainst an in-process fake server that completes the startup handshake and then goes silent (keeps reading, never replies) — the exact state of a silently-dropped flow. It checks out a connection, drops it (arming the on-release ping against the silent peer), and asserts the nextacquire()succeeds by opening a fresh connection rather than failing withPoolTimedOut.DATABASE_URL(connects only to its own local listener), so it runs in CI.required-features = ["postgres", "runtime-tokio"](it uses a tokio fake server), and skipped under the other runtime matrix cells.PoolTimedOutatacquire_timeout— verified locally by reverting the fix.Notes for reviewers
CLOSE_ON_DROP_TIMEOUTrather than introduce a config knob; happy to make it configurable or derive it fromacquire_timeoutinstead if you'd prefer.close_hard()(socket shutdown only). If you'd rather also guardclose_hard/closeagainst a hypothetically-blocking shutdown, I can wrap those too, but they don't await the peer today.This change was developed with AI assistance; I've reviewed it end-to-end and can speak to the design and trade-offs.