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()
}
}