Skip to content

Hang when invoking async code synchronously from a sync context #75866

@wadetregaskis

Description

@wadetregaskis

Description

Sometimes I need to run async code synchronously, such as when an Apple framework API calls my closure (no async support) and requires a result from it, but the code required to retrieve that result is async. In an ideal world this wouldn't occur, but in typical macOS and iOS GUI programming it occurs quite often.

And conceptually this is trivial; indeed in prior paradigms like NSRunloop this sort of pattern was easy to implement, since runloops were re-entrant. There's no such API available to me for Swift Concurrency, as far as I'm aware.

I've almost got it working with Swift Concurrency, without runtime issues and compiler complaints, but I'm stuck on the last hurdle and I'm not sure it's not a compiler / stdlib bug.

Reproduction

import Dispatch

extension Task {
    static func sync(_ code: () async throws(Failure) -> Success) throws(Failure) -> Success {
        let semaphore = DispatchSemaphore(value: 0)

        nonisolated(unsafe) var result: Result<Success, Failure>? = nil

        withoutActuallyEscaping(code) {
            let sendableCode = unsafeBitCast($0, to: (@Sendable () async throws -> Success).self)

            _ = Task<Void, Never>.detached(priority: .userInitiated) { @Sendable () async -> Void in
                do {
                    result = .success(try await sendableCode())
                } catch {
                    result = .failure(error as! Failure)
                }

                semaphore.signal()
            }
    
            semaphore.wait()
        }

        return try result!.get()
    }
}

Expected behavior

Everything runs fine, life moves on.

The actual behaviour is that it just silently hangs at runtime, even though the code compiles without any complaints in Swift 6 mode. The code closure is never actually invoked; the Task's closure seems to just silently hang when trying to call it. There is no evidence of it anywhere in any thread's callstack (the thread running sync is stuck on the semaphore.wait()).

Yet if I mark the code parameter as @Sendable, it works fine. However, then I'm erroneously requiring code to be sendable and it causes design and implementation problems for the caller (mainly re. capture local state, which isn't and shouldn't have to be sendable because there's no concurrency or escaping involved).

My only hypothesis so far is that it's trying to force code to run on the same thread as sync is called on, thus a fairly pedestrian deadlock, but: I don't know why (the code closure isn't intentionally bound to any particular isolation context) and I don't understand how merely making the closure @Sendable somehow changes that. Thus why I'm wondering if this is actually expected behaviour in any sense, or rather a bug.

I tried in vain every possible attribute and modifier I could think of (lots of nonisolated stuff, @_inheritActorContext, etc), even stuff I didn't expect to work merely for completeness, and nothing has helped.

Environment

Xcode 15 beta 5.
swift-driver version: 1.113 Apple Swift version 6.0 (swiftlang-6.0.0.7.6 clang-1600.0.24.1)

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugA deviation from expected or documented behavior. Also: expected but undesirable behavior.triage neededThis issue needs more specific labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions