taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 4a76e437802b9e51cdea6b37db599973f859bbed
parent 38a00b98662986c28f7e54b1f23dc611950bc94d
Author: Marc Stibane <marc@taler.net>
Date:   Fri, 13 Oct 2023 09:08:11 +0200

AsyncSemaphore

Diffstat:
MTalerWallet.xcodeproj/project.pbxproj | 6++++++
ATalerWallet1/Helper/AsyncSemaphore.swift | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 259 insertions(+), 0 deletions(-)

diff --git a/TalerWallet.xcodeproj/project.pbxproj b/TalerWallet.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 4E16E12329F3BB99008B9C86 /* CurrencyFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */; }; 4E2254972A822B8100E41D29 /* payment_received.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4E2254952A822B8100E41D29 /* payment_received.m4a */; }; 4E2254982A822B8100E41D29 /* payment_sent.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4E2254962A822B8100E41D29 /* payment_sent.m4a */; }; + 4E3327BA2AD1635100BF5AD6 /* AsyncSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3327B92AD1635100BF5AD6 /* AsyncSemaphore.swift */; }; + 4E3327BB2AD1635100BF5AD6 /* AsyncSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3327B92AD1635100BF5AD6 /* AsyncSemaphore.swift */; }; 4E363CBC2A237E0900D7E98C /* URL+id+iban.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E363CBB2A237E0900D7E98C /* URL+id+iban.swift */; }; 4E363CBE2A23CB2100D7E98C /* AnyTransition+backslide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */; }; 4E363CC02A24754200D7E98C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 4E363CBF2A24754200D7E98C /* Settings.bundle */; }; @@ -284,6 +286,7 @@ 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrencyFormatter.swift; sourceTree = "<group>"; }; 4E2254952A822B8100E41D29 /* payment_received.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = payment_received.m4a; sourceTree = "<group>"; }; 4E2254962A822B8100E41D29 /* payment_sent.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = payment_sent.m4a; sourceTree = "<group>"; }; + 4E3327B92AD1635100BF5AD6 /* AsyncSemaphore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncSemaphore.swift; sourceTree = "<group>"; }; 4E363CBB2A237E0900D7E98C /* URL+id+iban.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+id+iban.swift"; sourceTree = "<group>"; }; 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyTransition+backslide.swift"; sourceTree = "<group>"; }; 4E363CBF2A24754200D7E98C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; }; @@ -558,6 +561,7 @@ isa = PBXGroup; children = ( 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */, + 4E3327B92AD1635100BF5AD6 /* AsyncSemaphore.swift */, 4EDBDCD82AB787CB00925C02 /* CallStack.swift */, 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */, 4EAD117529F672FA008EDD0B /* KeyboardResponder.swift */, @@ -1029,6 +1033,7 @@ 4E3EAE2A2A990778009F1BE8 /* PendingRowView.swift in Sources */, 4E3EAE2B2A990778009F1BE8 /* LoadingView.swift in Sources */, 4E3EAE8C2AA0933C009F1BE8 /* Font+Taler.swift in Sources */, + 4E3327BA2AD1635100BF5AD6 /* AsyncSemaphore.swift in Sources */, 4E3EAE2C2A990778009F1BE8 /* ManualWithdraw.swift in Sources */, 4E3EAE2D2A990778009F1BE8 /* Model+Exchange.swift in Sources */, 4E3EAE2E2A990778009F1BE8 /* QRCodeDetailView.swift in Sources */, @@ -1131,6 +1136,7 @@ 4EB0955E2989CBFE0043A8A1 /* PendingRowView.swift in Sources */, 4EB0956D2989CBFE0043A8A1 /* LoadingView.swift in Sources */, 4E3EAE8D2AA0933C009F1BE8 /* Font+Taler.swift in Sources */, + 4E3327BB2AD1635100BF5AD6 /* AsyncSemaphore.swift in Sources */, 4E50B3502A1BEE8000F9F01C /* ManualWithdraw.swift in Sources */, 4E3B4BC92A42BC4800CC88B8 /* Model+Exchange.swift in Sources */, 4E5A88F52A38A4FD00072618 /* QRCodeDetailView.swift in Sources */, diff --git a/TalerWallet1/Helper/AsyncSemaphore.swift b/TalerWallet1/Helper/AsyncSemaphore.swift @@ -0,0 +1,253 @@ +// Copyright (C) 2022 Gwendal Roué +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import Foundation + +/// An object that controls access to a resource across multiple execution +/// contexts through use of a traditional counting semaphore. +/// +/// You increment a semaphore count by calling the ``signal()`` method, and +/// decrement a semaphore count by calling ``wait()`` or one of its variants. +/// +/// ## Topics +/// +/// ### Creating a Semaphore +/// +/// - ``init(value:)`` +/// +/// ### Signaling the Semaphore +/// +/// - ``signal()`` +/// +/// ### Waiting for the Semaphore +/// +/// - ``wait()`` +/// - ``waitUnlessCancelled()`` +public final class AsyncSemaphore: @unchecked Sendable { + /// `Suspension` is the state of a task waiting for a signal. + /// + /// It is a class because instance identity helps `waitUnlessCancelled()` + /// deal with both early and late cancellation. + /// + /// We make it @unchecked Sendable in order to prevent compiler warnings: + /// instances are always protected by the semaphore's lock. + private class Suspension: @unchecked Sendable { + enum State { + /// Initial state. Next is suspendedUnlessCancelled, or cancelled. + case pending + + /// Waiting for a signal, with support for cancellation. + case suspendedUnlessCancelled(UnsafeContinuation<Void, Error>) + + /// Waiting for a signal, with no support for cancellation. + case suspended(UnsafeContinuation<Void, Never>) + + /// Cancelled before we have started waiting. + case cancelled + } + + var state: State + + init(state: State) { + self.state = state + } + } + + // MARK: - Internal State + + /// The semaphore value. + private var value: Int + + /// As many elements as there are suspended tasks waiting for a signal. + private var suspensions: [Suspension] = [] + + /// The lock that protects `value` and `suspensions`. + /// + /// It is recursive in order to handle cancellation (see the implementation + /// of ``waitUnlessCancelled()``). + private let _lock = NSRecursiveLock() + + // MARK: - Creating a Semaphore + + /// Creates a semaphore. + /// + /// - parameter value: The starting value for the semaphore. Do not pass a + /// value less than zero. + public init(value: Int) { + precondition(value >= 0, "AsyncSemaphore requires a value equal or greater than zero") + self.value = value + } + + deinit { + precondition(suspensions.isEmpty, "AsyncSemaphore is deallocated while some task(s) are suspended waiting for a signal.") + } + + // MARK: - Locking + + // Let's hide the locking primitive in order to avoid a compiler warning: + // + // > Instance method 'lock' is unavailable from asynchronous contexts; + // > Use async-safe scoped locking instead; this is an error in Swift 6. + // + // We're not sweeping bad stuff under the rug. We really need to protect + // our inner state (`value` and `suspension`) across the calls to + // `withUnsafeContinuation`. Unfortunately, this method introduces a + // suspension point. So we need a lock. + private func lock() { _lock.lock() } + private func unlock() { _lock.unlock() } + + // MARK: - Waiting for the Semaphore + + /// Waits for, or decrements, a semaphore. + /// + /// Decrement the counting semaphore. If the resulting value is less than + /// zero, this function suspends the current task until a signal occurs, + /// without blocking the underlying thread. Otherwise, no suspension happens. + public func wait() async { + lock() + + value -= 1 + if value >= 0 { + unlock() + return + } + + await withUnsafeContinuation { continuation in + // Register the continuation that `signal` will resume. + let suspension = Suspension(state: .suspended(continuation)) + suspensions.insert(suspension, at: 0) // FIFO + unlock() + } + } + + /// Waits for, or decrements, a semaphore, with support for cancellation. + /// + /// Decrement the counting semaphore. If the resulting value is less than + /// zero, this function suspends the current task until a signal occurs, + /// without blocking the underlying thread. Otherwise, no suspension happens. + /// + /// If the task is canceled before a signal occurs, this function + /// throws `CancellationError`. + public func waitUnlessCancelled() async throws { + lock() + + value -= 1 + if value >= 0 { + defer { unlock() } + + do { + // All code paths check for cancellation + try Task.checkCancellation() + } catch { + // Cancellation is like a signal: we don't really "consume" + // the semaphore, and restore the value. + value += 1 + throw error + } + + return + } + + // Get ready for being suspended waiting for a continuation, or for + // early cancellation. + let suspension = Suspension(state: .pending) + + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation<Void, Error>) in + if case .cancelled = suspension.state { + // Early cancellation: waitUnlessCancelled() is called from + // a cancelled task, and the `onCancel` closure below + // has marked the suspension as cancelled. + // Resume with a CancellationError. + unlock() + continuation.resume(throwing: CancellationError()) + } else { + // Current task is not cancelled: register the continuation + // that `signal` will resume. + suspension.state = .suspendedUnlessCancelled(continuation) + suspensions.insert(suspension, at: 0) // FIFO + unlock() + } + } + } onCancel: { + // withTaskCancellationHandler may immediately call this block (if + // the current task is cancelled), or call it later (if the task is + // cancelled later). In the first case, we're still holding the lock, + // waiting for the continuation. In the second case, we do not hold + // the lock. Being able to handle both situations is the reason why + // we use a recursive lock. + lock() + + // We're no longer waiting for a signal + value += 1 + if let index = suspensions.firstIndex(where: { $0 === suspension }) { + suspensions.remove(at: index) + } + + if case let .suspendedUnlessCancelled(continuation) = suspension.state { + // Late cancellation: the task is cancelled while waiting + // from the semaphore. Resume with a CancellationError. + unlock() + continuation.resume(throwing: CancellationError()) + } else { + // Early cancellation: waitUnlessCancelled() is called from + // a cancelled task. + // + // The next step is the `withTaskCancellationHandler` + // operation closure right above. + suspension.state = .cancelled + unlock() + } + } + } + + // MARK: - Signaling the Semaphore + + /// Signals (increments) a semaphore. + /// + /// Increment the counting semaphore. If the previous value was less than + /// zero, this function resumes a task currently suspended in ``wait()`` + /// or ``waitUnlessCancelled()``. + /// + /// - returns: This function returns true if a suspended task is + /// resumed. Otherwise, the result is false, meaning that no task was + /// waiting for the semaphore. + @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: + unlock() + return false + } + } +}