Repository navigation
bpo-43153: Don't mask PermissionError with NotADirectoryError during tempdirectory cleanup - #29940
Conversation
…y cleanup Co-Authored-By: andrei kulakov <[email protected]>
|
I've tested locally and it works on Windows. |
| # PermissionError is raised on FreeBSD for directories | ||
| except (IsADirectoryError, PermissionError): | ||
| except PermissionError: | ||
| # On Windows, calling _rmtree again will raise |
There was a problem hiding this comment.
I think this would also happen on other OSes, not just Windows. The bug can only be reproduced on windows because PermissionError in the bug was caused by an open file, but PermissionError in theory can be caused by actual permission / ownership settings. It shouldn't matter much because this is a temp dir (and files in it) created by current user, but this comment can be misunderstood, so it's better to drop ref. to Windows here.
It may be left in the if case below because it can avoid the isdir() check.
It would be good to also add "Issue #43153" to the comment.
There was a problem hiding this comment.
Note that calling unlink() on a directory in Windows always raises PermissionError. Windows maps the NT status code STATUS_FILE_IS_A_DIRECTORY to the WinAPI error code ERROR_ACCESS_DENIED.
There was a problem hiding this comment.
I think this would also happen on other OSes, not just Windows. The bug can only be reproduced on windows
I'm confused, should we continue the _os.name == 'nt' check then? I'm not worried about the additional performance hit from isdir (though I would like to avoid it). Correctness is more important. If this does happen on other OSes, then we should remove that OS check.
There was a problem hiding this comment.
We can remove the os check. It can happen on other OSes. With the check removed, the logic becomes "if we are not permitted to remove the file, return or reraise", which makes perfect sense.
|
It might also be good to run this on all windows build bots to confirm that unit test doesn't fail on any windows version we support. The code itself should be fine but unit test may fail if some windows version doesn't raise permission error in this case. |
|
Please also update the PR title IsAdir.. => NotADir... |
PermissionError with IsADirectoryError during tempdirectory cleanupPermissionError with NotADirectoryError during tempdirectory cleanup
|
Azure pipelines failure is unrelated and fixed by GH-29963. |
|
We can probably just keep the unit test for windows and skip it on other OSes. Other OSes would need different ways of causing a PermissionError, so it's not worth the effort. [Edit: for example in my recent testing, on MacOS it's not enough to have a file with no permissions, it is the containing dir that has to lack permissions for the file to raise PermissionError]. |
Yep, I noticed the other tests are starting to fail. Seems like it's not possible to repro this on the other OSes. I'll make sure to test with buildbots before we merge this PR (and once that docfix PR lands). BTW, thanks for guiding me throughout this PR :). I'm admittedly not familiar with this part of the stdlib. Thanks to Victor and Eryk too! |
Glad I could help! I haven't done that much PR reviews yet so it was great to get that experience too, especially with the logic I was already a bit familiar with. Please add me as a reviewer in the future on similar PRs! |
|
🤖 New build scheduled with the buildbot fleet by @Fidget-Spinner for commit 499e424 🤖 If you want to schedule another build, you need to add the ":hammer: test-with-buildbots" label again. |
| # bpo-43153: Calling _rmtree again may | ||
| # raise NotADirectoryError and mask the PermissionError. | ||
| # So we must re-raise the current PermissionError. | ||
| if not _os.path.isdir(path): |
There was a problem hiding this comment.
os.path.isdir() returns True for a symlink to directory. Either check os.path.islink() before os.path.isdir() or use os.stat() explicitly.
There was a problem hiding this comment.
Failing to delete a symlink should be rare in a temp directory. But if it's worth handling here, then it should be consistent with shutil._rmtree_isdir(). Unfortunately the latter is written for os.DirEntry instances. Here's an updated implementation for shutil that can also check a path. I factored out the check for a mount point into a common _st_ismount(st) function, which avoids duplicated code and, I think, makes it easier to read.
if not hasattr(os.stat_result, 'st_reparse_tag'):
def _rmtree_islink(path):
return os.path.islink(path)
def _rmtree_isdir(path):
try:
if isinstance(path, os.DirEntry):
return entry.is_dir(follow_symlinks=False)
return stat.S_ISDIR(os.lstat(path).st_mode)
except OSError:
return False
else:
# In general mount points in Windows are directories, but os.unlink()
# supports them, so rmtree() handles them as symlinks.
def _st_ismount(st):
return (st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT)
def _rmtree_islink(path):
try:
if isinstance(path, os.DirEntry):
st = entry.stat(follow_symlinks=False)
else:
st = os.lstat(path)
return stat.S_ISLNK(st.st_mode) or _st_ismount(st)
except OSError:
return False
def _rmtree_isdir(path):
try:
if isinstance(path, os.DirEntry):
st = entry.stat(follow_symlinks=False)
else:
st = os.lstat(path)
return stat.S_ISDIR(st.st_mode) and not _st_ismount(st)
except OSError:
return False|
Thanks @Fidget-Spinner for the PR, and @serhiy-storchaka for merging it 🌮🎉.. I'm working now to backport this PR to: 3.11, 3.12. |
|
GH-112753 is a backport of this pull request to the 3.12 branch. |
…ing tempdirectory cleanup (pythonGH-29940) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
|
GH-112754 is a backport of this pull request to the 3.11 branch. |
…ing tempdirectory cleanup (pythonGH-29940) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
…or` during tempdirectory cleanup (GH-29940) (GH-112754) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
…or` during tempdirectory cleanup (GH-29940) (GH-112753) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
|
Thanks @serhiy-storchaka for bringing this over the finish line! |
|
Unfortunately, I failed to create tests for junction points (which would fail without special handling of junction points in tempfile). But the code looks right. |
| try: | ||
| _os.unlink(path) | ||
| # PermissionError is raised on FreeBSD for directories | ||
| except (IsADirectoryError, PermissionError): | ||
| except IsADirectoryError: | ||
| cls._rmtree(path, ignore_errors=ignore_errors) | ||
| except PermissionError: | ||
| # The PermissionError handler was originally added for | ||
| # FreeBSD in directories, but it seems that it is raised | ||
| # on Windows too. | ||
| # bpo-43153: Calling _rmtree again may | ||
| # raise NotADirectoryError and mask the PermissionError. | ||
| # So we must re-raise the current PermissionError if | ||
| # path is not a directory. | ||
| try: | ||
| st = _os.lstat(path) | ||
| except OSError: | ||
| if ignore_errors: | ||
| return | ||
| raise | ||
| if (_stat.S_ISLNK(st.st_mode) or | ||
| not _stat.S_ISDIR(st.st_mode) or | ||
| (hasattr(st, 'st_file_attributes') and | ||
| st.st_file_attributes & _stat.FILE_ATTRIBUTE_REPARSE_POINT and | ||
| st.st_reparse_tag == _stat.IO_REPARSE_TAG_MOUNT_POINT) | ||
| ): | ||
| if ignore_errors: | ||
| return | ||
| raise | ||
| cls._rmtree(path, ignore_errors=ignore_errors) |
There was a problem hiding this comment.
Why not go with an "ask-for-forgiveness-rather-than-permission" type of solution, e.g.:
try:
_os.unlink(path)
except IsADirectoryError:
cls._rmtree(path, ignore_errors=ignore_errors)
except PermissionError as pe:
# PermissionError is raised on FreeBSD for directories
# and by Windows on lock files used by other processes
try:
cls._rmtree(path, ignore_errors=ignore_errors)
except NotADirectoryError:
# NOTE: This is raised if PermissionError did not
# correspond to a IsADirectoryError, e.g. on
# Windows.
if not ignore_errors:
raise peThere was a problem hiding this comment.
rmtree() is a complex function, and we cannot be sure whether NotADirectoryError was raised by the toplevel scandir() or somewhere deeper in the tree.
There was a problem hiding this comment.
Seems like a responsibility inversion to me. Sure there could be a bug deep down in _rmtree that ends up bubbling an unrelated NotADirectoryError, but that would technically be a bug in _rmtree. Maybe there are valid cases where such an unrelated error would be raised?
Also I see the diff I commented on is not the final version so I'll look at the final version.
There was a problem hiding this comment.
Not necessary a bug. It may be a race condition. Although the LBYL approach is also prone to race conditions, I think it has less chance to override error with a wrong exception.
I myself prefer the EAFP approach, but I do not think that it has advantages in this case.
…ing tempdirectory cleanup (pythonGH-29940) Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
…ing tempdirectory cleanup (pythonGH-29940) Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]>
…or` during tempdirectory cleanup (pythonGH-29940) (pythonGH-112754) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]> (cherry picked from commit 23920a0)
…or` during tempdirectory cleanup (pythonGH-29940) (pythonGH-112754) (cherry picked from commit 8cdfee1) Co-authored-by: Ken Jin <[email protected]> Co-authored-by: andrei kulakov <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]> (cherry picked from commit 23920a0) (cherry picked from commit e3888c0) 3.9.25.x: 3.9's TemporaryDirectory has no ignore_cleanup_errors (added in 3.10), so _rmtree() gains a private ignore_errors parameter that is always False here; it keeps the code in the 3.10/3.11 shape for the follow-up fixes. The ignore_cleanup_errors test is not ported.
Co-Authored-By: andrei kulakov [email protected]
https://bugs.python.org/issue43153
#87319