Skip to content

Commit 326b01c

Browse files
committed
withAsyncTaskCleanupHandler calls its cleanup handler twice if body throws
If the body closure given to withAsyncTaskCleanupHandler throws, its cleanup handler is called twice: once in the catch block where body is called, and then again in the task group task once the Task.sleep throws due to cancellation, which swallows the error and then continues to call the handler as well. That results in the teardown sequence being invoked twice, as well as the teardown sequence being invoked for non-error cases. This patch ensures the cleanup handler is invoked in failure cases only, and only once. Closes #80
1 parent 80bd50f commit 326b01c

File tree

1 file changed

+132
-7
lines changed

1 file changed

+132
-7
lines changed

Sources/Subprocess/Configuration.swift

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -752,31 +752,156 @@ extension Optional where Wrapped == String {
752752
}
753753
}
754754

755-
internal func withAsyncTaskCleanupHandler<Result>(
756-
_ body: () async throws -> Result,
755+
internal func withAsyncTaskCleanupHandler<Success>(
756+
_ body: () async throws -> Success,
757757
onCleanup handler: @Sendable @escaping () async -> Void,
758758
isolation: isolated (any Actor)? = #isolation
759-
) async rethrows -> Result {
759+
) async rethrows -> Success {
760+
let runCancellationHandlerPromise = Promise<Bool, Never>()
760761
return try await withThrowingTaskGroup(
761762
of: Void.self,
762-
returning: Result.self
763+
returning: Success.self
763764
) { group in
764765
group.addTask {
765766
// Keep this task sleep indefinitely until the parent task is cancelled.
766767
// `Task.sleep` throws `CancellationError` when the task is canceled
767768
// before the time ends. We then run the cancel handler.
768769
do { while true { try await Task.sleep(nanoseconds: 1_000_000_000) } } catch {}
769770
// Run task cancel handler
770-
await handler()
771+
runCancellationHandlerPromise.fulfill(with: true)
772+
}
773+
774+
group.addTask {
775+
if await runCancellationHandlerPromise.value {
776+
await handler()
777+
}
778+
}
779+
780+
defer {
781+
group.cancelAll()
771782
}
772783

773784
do {
774785
let result = try await body()
775-
group.cancelAll()
786+
runCancellationHandlerPromise.fulfill(with: false)
776787
return result
777788
} catch {
778-
await handler()
789+
runCancellationHandlerPromise.fulfill(with: true)
779790
throw error
780791
}
781792
}
782793
}
794+
795+
#if canImport(Darwin)
796+
import os
797+
#else
798+
import Synchronization
799+
#endif
800+
801+
/// Represents a placeholder for a value which will be provided by synchronous code running in another execution context.
802+
internal final class Promise<Success: Sendable, Failure: Swift.Error>: Sendable {
803+
private struct State {
804+
var waiters: [CheckedContinuation<Success, Failure>] = []
805+
var value: Result<Success, Failure>?
806+
}
807+
808+
#if canImport(Darwin)
809+
private let state: OSAllocatedUnfairLock<State>
810+
#else
811+
private let state: Mutex<State>
812+
#endif
813+
814+
/// Creates a new promise in the unfulfilled state.
815+
public init() {
816+
#if canImport(Darwin)
817+
state = OSAllocatedUnfairLock(initialState: State(value: nil))
818+
#else
819+
state = Mutex(State(value: nil))
820+
#endif
821+
}
822+
823+
deinit {
824+
state.withLock { state in
825+
precondition(state.waiters.isEmpty, "Deallocated with remaining waiters")
826+
}
827+
}
828+
829+
/// Fulfills the promise with the specified result.
830+
///
831+
/// - returns: Whether the promise was already fulfilled.
832+
@discardableResult
833+
public func fulfill(with result: Result<Success, Failure>) -> Bool {
834+
let (waiters, alreadyFulfilled): ([CheckedContinuation<Success, Failure>], Bool) = state.withLock { state in
835+
if state.value != nil {
836+
return ([], true)
837+
}
838+
state.value = result
839+
let waiters = state.waiters
840+
state.waiters.removeAll()
841+
return (waiters, false)
842+
}
843+
844+
// Resume the continuations outside the lock to avoid potential deadlock if invoked in a cancellation handler.
845+
for waiter in waiters {
846+
waiter.resume(with: result)
847+
}
848+
849+
return alreadyFulfilled
850+
}
851+
852+
/// Fulfills the promise with the specified value.
853+
///
854+
/// - returns: Whether the promise was already fulfilled.
855+
@discardableResult
856+
public func fulfill(with value: Success) -> Bool {
857+
fulfill(with: .success(value))
858+
}
859+
860+
/// Fulfills the promise with the specified error.
861+
///
862+
/// - returns: Whether the promise was already fulfilled.
863+
@discardableResult
864+
public func fail(throwing error: Failure) -> Bool {
865+
fulfill(with: .failure(error))
866+
}
867+
}
868+
869+
extension Promise where Success == Void {
870+
/// Fulfills the promise.
871+
///
872+
/// - returns: Whether the promise was already fulfilled.
873+
@discardableResult
874+
internal func fulfill() -> Bool {
875+
fulfill(with: ())
876+
}
877+
}
878+
879+
extension Promise where Failure == Never {
880+
/// Suspends if the promise is not yet fulfilled, and returns the value once it is.
881+
internal var value: Success {
882+
get async {
883+
await withCheckedContinuation { continuation in
884+
let value: Result<Success, Never>? = state.withLock { state in
885+
if let value = state.value {
886+
return value
887+
} else {
888+
state.waiters.append(continuation)
889+
return nil
890+
}
891+
}
892+
893+
// Resume the continuations outside the lock to avoid potential deadlock if invoked in a cancellation handler.
894+
if let value {
895+
continuation.resume(with: value)
896+
}
897+
}
898+
}
899+
}
900+
901+
/// Suspends if the promise is not yet fulfilled, and returns the result once it is.
902+
internal var result: Result<Success, Failure> {
903+
get async {
904+
await .success(value)
905+
}
906+
}
907+
}

0 commit comments

Comments
 (0)