Skip to content

Commit 2b04bf8

Browse files
committed
fix(pool): bound each step of returning a connection to the pool
Fixes #4349. `Floating::return_to_pool` holds the pool's `DecrementSizeGuard` while it does I/O on the connection: an `after_release` hook, a `ping()` to check the connection is still usable, or a graceful `close()`. None of those were bounded. That matters because the I/O can be on a socket that is dead in a way the socket cannot report. If the server disappeared without closing the connection, and the client has nothing left to retransmit, no RST is ever provoked and reads never complete. The returning task then holds its permit forever and the pool shrinks by one connection. After `max_connections` of those, every `acquire()` fails with `PoolTimedOut` and the pool never recovers, even though the server is back. Each step now gets `RETURN_TO_POOL_TIMEOUT` (5s, matching the existing `CLOSE_ON_DROP_TIMEOUT`), and on expiry the connection is dropped via `close_hard()`, which does no I/O. Cancelling `close()` likewise drops the connection and releases the permit. The regression test uses an `after_release` hook that never completes, which is a deterministic stand-in for a socket that never answers: before this change it exhausts `acquire_timeout`, after it the pool is usable again once the bound expires. I realise #3582 reworks this area and includes a timeout of its own; this is meant for main until that lands.
1 parent 1d674f5 commit 2b04bf8

2 files changed

Lines changed: 86 additions & 21 deletions

File tree

sqlx-core/src/pool/connection.rs

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ use crate::pool::options::PoolConnectionMetadata;
1515

1616
const CLOSE_ON_DROP_TIMEOUT: Duration = Duration::from_secs(5);
1717

