Skip to content

Conversation

@DougGregor
Copy link
Member

Replace the use of rethrows and #isolation in
withTaskCancellationHandler with typed throws and
nonisolated(nonsending), respectively.

Fixes rdar://146901428.

…askCancellationHandler

Replace the use of rethrows and #isolation in
withTaskCancellationHandler with typed throws and
nonisolated(nonsending), respectively.

Fixes rdar://146901428.
@DougGregor DougGregor requested a review from ktoso as a code owner November 14, 2025 22:47
@DougGregor
Copy link
Member Author

@swift-ci please smoke test

@DougGregor DougGregor enabled auto-merge November 14, 2025 23:33
@DougGregor DougGregor requested a review from phausler as a code owner November 15, 2025 00:14
@DougGregor
Copy link
Member Author

@swift-ci please smoke test

public func withTaskCancellationHandler<T, E>(
operation: () async throws(E) -> T,
onCancel handler: @Sendable () -> Void
) async throws(E) -> T {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have any tests that exercise the case described here?

func next(isolation: isolated (any Actor)? = #isolation) async {
    MainActor.assertIsolated() // passes
    await withTaskCancellationHandler {
        MainActor.assertIsolated() // fails!
    } onCancel: { }
}

@MainActor
func callOnMain() async {
  await next()
}

is the outcome in that scenario expected to be different now? or will the same issue occur depending on how the closure isolation is inferred?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been a long standing bug. We always "promised" that these with methods would never lose isolation however this didn't actually work correctly with the isolated parameter...

This is finally bringing the correct behavior to these APIs. All the with methods have the same issue and we'll change all of them to now give the behavior we've always promised to begin with

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe i'm confused as to what issue is being targeted here. my understanding is that the existing signature (prior to a conversion to nonisolated(nonsending)) already inherits the caller's actor isolation (via the defaulted isolated param). so there already should never be an isolation crossing when calling the function. but IIUC closures can be inferred as non-isolated if they are declared in an instance-isolated context but do not capture the isolated parameter.

it seems like this change alone (ignoring typed throws) will not alter the behavior of this API – if the operation closure is inferred to be non-isolated, then it will be seen as an isolation crossing, and introduce a possibly-unexpected executor hop. is the 'full fix' dependent on the NonisolatedNonsendingByDefault feature also being enabled?

Copy link
Contributor

@ktoso ktoso Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that is the (very long standing) bug, the async closure still would hop:

    await withTaskCancellationHandler {
        MainActor.assertIsolated() // fails!

this is very much incorrect and should have never been happening to hop off, but it does. The way closure isolation works just isn't correct here and the "real" solution is to move all these with... APIs to nonisolated nonsending.

it seems like this change alone (ignoring typed throws) will not alter the behavior of this API

It does change behavior; it corrects the unexpected hopping off in certain scenarios -- one of which you've just pasted, that is incorrect and we must fix it; it breaks the safety contract of the withTaskCancellationHandler function

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does change behavior; it corrects the unexpected hopping off in certain scenarios -- one of which you've just pasted, that is incorrect and we must fix it; it breaks the safety contract of the withTaskCancellationHandler function

focusing specifically on the change of the function signature to nonisolated(nonsending) – that does not affect how the operation closure is inferred does it?

admittedly i have not applied these changes locally to experiment, but if you make a function with a similar signature, it does not appear that without the NonisolatedNonsendingByDefault feature enabled the 'unexpected hop' goes away, as the operation closures appear to continue to be inferred to have no isolation. here are some examples demonstrating this: https://swift.godbolt.org/z/chK56d97q.

is that class of examples, the only way in which isolation can be 'lost' or are there others?


anyway, i apologize for the intrusion as this is probably not the appropriate venue to have this sort of discussion. to briefly return to my original motivation – if this change is anticipated to fix a known bug, it seems prudent to have tests that demonstrate the fix works as intended.

@DougGregor
Copy link
Member Author

@swift-ci please smoke test

@DougGregor
Copy link
Member Author

@swift-ci please smoke test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift-foundation#1602

@swift-ci please smoke test Linux

@DougGregor
Copy link
Member Author

swiftlang/swift-docc#1351

@swift-ci please smoke test Windows

3 similar comments
@DougGregor
Copy link
Member Author

swiftlang/swift-docc#1351

@swift-ci please smoke test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift-docc#1351

@swift-ci please smoke test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift-docc#1351

@swift-ci please smoke test Windows

@export(implementation)
nonisolated(nonsending)
public func withTaskCancellationHandler<T, E>(
operation: () async throws(E) -> T,
Copy link
Member

@FranzBusch FranzBusch Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this closure also be nonisolated(nonsending) otherwise when calling it we might need to insert defensive hops?

Suggested change
operation: () async throws(E) -> T,
operation: nonisolated(nonsending) () async throws(E) -> T,

nonisolated(nonsending)
public func withTaskCancellationHandler<T, E>(
operation: () async throws(E) -> T,
onCancel handler: @Sendable () -> Void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this closure be sending since it is only send to the task/thread that cancels this task so it is never executed concurrently itself just concurrent to the caller of the withTaskCancellationHandler.

@available(SwiftStdlib 5.1, *)
@export(implementation)
nonisolated(nonsending)
public func withTaskCancellationHandler<T, E>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Could we give these generic types a better name so that they show up nicely in docs or would that be API breaking?

Suggested change
public func withTaskCancellationHandler<T, E>(
public func withTaskCancellationHandler<Return, Failure: Error>(

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants