From 5e8d218662be2165cce151faf07609379ee131ad Mon Sep 17 00:00:00 2001 From: Shuu Date: Sat, 15 Aug 2026 01:38:01 +0900 Subject: [PATCH] fix(async_backend): drain orphaned futures on wait timeout under asyncdispatch --- async_postgres/async_backend.nim | 23 ++++++++++++++++++++--- tests/test_async_backend.nim | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/async_postgres/async_backend.nim b/async_postgres/async_backend.nim index 12e36ac..1c19bbd 100644 --- a/async_postgres/async_backend.nim +++ b/async_postgres/async_backend.nim @@ -159,6 +159,20 @@ elif hasAsyncDispatch: proc `<`*(a, b: Moment): bool = a.ticks < b.ticks + proc drainOrphan[T](fut: Future[T]) {.gcsafe.} = + ## Default ``onOrphan``: drain the orphan's outcome so a late failure is + ## read and cleared, and cannot resurface. Callers that need resource + ## cleanup pass their own ``onOrphan``, which replaces this default. + if fut.failed: + discard fut.error + fut.error = nil + fut.errorStackTrace = "" + elif fut.finished: + when T is void: + fut.read() + else: + discard fut.read() + proc wait*[T]( fut: Future[T], timeout: Duration, onOrphan: proc(fut: Future[T]) {.gcsafe.} = nil ): Future[T] {.async.} = @@ -170,9 +184,10 @@ elif hasAsyncDispatch: ## future keeps running in the background until its I/O completes. This ## ``onOrphan`` callback is registered on the inner future and called once ## that orphan eventually completes. Use it to close a live connection or - ## release other resources the orphan holds. Without it the orphan produces - ## an unhandled ``FutureCompleted`` warning at best and a leaked socket / - ## server slot at worst. + ## release other resources the orphan holds. When ``onOrphan`` is omitted + ## the default handler drains the orphan's outcome (clears a late failure) + ## but does **not** release other resources. Pass an explicit ``onOrphan`` + ## when the orphan owns a connection or other live resource. ## ## Under chronos the ``onOrphan`` argument is accepted but never called — ## futures are properly cancelled on timeout and no orphan remains. @@ -183,6 +198,8 @@ elif hasAsyncDispatch: proc() = if not onOrphan.isNil: onOrphan(fut) + else: + drainOrphan(fut) ) raise newException(AsyncTimeoutError, "Timeout") when T is void: diff --git a/tests/test_async_backend.nim b/tests/test_async_backend.nim index bef2603..e12b67f 100644 --- a/tests/test_async_backend.nim +++ b/tests/test_async_backend.nim @@ -129,6 +129,34 @@ suite "wait": ) check not orphanCalled + test "default onOrphan drains a failing orphan": + # The default drain reads and clears the orphan's late error so it + # cannot resurface. This test fails without the drain. + proc boom(): Future[void] {.async.} = + await sleepMsAsync(20) + raise newException(ValueError, "boom") + + let fut = boom() + expect AsyncTimeoutError: + waitFor wait(fut, milliseconds(1)) + waitFor sleepMsAsync(50) + check fut.finished + check not fut.failed + check fut.error == nil + + test "default onOrphan drains a completing orphan with a value": + # Exercises the non-void branch of the default drain. + proc delayed(): Future[int] {.async.} = + await sleepMsAsync(20) + return 42 + + let fut = delayed() + expect AsyncTimeoutError: + discard waitFor wait(fut, milliseconds(1)) + waitFor sleepMsAsync(50) + check fut.finished + check not fut.failed + when hasChronos: test "the inner future is cancelled on timeout": let fut = sleepAsync(milliseconds(30))