Along the lines of move-only types, I've taken to doing something like this to address this problem:
public final class Cancellable<Output: Sendable>: Sendable {
private let _cancel: @Sendable () -> Void
private let _isCancelled: @Sendable () -> Bool
private let _value: @Sendable () async throws -> Output
private let _result: @Sendable () async -> Result<Output, Swift.Error>
public var isCancelled: Bool { _isCancelled() }
public var value: Output { get async throws { try await _value() } }
public var result: Result<Output, Swift.Error> { get async { await _result() } }
@Sendable public func cancel() -> Void { _cancel() }
@Sendable public func cancelAndAwaitValue() async throws -> Output {
_cancel(); return try await _value()
}
@Sendable public func cancelAndAwaitResult() async throws -> Result<Output, Swift.Error> {
_cancel(); return await _result()
}
init(
cancel: @escaping @Sendable () -> Void,
isCancelled: @escaping @Sendable () -> Bool,
value: @escaping @Sendable () async throws -> Output,
result: @escaping @Sendable () async -> Result<Output, Swift.Error>
) {
_cancel = cancel
_isCancelled = isCancelled
_value = value
_result = result
}
deinit { if !self.isCancelled { self.cancel() } }
}
public extension Cancellable {
convenience init(task: Task<Output, Swift.Error>) {
self.init(
cancel: { task.cancel() },
isCancelled: { task.isCancelled },
value: { try await task.value },
result: { await task.result }
)
}
}
As long as a child cancellable does not escape its parent's closure, cancelling or losing the reference to the parent's Cancellable will result in cancellation of the parent and all descendants. The rationale here is that I was spinning up unstructured Tasks and forgetting to cancel them at times and AFAICT there is no good way to debug this. Basically I stole the idea from Combine.