18+
/// Bounds each step of returning a connection to the pool.
19+
///
20+
/// Every step here does I/O on a connection that may be dead in a way the socket
21+
/// cannot report: if the server disappeared without closing it, and the client has
22+
/// nothing left to retransmit, reads never complete. The returning task holds the
23+
/// pool's `DecrementSizeGuard` for as long as it runs, so an unbounded step leaks a
24+
/// permit and the pool shrinks by one connection every time it happens.
25+
const RETURN_TO_POOL_TIMEOUT: Duration = Duration::from_secs(5);
26+
1827
/// A connection managed by a [`Pool`][crate::pool::Pool].
1928
///
2029
/// Will be returned to the pool on-drop.
@@ -275,32 +284,40 @@ impl<DB: Database> Floating<DB, Live<DB>> {
275284
async fn return_to_pool(mut self) -> bool {
276285
// Immediately close the connection.
277286
if self.guard.pool.is_closed() {
278-
self.close().await;
287+
self.close_bounded().await;
279288
return false;
280289
}
281290

282291
// If the connection is beyond max lifetime, close the connection and
283292
// immediately create a new connection
284293
if is_beyond_max_lifetime(&self.inner, &self.guard.pool.options) {
285-
self.close().await;
294+
self.close_bounded().await;
286295
return false;
287296
}
288297

289298
if let Some(test) = &self.guard.pool.options.after_release {
290299
let meta = self.metadata();
291-
match (test)(&mut self.inner.raw, meta).await {
292-
Ok(true) => (),
293-
Ok(false) => {
294-
self.close().await;
300+
let result =
301+
crate::rt::timeout(RETURN_TO_POOL_TIMEOUT, (test)(&mut self.inner.raw, meta)).await;
302+
303+
match result {
304+
Ok(Ok(true)) => (),
305+
Ok(Ok(false)) => {
306+
self.close_bounded().await;
295307
return false;
296308
}
297-
Err(error) => {
309+
Ok(Err(error)) => {
298310
tracing::warn!(%error, "error from `after_release`");
299311
// Connection is broken, don't try to gracefully close as
300312
// something weird might happen.
301313
self.close_hard().await;
302314
return false;
303315
}
316+
Err(_) => {
317+
tracing::warn!("timed out in `after_release`; discarding the connection");
318+
self.close_hard().await;
319+
return false;
320+
}
304321
}
305322
}
306323

@@ -311,22 +328,42 @@ impl<DB: Database> Floating<DB, Live<DB>> {
311328
// returned to the pool; also of course, if it was dropped due to an error
312329
// this is simply a band-aid as SQLx-next connections should be able
313330
// to recover from cancellations
314-
if let Err(error) = self.raw.ping().await {
315-
tracing::warn!(
316-
%error,
317-
"error occurred while testing the connection on-release",
318-
);
319-
320-
// Connection is broken, don't try to gracefully close.
321-
self.close_hard().await;
322-
false
323-
} else {
324-
// if the connection is still viable, release it to the pool
325-
self.release();
326-
true
331+
let result = crate::rt::timeout(RETURN_TO_POOL_TIMEOUT, self.raw.ping()).await;
332+
333+
match result {
334+
Ok(Ok(())) => {
335+
// if the connection is still viable, release it to the pool
336+
self.release();
337+
true
338+
}
339+
Ok(Err(error)) => {
340+
tracing::warn!(
341+
%error,
342+
"error occurred while testing the connection on-release",
343+
);
344+
345+
// Connection is broken, don't try to gracefully close.
346+
self.close_hard().await;
347+
false
348+
}
349+
Err(_) => {
350+
tracing::warn!("timed out while testing the connection on-release; discarding it",);
351+
352+
// The socket is not answering; dropping it is the only way to get the
353+
// pool permit back.
354+
self.close_hard().await;
355+
false
356+
}
327357
}
328358
}
329359

360+
/// Close the connection, giving up on a graceful close if it does not complete
361+
/// promptly. Cancelling `close()` still drops the connection and releases the
362+
/// pool permit, which is the outcome that matters here.
363+
async fn close_bounded(self) {
364+
let _ = crate::rt::timeout(RETURN_TO_POOL_TIMEOUT, self.close()).await;
365+
}
366+
330367
pub async fn close(self) {
331368
// This isn't used anywhere that we care about the return value
332369
let _ = self.inner.raw.close().await;

tests/postgres/postgres.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use sqlx_test::{new, pool, setup_if_needed};
1212
use std::env;
1313
use std::pin::{pin, Pin};
1414
use std::sync::Arc;
15-
use std::time::Duration;
15+
use std::time::{Duration, Instant};
1616

1717
#[sqlx_macros::test]
1818
async fn it_connects() -> anyhow::Result<()> {
@@ -321,6 +321,34 @@ async fn it_can_fail_and_recover() -> anyhow::Result<()> {
321321
Ok(())
322322
}
323323

324+
/// A hook that never completes must not hold the pool permit forever: returning a
325+
/// connection to the pool does I/O on a socket that may be dead in a way it cannot
326+
/// report, so each step is bounded and the connection is discarded on timeout.
327+
#[sqlx_macros::test]
328+
async fn pool_recovers_from_a_hanging_after_release() -> anyhow::Result<()> {
329+
setup_if_needed();
330+
331+
let pool = PgPoolOptions::new()
332+
.max_connections(1)
333+
.acquire_timeout(Duration::from_secs(30))
334+
.after_release(|_conn, _meta| Box::pin(std::future::pending()))
335+
.connect(&env::var("DATABASE_URL")?)
336+
.await?;
337+
338+
let conn = pool.acquire().await?;
339+
drop(conn);
340+
341+
// The permit comes back once the bounded return-to-pool gives up on the hook.
342+
let started_at = Instant::now();
343+
let _conn = pool.acquire().await?;
344+
assert!(
345+
started_at.elapsed() < Duration::from_secs(30),
346+
"acquire waited on a connection whose return never finished",
347+
);
348+
349+
Ok(())
350+
}
351+
324352
#[sqlx_macros::test]
325353
async fn it_can_fail_and_recover_with_pool() -> anyhow::Result<()> {
326354
let pool = sqlx_test::pool::<Postgres>().await?;

0 commit comments

Comments
 (0)