Recursive delete race in FileManager

FileManager (known as NSFileManager in Obj-C) has a handy removeItem method which you can use to recursively delete a directory at some path.

We must be careful to catch its exceptions though, which are not correctly documented. Notice that it says “Returns false if an error occurred”. Unlike its Objective-C counterpart, it clearly has no return value. For example you may try to delete some path that doesn’t exist:

let path = "/tmp/does_not_exist"
try! FileManager.default.removeItem(atPath: path)

We discover that we’ve failed to handle an exception:

main.swift:28: Fatal error: 'try!' expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=4 "“does_not_exist” couldn’t be removed." UserInfo={NSUserStringVariant=(
    Remove
), NSFilePath=/tmp/does_not_exist, NSUnderlyingError=0x109011300 {Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory"}}

Fair enough. Let’s check that the path exists before we remove it.

if FileManager.default.fileExists(atPath: path) {
  try! FileManager.default.removeItem(atPath: path)
}

Now our code is safe, right? Well, not so fast.

A while ago I was working on some code following the above pattern and it was intermittently crashing with the following exception:

Fatal error: 'try!' expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=513 "“test_dir” couldn’t be removed because you don’t have permission to access it."

I don’t have permission to access it? This didn’t make a lick of sense. The path was a temporary directory previously created within the same invocation of the iOS app. There was absolutely no funny business with permissions or ownership. For a while I put it down to some sort of Apple bug (which it kind of was) and implemented a workaround so that I didn’t have to delete that directory at that time.

A little later though, I looked more closely at the rest of the error message.

UserInfo={NSUserStringVariant=(
    Remove
), NSFilePath=/tmp/test_dir, NSUnderlyingError=0x12488a1f0 {Error Domain=NSPOSIXErrorDomain Code=66 "Directory not empty"}}

This underlying error is completely different from what it told me at the top level and it’s much more plausible. It appears that the recursive delete operation is doing something like a depth-first search to remove the files one by one. Visiting each item would be important in order for it to support its shouldRemoveItemAtPath: delegate method.

Therefore if another thread or process swoops in and creates a file new just before it tries to delete a directory, it will not be empty of files and an error occurs.

This is easy to reproduce on a Mac with a short CLI program:

import Foundation

let path = "/tmp/test_dir"

DispatchQueue.global().async {
  while true {
    try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
    for i in 1...100 {
      FileManager.default.createFile(atPath: path + "/\(i)", contents: Data())
    }
  }
}

while true {
  if FileManager.default.fileExists(atPath: path) {
    try! FileManager.default.removeItem(atPath: path)
  }
}

Here we dispatch a task to the background thread pool which will continuously create a directory and put files in it. Then in the foreground thread we continuously delete the directory if it exists. I haven’t performed any tuning to make this crash quickly, and even then it crashes within a second or two on my Mac with the above error.

The moral of the story: always handle exceptions on calls to removeItem(). And don’t always trust the documentation and error messages.