What is a recommended approach for passing non-sendable values to AsyncStream.Continuation.onTermination?

When working with older APIs, I oftentimes wrap them in an AsyncStream to create a nicer API when working with Swift Concurrency. I make use of AsyncStream.Continuation.onTermination to control when to stop the older API from sending updates. This mostly happens with two cases:

  • The API has an explicit "stop" function which must be called.
  • The API is some kind of reference type, which will automatically cancel when dereferenced.

For example, take this function

import AppKit

public enum Keyboard {
    public static func updates() -> AsyncStream<NSEvent.ModifierFlags>? {
        let (stream, continuation) = AsyncStream.makeStream(of: NSEvent.ModifierFlags.self)
        
        let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
            continuation.yield(event.modifierFlags)
            return event
        }

        guard let monitor else { return nil }

        continuation.onTermination = { _ in
            NSEvent.removeMonitor(monitor) // Capture of 'monitor' with non-sendable type 'Any' in a `@Sendable` closure
        }
        
        return stream
    }
}

When trying to cancel the monitor, I will get a warning, because the onTermination closure is Sendable, and the Any type of monitor is not. I was hoping that this would be automagically fixed by enabling Region-based isolation, but this doesn't seem to have any effect.

What would be the recommended approach to tackle this issue?

Something I was wondering too: if the onTermination closure would be transferring (like described in this post), would this fix the warning? My assumption is that the current warning appears because the compiler cannot guarantee that the monitor would not be passed around, which transferring could enforce?

I’m not going to wade into this in the general case, but I’m very concerned about the specific example you posted. Unless otherwise documented AppKit is a main-thread-only framework. AFAIK NSEvent monitors are not documented to be thread safe. Thus, all of that work would need to be done on the main actor.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

3 Likes

Thank you for letting me know!
I cannot find anything about the thread safety of monitors, so I should indeed assume they should only be called on the main actor.

The onTermination function is nonisolated though. I am able to fix all warnings by using the following code

let stopMonitor: @MainActor @Sendable () -> Void = {
    NSEvent.removeMonitor(monitor)
}

continuation.onTermination = { _ in
    Task {
        await stopMonitor()
    }
}

However, this introduces a new Task which isn't ideal. Would there be a better way to achieve this?

Not sure how far you'll get with this, but you might need to implement an AsyncSequence instead, e.g.: swift-async-algorithms/Sources/AsyncAlgorithms/AsyncTimerSequence.swift at main · apple/swift-async-algorithms · GitHub

However, this introduces a new Task which isn't ideal. Would
there be a better way to achieve this?

Given that:

  • onTermination has no isolation constraints

  • Event monitors are main-thread-only

I don’t see any way to avoid that task.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

I will look into this, thank you!