Xcode 16.3: Can't use `makeIterator` via FileManager's enumerator(at:) in async function

I updated from Xcode 16.2 to 16.3 and Swift Concurrency was displeased:

It appears that I can no longer use FileManager's directory enumerator in an async function? This method is on an Actor with a custom executor that uses a serial dispatch queue:

///
///  A custom executor for an Actor that uses a serial dispatch queue, which allows us to adapt the Actor pattern to older APIs that offer
///  a dispatchQueue for running their operations/callbacks.
///
final class SerialDispatchQueueExecutor: SerialExecutor
{
    private let queue: DispatchQueue
    
    init(queue: DispatchQueue) {
        self.queue = queue
    }
    
    
    @available(macOS 14.0, iOS 17.0, *)
    func enqueue(_ job: consuming ExecutorJob)
    {
        let unownedJob = UnownedJob(job)
        let unownedExecutor = asUnownedSerialExecutor()

        queue.async {
            unownedJob.runSynchronously(on: unownedExecutor)
        }
    }
}

The method has always worked fine, but I gather there's some edge case with makeIterator in Swift Concurrency. So, is there a new Concurrency-blessed approach that I should be using here?

Unfortunately it looks like the underlying Obj-C NSEnumerator's Sequence conformance has been updated to be explicitly incompatible with Swift concurrency.

@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)
extension NSEnumerator : Sequence {

    /// Return an *iterator* over the *enumerator*.
    ///
    /// - Complexity: O(1).
    @available(*, noasync)
    public func makeIterator() -> NSFastEnumerationIterator

    /// A type representing the sequence's elements.
    @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.10, *)
    public typealias Element = Any

    /// A type that provides the sequence's iteration interface and
    /// encapsulates its iteration state.
    @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.10, *)
    public typealias Iterator = NSFastEnumerationIterator
}

@available(*, unavailable)
extension NSEnumerator : @unchecked Sendable {
}

noasync seems extremely aggressive. I'm not sure there's a workaround that doesn't require materializing the entire enumerator, which rather defeats the point. Perhaps you can create your own Sequence-conforming type to safely wrap NSEnumerator?

@Jon_Shier Yep. I figure whatever the edge cases are, they don’t apply to me because this whole thing is running on one serial queue—there is no “concurrency” for the enumerator itself. I’m away from my Mac, but I wonder if I can use the assumeIsolated trick from the standard library here to get past the compiler?

It’s very frustrating. This API is now suddenly unavailable in Swift Concurrency, but there is (as far as I can see) no modern replacement that does work with Concurrency. I mean, one of the most natural things to do on a background actor is walk a giant file tree.

No, noasync prevents running the code in any async context. I don't know how smart the diagnostic is and whether wrapping it behind a sync function would do anything, or whether you'd need a whole other wrapper.

1 Like

Isn’t the compiler going to see that I’m in an asynchronous context, even if I have that wrapper?

I have a feeling that I’m gonna end up dropping down to C APIs.

Maybe, that's why I said it depends on how smart the diagnostic is. But before C I'd try wrapping the DirectoryEnumerator yourself, as NSEnumerator can be enumerated manually.

1 Like

You can create your own iterator around DirectoryEnumerator.

struct DirectoryIterator: Sequence {
    let enumerator: FileManager.DirectoryEnumerator
    
    func makeIterator() -> AnyIterator<Any> {
        AnyIterator {
            enumerator.nextObject()
        }
    }
}

await Task {
    guard let enumerator = FileManager.default
        .enumerator(at: .applicationDirectory,
                    includingPropertiesForKeys: [.pathKey],
                    options: .skipsSubdirectoryDescendants) else { return }

    for case let path as URL in DirectoryIterator(enumerator: enumerator) {
        print(path)
    }
}.value

Whether this is safe depends on why the changes to NSEnumerator were made.

1 Like

@Jon_Shier Sure enough, the compiler doesn't see it as async anymore with that wrapper in place. That's a pleasant surprise. Thanks for the help!

That's an over-complication.

noasync doesn't propagate.

@available(*, noasync) func noasync() { }
func yesasync() { noasync() }
func async() async { yesasync() } // Compiles.

And it doesn't make its way through opaque typing. So you can just do

for case let path as URL in enumerator.asSomeSequence {
extension Sequence {
  var asSomeSequence: some Sequence<Element> { self }
}

or

for case let path as URL in AnySequence(enumerator) {
2 Likes

I wanted to avoid the original Iterator in case there was a real issue.