commit 666387dd465ebfc651137433d54ec0a837b2f566 parent d35cc0b39011f4efa1ae2668ef944ca3c6d0b494 Author: Marc Stibane <marc@taler.net> Date: Thu, 20 Aug 2026 19:34:28 +0200 AI: cancellation can double-resume Diffstat:
| M | TalerCommon/Helper/AsyncSemaphore.swift | | | 34 | ++++++++++++++++++++++------------ |
1 file changed, 22 insertions(+), 12 deletions(-)
diff --git a/TalerCommon/Helper/AsyncSemaphore.swift b/TalerCommon/Helper/AsyncSemaphore.swift @@ -234,21 +234,31 @@ public final class AsyncSemaphore: @unchecked Sendable { @discardableResult public func signal() -> Bool { lock() - + value += 1 - - switch suspensions.popLast()?.state { // FIFO - case let .suspendedUnlessCancelled(continuation): - unlock() - continuation.resume() - return true - case let .suspended(continuation): - unlock() - continuation.resume() - return true - default: + + guard let suspension = suspensions.popLast() else { // FIFO unlock() return false } + switch suspension.state { + case let .suspendedUnlessCancelled(continuation): + // Mark this suspension as spent *before* unlocking. Otherwise, if + // the waiting task is cancelled concurrently, its `onCancel` + // handler (TalerCommon/Helper/AsyncSemaphore.swift) would still + // see `.suspendedUnlessCancelled` on `suspension.state` (we never + // mutated it) and resume `continuation` a second time. + suspension.state = .cancelled + unlock() + continuation.resume() + return true + case let .suspended(continuation): + unlock() + continuation.resume() + return true + default: + unlock() + return false + } } }