PSA: FileManager.trashItem (Maybe) Uses NSFileCoordinator Under the Hood Automatically, So You Shouldn't to Prevent Deadlocks

Users of The Archive and Dropbox have reported issues with deleting files in their Dropbox-managed folders in the past weeks: the app would beachball forever. Apparently, Dropbox’s recent migration from ~/Dropbox to ~/Library/CloudStorage affects this. I had the occasional Google Drive user in the past months report similar issues but couldn’t make much sense of it – until now.

iCloud Drive works fine, by the way. So only 3rd party providers are affected?

The short version is this:

When you use FileManager.trashItem(at:resultingItemURL:), the FileManager maybe automatically coordinates the trash operation (which is a file move/rename) on its own, so you could deadlock your app by coordinating access to the same URL twice (the outer call never relinquishes the “lock”; this, at least, is my impression after following Soroush Khanlou’s experiments from 2019).

Why “maybe”? Because I couldn’t reproduce the internal method calls from another dev’s explanation (see below).

Update 2023-03-02: Dimitar Nestorov of MusicBar suggested to use regular expression breakpoints: breakpoint set -r '\[NSFileCoordinator .*\]$' – and I could see that no NSFileCoordinator method was called during the trashItem call. So it’s still a mystery why this doesn’t work.

Detailed Problem Description

To reproduce the problem: Wrap calls to FileManager.trashItem in NSFileCoordinator.coordinate blocks and use a Dropbox-managed folder.

It appears like you should not coordinate trashing files since macOS 10.15: In the Apple Developer forums, people have reported that since macOS Catalina (10.15), wrapping a trashing operation in a coordinate block doesn’t work anymore for them, even though it worked before. One dev’s stack trace inspection brought up that trashing internally coordinates file access, too.

I failed to make a symbolic breakpoint on various NSFileCoordinator methods trigger during the call to trashItem, so I cannot verify the claims from the dev forums.

The reason for this could be that I’m not on iOS, and/or not using NSDocument-based APIs.

I’m also not fast enough to disassemble anything and check what happens there, so if you know a way to inspect this further, please do tell!

Either way, stopping to wrap the call to FileManager.trashItem(at:resultingItemURL:) in a coordinated file access block fixed the deadlock, so the explanation makes sense, even though I cannot find an automatic call to NSFileCoordinator, yet.

Since removing a file in a coordination block works fine, I’m sticking with a fallback of this form for macOS 10.15+ (and keep a call to both inside the coordinate block for older macOS’s):

do {
    try fileManager.trashItem(at: url, resultingItemURL: nil)
} catch {
    log.error("Could not trash item. Resorting to permanent deletion.\n(\(error))")

    try NSFileCoordinator().coordinate(writingItemAt: url, options: .forDeleting) { url in
        do {
            try fileManager.removeItem(at: url)
        } catch {
            log.error("Could not delete item directly, either.\n(\(error))")
            throw error
        }
    }
}

The Result-based coordinate call is implemented here:

extension NSFileCoordinator {
    /// Result-based wrapper around the default `coordinate` implementation.
    /// - returns: `.success(())` when the writing was completed without error. Forwards the error throws by `writer`
    ///   or the error from the base `NSFileCoordinator.coordinate` implementation.
    private func coordinate(writingItemAt url: URL,
                            options: NSFileCoordinator.WritingOptions = [],
                            byAccessor writer: (URL) throws -> Void)
    -> Result<Void, Error> {
        var coordinatorError: NSError?
        var blockResult: Result<Void, Error>?
        self.coordinate(writingItemAt: url, options: options, error: &coordinatorError) { url in
            do {
                try writer(url)
                blockResult = .success(())
            } catch let error {
                blockResult = .failure(error)
            }
        }
        return coordinatorError.map { Result<Void, Error>(error: $0 as Swift.Error) }
            ?? blockResult
            ?? .failure("Unhandled case: NSFileCoordinator.coordinate did not fail and write block did never run")
    }

    /// Throwing wrapper around the default `coordinate` implementation.
    /// - throws: Forwards the error throws by `writer` or the error from the base `NSFileCoordinator.coordinate` implementation.
    func coordinate(writingItemAt url: URL,
                    options: NSFileCoordinator.WritingOptions = [],
                    byAccessor writer: (URL) throws -> Void) throws {
        return try coordinate(writingItemAt: url, options: options, byAccessor: writer).get()
    }
}