Skip to content

Instantly share code, notes, and snippets.

@macguru
Last active October 24, 2025 11:39
Show Gist options
  • Save macguru/d39e78a9a7f3c5a9ba742a6759d700c5 to your computer and use it in GitHub Desktop.
Save macguru/d39e78a9a7f3c5a9ba742a6759d700c5 to your computer and use it in GitHub Desktop.
ScopedTask: A wrapper around Task that auto-cancels when going out of scope.
/// A task that is cancelled if goes out of scope, i.e. the last reference on it has been discarded.
public final class ScopedTask<Success: Sendable, Failure: Error>: Sendable {
/// The underlying task
let wrapped: Task<Success, Failure>
/// Wraps a task into a scoped task.
init(wrapping task: Task<Success, Failure>) {
wrapped = task
}
/// Auto-cancellation on deinit
deinit {
wrapped.cancel()
}
/// Cancels the wrapped task.
public func cancel() {
wrapped.cancel()
}
/// The result or error from the task, after it completes.
public var result: Result<Success, Failure> {
get async {
await wrapped.result
}
}
/// A Boolean value that indicates whether the task should stop executing.
public var isCancelled: Bool {
wrapped.isCancelled
}
}
public extension ScopedTask where Failure == any Error {
/// Initializes a scoped task with a throwing closure
convenience init(
name: String? = nil,
@_inheritActorContext _ operation: sending @escaping @isolated(any) () async throws -> Success
) {
self.init(wrapping: Task(name: name, operation: operation))
}
/// The result from a throwing task, after it completes.
var value: Success {
get async throws {
try await result.get()
}
}
}
public extension ScopedTask where Failure == Never {
/// Initializes a scoped task with a non-throwing closure
convenience init(
name: String? = nil,
@_inheritActorContext _ operation: sending @escaping @isolated(any) () async -> Success
) {
self.init(wrapping: Task(name: name, operation: operation))
}
/// The result from a non-throwing task, after it completes.
var value: Success {
get async {
await result.get()
}
}
}
@mattmassicotte
Copy link

Thanks for sharing this!

Making wrappers around Task is actually surprisingly difficult if you want to match the semantics and runtime behavior exactly. But it is possible! It's just incredibly gross.

convenience init(
	name: String? = nil,
	@_inheritActorContext _ operation: sending @escaping @isolated(any) () async throws -> Success
) {
	self.init(wrapping: Task(name: name, operation: operation))
}

The sending is minor, but definitely more convenient than @Sendable. The @_inheritActorContext makes sure that you get the same static isolation behavior as Task, though I don't think that matters when used with a @Sendable closure.

But, the @isolated(any) is particularly important because, aside from changing the runtime behavior in a subtle way, this code as written will fail to compile when NonisolatedNonsendingByDefault is enabled.

@macguru
Copy link
Author

macguru commented Oct 21, 2025

Wow 😮

I haven't tried it with NonisolatedNonsendingByDefault yet, because that threw up some weird errors everywhere (surprise), so we disabled it again. I see that this works now, awesome 👍

I've updated the sample, of course.

I'm wondering though. I read somewhere that @_inheritActorContext is kinda deprecated and that we should be using an #isolation parameter instead. If I change the signature to this, though, the actor context is not being inherited…

convenience init(
	name: String? = nil,
	isolation: (any Actor)? = #isolation,
	_ operation: sending @escaping @isolated(any) () async -> Success
) {
	self.init(wrapping: Task(name: name, operation: operation))
}

So your solution is certainly the best for now.

@mattmassicotte
Copy link

Yeah, wrapping Task creation, in a synchronous function especially, is so hard to do.

@_inheritActorContext is not the same as an isolated parameter. And while that attribute is considered unsupported, it is not (yet anyways) deprecated because there is no other way to emulate its behavior.

However, in this very specific situation, a side-effect of using an isolated parameter should end up causing this to work the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment