Question about `assumeIsolated` for custom global executor

I am trying to use WebKitGtk from Swift on Linux.

Gtk requires a run loop running. So my idea was, let's create a global actor that runs the run loop and bind all wrapper classes for WebKit to that global actor. So far so good, but when using callback from C I need to convince the compiler that I am indeed on the correct custom executor.

// the global actor

@globalActor 
actor GtkMainActor: GlobalActor {
    static let shared = GtkMainActor.run()

    nonisolated var unownedExecutor: UnownedSerialExecutor { 
        self.executor.asUnownedSerialExecutor()
    }

    let executor: GlibExecutor

    private init(executor: GlibExecutor) {
        self.executor = executor
    }

    static func run() -> Self {
        let gtkQueue = DispatchQueue(label: "gtk-queue")

            gtkQueue.async {
                gtk_init(nil, nil)
                let loop = g_main_loop_new(nil, 0)
                print("Gtk loop created")
                g_main_loop_run(loop)
                print("Gtk loop finished")
                g_main_loop_unref(loop)
            }

        return Self(executor: GlibExecutor(queue: gtkQueue))
    }
}

// the custom executor that dispatches to the run loop
class GlibContext {
    var job: UnownedJob!
    var executor: UnownedSerialExecutor!
}

final class GlibExecutor: SerialExecutor {

    let queue: DispatchQueue

    init(queue: DispatchQueue) {
        self.queue = queue
    }

    func checkIsolated() {
        dispatchPrecondition(condition: .onQueue(queue))
    }
           
    
    func enqueue(_ job: consuming ExecutorJob) {
        let context = GlibContext()
        context.job = UnownedJob(job)
        context.executor = self.asUnownedSerialExecutor()

        let unmanaged = Unmanaged.passRetained(context)

        let job : @convention(c) (UnsafeMutableRawPointer?) -> gboolean = { data in
            let ctx = Unmanaged<GlibContext>.fromOpaque(data!).takeRetainedValue()
            ctx.job!.runSynchronously(on: ctx.executor)
            return G_SOURCE_REMOVE
        }

        g_idle_add(job, unmanaged.toOpaque())
    }

    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        return UnownedSerialExecutor(ordinary: self)
    }
}

Now, webkit uses callbacks, those callbacks must be @convention(c) of course, so I can't make them @GtkMainActor, but obviously they will always be called from that queue/thread.

@GtkMainActor
class WebBrowser {
    var view: UnsafeMutablePointer<WebKitWebView>!
    var loadChangedHandler: gulong = 0

    var onLoadFinished: ((String) -> Void)?

    init() {
        
        self.view = shim_webkit_web_view_new()
        guard let view = self.view else {
            fatalError("Failed to create web view")
        }
        self.loadChangedHandler = shim_webkit_web_view_connect_load_changed(view, loadChanged, Unmanaged.passUnretained(self).toOpaque())
    }
    
    deinit {
        g_signal_handler_disconnect(self.view, self.loadChangedHandler)
        g_object_unref(self.view)
        print("WebBrowser deinit")
    }

    func loadUrl(url: String) {
        url.withCString { cstring in
            webkit_web_view_load_uri(self.view, cstring)
        }
    }
}

class Context {
    let continuation : CheckedContinuation<String, Error>

    init(continuation: CheckedContinuation<String, Error>) {
        self.continuation = continuation
    }
}

func loadChanged(source: UnsafeMutablePointer<WebKitWebView>!, event: WebKitLoadEvent, user_data: UnsafeMutableRawPointer!) {
    let webView = Unmanaged<WebBrowser>.fromOpaque(user_data).takeUnretainedValue()

    switch event {
        case WEBKIT_LOAD_STARTED:
            print("Load started")
        case WEBKIT_LOAD_REDIRECTED:
            print("Load redirected")
        case WEBKIT_LOAD_COMMITTED:
            print("Load committed")
        case WEBKIT_LOAD_FINISHED:
            print("Load finished")
            GtkMainActor.shared.assumeIsolated { actor in
                webView.onLoadFinished?(webView.uri!)
               // !!! Error: Global actor 'GtkMainActor'-isolated 
               // property 'onLoadFinished' can not be referenced 
               // from a nonisolated context
            }
        default: fatalError("Unknown event: \(event)")
    }

}

Here the relevant snippet from the last function:

let webview: WebBrowser = …

GtkMainActor.shared.assumeIsolated { actor in
      webView.onLoadFinished?(webView.uri!)
      // !!! Error: Global actor 'GtkMainActor'-isolated 
      // property 'onLoadFinished' can not be referenced 
      // from a nonisolated context
}

I don't understand why I am getting this error. Am I not telling the compiler: "I know we are on the correct actor, crash at runtime if I am wrong"?

I read through the various proposal around actors and serial executors, but I feel like I am misunderstanding something fundamental here...

In essence this is a missing feature: there is no equivalence between the @MyGlobalActor and the MyGlobalActor.shared statically. So while the isolation checks will pass, the compiler doesn't believe that the shared instance is the same as @MyGlobalActor.

There is no way to declare a generic method on GlobalActor which handles this today since we can't abstract over the @SomeActor.

You'll have to copy paste (or write a macro) that writes this method for you:

extension GtkMainActor {
    static func assumeIsolated<T : Sendable>(
        _ operation: @GtkMainActor () throws -> T
    ) rethrows -> T {
        typealias YesActor = @GtkMainActor () throws -> T
        typealias NoActor = () throws -> T
        
        shared.preconditionIsolated()
        
        // To do the unsafe cast, we have to pretend it's @escaping.
        return try withoutActuallyEscaping(operation) {
            (_ fn: @escaping YesActor) throws -> T in
            let rawFn = unsafeBitCast(fn, to: NoActor.self)
            return try rawFn()
        }
    }
}

this will then work as expected.

This is something we should fix in the concurrency library but we haven't been able to to far.

Here's the github issue to track about it Support @globalActor assumeIsolated Β· Issue #80878 Β· swiftlang/swift Β· GitHub

2 Likes

Got it, thanks! That’s exactly what I came up with yesterday. Would it be possible to extend the existing @globalExecutor annotation/macro to generate this method?