3

Consider the following python function for cleaning a directory:

def cleanDir(path):
  shutil.rmtree(path)
  os.mkdir(path)

On Windows (actually tested on Windows7 and Windows10 with python 2.7.10 and 3.4.4), when navigating at the same time with Windows Explorer into the corresponding directory (or when only navigating in the left tree pane to the parent folder), the following exception may be raised:

Traceback (most recent call last):
  ...
  File "cleanDir.py", line ..., in cleanDir
    os.mkdir(path)
PermissionError: [WinError 5] Access is denied: 'testFolder'

The problem has already been reported in this issue. But it was not analyzed further and the given solution using sleep is not satisfying. According to Eryk's comments below the same behaviour is also to be expected up to current python versions, i.e. python 3.8.

Note that shutil.rmtree returns without exception. But trying to create the directory again immediately may fail. (A retry is most of the time successful, see the full code for testing below.) And note that you need to click around in the Windows Explorer in the test folders, left and right side, to force the problem.

The problem seems to be in the Windows filesystem API functions (and not in the Python os module): deleted folders seem not to be "forwarded" to all functions immediately, when Windows Explorer has a handle on the corresponding folder.

import os, shutil
import time

def populateFolder(path):
  if os.path.exists(path):
    with open(os.path.join(path,'somefile.txt'), 'w') as f:
      f.write('test')
  #subfolderpath = os.path.join(path,'subfolder')
  #os.mkdir(subfolderpath)
  #with open(os.path.join(subfolderpath,'anotherfile.txt'), 'w') as f2:
  #  f2.write('test')

def cleanDir(path):
  shutil.rmtree(path)
  os.mkdir(path)


def cleanDir_safe(path):
  shutil.rmtree(path)

  try:
    #time.sleep(0.005) # makes first try of os.mkdir successful
    os.mkdir(path)
  except Exception as e:
    print('os.mkdir failed: %s' % e)
    time.sleep(0.01)
    os.mkdir(path)

  assert os.path.exists(path)


FOLDER_PATH = 'testFolder'
if os.path.exists(FOLDER_PATH):
  cleanDir(FOLDER_PATH)
else:
  os.mkdir(FOLDER_PATH)

loopCnt = 0
while True:
  populateFolder(FOLDER_PATH)
  #cleanDir(FOLDER_PATH)
  cleanDir_safe(FOLDER_PATH)
  time.sleep(0.01)
  loopCnt += 1
  if loopCnt % 100 == 0:
    print(loopCnt)
coproc
  • 6,027
  • 2
  • 20
  • 31
  • @ErykSun tested with python 2.7.10 and python 3.4.4 – coproc Feb 07 '20 at 17:01
  • @ErykSun we have definetly observed this behaviour: `os.path.exists(path)` ("read access") is always `False` after a successful `shutil.rmtree`. `os.mkdir(path)` ("write access") may fail if called "quickly" after `shutil.rmtree`. With the given test code this should be easily reproducible. – coproc Feb 07 '20 at 17:05
  • @ErykSun "a deleted but still linked directory": isn't this temporarily inconsistent state a bug in the Windows filesystem API? – coproc Feb 08 '20 at 18:34
  • @ErykSun So from python 3.5.8 on (e.g. also in python 3.6) calling `os.mkdir(path)` directly after `shutil.rmdir(path)` is safe? – coproc Feb 08 '20 at 18:36
  • @ErykSun a sample code with `FindFirstFileW` would be great as we cannot upgrade our code base to python 3.5+ immediately – coproc Feb 09 '20 at 10:49
  • @ErykSun could `shutil.rmtree` be extended to only return after the directory has been unlinked? Wouldn't that be the way to go to avoid such unexpected behaviour? – coproc Feb 09 '20 at 10:51
  • I've seen proposals to special case `os.rmdir` or `shutil.rmtree` in Windows. Nothing has been accepted into the standard library, however. And it wouldn't help you with 2.7 and 3.4 anyway since neither is supported for any updates, not even security updates. 3.7 is still available for bug/enhancement updates. 3.8 is the current release, and 3.9 is in development. A change like this might make it into 3.8, but could be restricted to just 3.9. – Eryk Sun Feb 09 '20 at 18:38
  • @ErykSun sure, I am aware that any extension of `os.mkdir` or `shutil.rmtree` will only go into some version 3.8+. But others have also fallen and more will fall into this trap, and some day also we will upgrade to 3.8+. – coproc Feb 09 '20 at 19:49
  • @ErykSun can you point me to the proposals for making `os.mkdir` reliable (i.e. avoiding this race condition)? What are the arguments against such an extension? – coproc Feb 10 '20 at 07:45

2 Answers2

9

