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.

1 Like

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

3 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?