Skip to content

Commit f9c5c22

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 f9c5c22

File tree

1 file changed

+130
-7
lines changed

1 file changed

+130
-7
lines changed

Sources/Subprocess/Configuration.swift

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

0 commit comments

Comments
 (0)