Explorer has an open handle to the directory that shares delete/rename access. This allows rmdir to succeed, whereas normally an open would not share delete/rename access, and rmdir would fail with a sharing violation (32). However, even though rmdir succeeds, the directory doesn't actually get unlinked until Explorer closes its handle. It's watching the directory for changes, so it gets notified that the directory has been deleted, but even if it closes its handle immediately, there's a race condition with the script's os.mkdir call.

You should retry os.mkdir in a loop, with an increasing timeout. You also need an onerror handler for shutil.rmtree that handles trying to remove a directory that's not empty because it contains 'deleted' files or directories.

For example:

import os
import time
import errno
import shutil

def onerror(function, path, exc_info):
    # Handle ENOTEMPTY for rmdir
    if (function is os.rmdir
          and issubclass(exc_info[0], OSError)
          and exc_info[1].errno == errno.ENOTEMPTY):
        timeout = 0.001
        while timeout < 2:
            if not os.listdir(path):
                return os.rmdir(path)
            time.sleep(timeout)
            timeout *= 2
    raise

def clean_dir_safe(path):
    shutil.rmtree(path, onerror=onerror)
    # rmtree didn't fail, but path may still be linked if there is or was
    # a handle that shares delete access. Assume the owner of the handle
    # is watching for changes and will close it ASAP. So retry creating
    # the directory by using a loop with an increasing timeout.
    timeout = 0.001
    while True:
        try:
            return os.mkdir(path)
        except PermissionError as e:
            # Getting access denied (5) when trying to create a file or
            # directory means either the caller lacks access to the
            # parent directory or that a file or directory with that
            # name exists but is in the deleted state. Handle both cases
            # the same way. Otherwise, re-raise the exception for other
            # permission errors, such as a sharing violation (32).
            if e.winerror != 5 or timeout >= 2:
                raise
            time.sleep(timeout)
            timeout *= 2

Discussion

In common cases, this problem is 'avoided' because existing opens do not share delete/rename access. In this case, trying to delete a file or directory fails with a sharing violation (winerror 32). For example, if a directory is open as the working directory of a process, it doesn't share delete/rename access. For regular files, most programs only share read/execute and write/append access.

Temporary files are often opened with delete/rename access sharing, especially if they're opened with delete/rename access (e.g. opened with the delete-on-close flag). This is the most common cause of 'deleted' files that are still linked but are inaccessible. Another case is opening a directory to watch for changes (e.g. see ReadDirectoryChangesW). Typically this open will share delete/rename access, which is the situation with Explorer in this question.


Stating that a file gets deleted without getting unlinked probably sounds strange (to say the least) to a Unix developer. In Windows, deleting a file (or directory) is just setting a delete disposition on its file control block (FCB). A file that has its delete disposition set gets automatically unlinked when the filesystem cleans up the file's last kernel file-object reference. A file object typically gets created by CreateFileW, which returns a handle to the object. Cleanup of a file object is triggered when the last handle to it is closed. Multiple handle references for a file object may exist due to handle inheritance in child processes or explicit DuplicateHandle calls.

To reiterate, a file or directory may be referenced by multiple kernel file objects, for which each may be referenced by multiple handles. Normally, with classic Windows delete semantics, all of the handles have to be closed before the file gets unlinked. Moreover, setting the delete disposition isn't necessarily final. If any of the open handles has delete/rename access, it can actually be used to restore access to the file by clearing the delete disposition (e.g. see SetFileInformationByHandle: FileDispositionInfo).

In Windows 10, the kernel also supports POSIX delete semantics, with which a file or directory is immediately unlinked as soon as the deleting handle is closed (see the details for NTAPI FileDispositionInformationEx). NTFS has been updated to support POSIX delete semantics. Recently WINAPI DeleteFileW (i.e. Python os.remove) has switched to using it if the filesystem supports it, but RemoveDirectoryW (i.e. Python os.rmdir) is still limited to a classic Windows delete.

Implementing POSIX semantics is relatively easy for NTFS. It simply sets the delete disposition and renames the file out of the way into an NTFS reserved directory "\$Extend\$Deleted", with a name that's based on its file ID. In practice, it appears that the file was unlinked, while continuing to allow existing file objects to access the file. One significant difference compared to a classic delete is that the original name is lost, so the delete disposition cannot be unset by existing handles that have delete/rename access.

Eryk Sun
  • 33,190
  • 5
  • 92
  • 111
1

I rename the folder and delete the 'new' folder then create the (intended) folder.

## Rename & Delete 'old' folder
if os.path.exists(file_path):
    sTmp = "fldr_" + datetime.now().strftime("%Y%m%d%H%M%S") # new folder name
    os.rename(file_path, sTmp)  # rename folder
    shutil.rmtree(sTmp)  # delete folder

## Create new folder
os.makedirs(file_path)  # make new folder
B LH
  • 11
  • 1