Robust cleanup after failed download#35
Conversation
|
I totally dropped the ball on this issue, but thank you for investigating and finding a fix @marktsuchida! Happy to test things if it is helpful! |
|
No prob. If the original problem is reproducible, I'd be curious to see if this fixes it (i.e., consistently exits on CTRL-C without leaving the temp dir dangling). |
|
Okay, yes, the general bug is reproducible, and this branch does not yet fix things. I am happy to debug further if necessary however it seems like you have a pretty good understanding of the situation, so let me know whether you want me to go deeper. Steps for reproduction:
|
|
@gselzer Thanks, that helps. Will investigate. |
(Assisted by Claude Code; any errors are mine.)
(Assisted by Claude Code; any errors are mine.)
Add a retry loop when cleaning up after failed download. Also added an error code used in the existing retry loop for swapping in a downloaded file. (Assisted by Claude Code; any errors are mine.)
Quoting my code comment: On Windows, we sometimes encounter errors when trying to delete a file that we just closed after writing. This is due to Antivirus opening the file to scan it. Microsoft Defender Antivirus is said to use FILE_SHARE_DELETE, but os.unlink() calls DeleteFileW(), which does not use FILE_SHARE_DELETE; since the check is bidirectional, it fails. So use Win32 API calls that will not have this problem. (Assisted by Claude Code; any errors are mine.)
(Assisted by Claude Code; any errors are mine.)
Make _utils.rmtree() (renamed) fall back to _utils.unlink_file(). Make _utils.unlink_file() also use a retry loop. Make download_and_extract() use _utils.unlink_file() for it's temporary file (non-error path). Make clear_cache() use _utils.rmtree(), but without the retry loop.
Keep functions that do similar things together. (Assisted by Claude Code; any errors are mine.)
For Python < 3.12, use onerror= instead. (Assisted by Claude Code; any errors are mine.)
(Assisted by Claude Code; any errors are mine.)
When our fallback unlink_file() "deletes" a file that is open elsewhere, it is marked by Windows to be deleted when all others have closed it. But this means that the file is still there, and rmdir() on its parent fails. But we don't want rmtree() to "succeed" when we have leftover directories. Instead, we let that be an error, and rely on rmtree()'s retry loop (if enabled) to go back and try to delete such directories. (Assisted by Claude Code; any errors are mine.)
b902555 to
c8f2d06
Compare
|
Some notes:
So
|
|
Finalizer-based cleanup is likely to mitigate the issue for the exact case we have here, for the manageable cost of using an explicit cleanup-stack (as proposed above). I started implementing this and it looked relatively clean, but then I started thinking about the fact that Python is moving to free-threaded. Running anything real on a daemon thread (including For So I think it's better for the cjdk API to offer a way to safely run cancellable background installations rather than add hard-to-test complexity to work around what is inherently unsafe usage. I don't want to find out in a few years, when PyQt supports free-threaded, that yet more workarounds are needed, for example. I also don't want to make creating technical debt be the "easy thing" for user code to do. Possibilities:
1 has the various downsides of any multi-process arrangement. 2 puts a burden on the caller to get things right, and ends up being less clean than 3. 3 has the added advantage that we can stream progress back to the caller as an alternative to the CLI progress bar, which should be nicer for GUI apps. It might be just as easy to support both 2 and 3, though. Given that we already have good cleanup upon cancellation by I'm going to prune this PR back to just the cleanup that makes sense assuming no abrupt thread termination, and start another one to add an async API (maybe post 0.5.0). |
Because the FILE_SHARE_DELETE approach only marks the file for deletion and does't necessarily delete before returning (the file is deleted when it is closed by the other process). That causes further cleanup (such as deleting the parent directory) to fail, and only makes things more complicated. Since we need to wait for the file to actually go away, we can just keep the retry loop around os.unlink().
0bc7c1a to
ccba40e
Compare
Clean up the previous changes by makeing sure we do not leak either keyed (fetch) dirs or tempfiles/dirs when failing with an exception.
ccba40e to
9839d51
Compare
Motivated by #28 (but turned out to be separate; see below).
Delete partial/failed downloaded file using a method that (should) not fail if Microsoft Defender Antivirus is scanning it