Skip to content

Commit 64d71df

Browse files
authored
Merge pull request #35 from cachedjdk/robust-cleanup-failed-download
Robust cleanup after failed download
2 parents b722bcd + 7351355 commit 64d71df

File tree

6 files changed

+311
-149
lines changed

6 files changed

+311
-149
lines changed

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ See also the section on [versioning](versioning-scheme).
1212

1313
- Add `cjdk clear-cache` and `clear_cache()`.
1414
- Environment variables set to empty are now treated as unset.
15+
- Improve cleanup of temporary files and directories.
16+
- Better message when a leftover directory blocks a download.
1517

1618
## [0.4.1] - 2025-05-01
1719

src/cjdk/_cache.py

Lines changed: 33 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
# SPDX-License-Identifier: MIT
44

55
import hashlib
6-
import shutil
6+
import sys
77
import time
88
import urllib
99
from contextlib import contextmanager
1010
from pathlib import Path
1111

12-
from . import _progress
12+
from . import _progress, _utils
1313

1414
__all__ = [
1515
"atomic_file",
@@ -86,21 +86,26 @@ def atomic_file(
8686
if not _file_exists_and_is_fresh(target, ttl):
8787
with _create_key_tmpdir(cache_dir, key) as tmpdir:
8888
if tmpdir:
89-
fetchfunc(tmpdir / filename)
90-
_swap_in_fetched_file(
91-
target,
92-
tmpdir / filename,
93-
timeout=timeout_for_read_elsewhere,
94-
)
95-
_add_url_file(keydir, key_url)
89+
filepath = tmpdir / filename
90+
try:
91+
fetchfunc(filepath)
92+
_utils.swap_in_file(
93+
target,
94+
filepath,
95+
timeout=timeout_for_read_elsewhere,
96+
)
97+
_add_url_file(keydir, key_url)
98+
finally:
99+
_utils.unlink_tempfile(filepath)
96100
else: # Somebody else is currently fetching
97101
_wait_for_dir_to_vanish(
98102
_key_tmpdir(cache_dir, key),
99103
timeout=timeout_for_fetch_elsewhere,
100104
)
101105
if not _file_exists_and_is_fresh(target, ttl=2**63):
102106
raise Exception(
103-
f"Fetching of file {target} appears to have been completed elsewhere, but file does not exist"
107+
f"Another process was fetching {target} but the file is not present; "
108+
f"the other process may have failed or been interrupted."
104109
)
105110
return target
106111

@@ -135,17 +140,21 @@ def permanent_directory(
135140
if not keydir.is_dir():
136141
with _create_key_tmpdir(cache_dir, key) as tmpdir:
137142
if tmpdir:
138-
fetchfunc(tmpdir)
139-
_move_in_fetched_directory(keydir, tmpdir)
140-
_add_url_file(keydir, key_url)
143+
try:
144+
fetchfunc(tmpdir)
145+
_move_in_fetched_directory(keydir, tmpdir)
146+
_add_url_file(keydir, key_url)
147+
finally:
148+
_utils.rmtree_tempdir(tmpdir)
141149
else: # Somebody else is currently fetching
142150
_wait_for_dir_to_vanish(
143151
_key_tmpdir(cache_dir, key),
144152
timeout=timeout_for_fetch_elsewhere,
145153
)
146154
if not keydir.is_dir():
147155
raise Exception(
148-
f"Fetching of directory {keydir} appears to have been completed elsewhere, but directory does not exist"
156+
f"Another process was fetching {keydir} but the directory is not present; "
157+
f"the other process may have failed or been interrupted"
149158
)
150159
return keydir
151160

@@ -181,8 +190,7 @@ def _create_key_tmpdir(cache_dir, key):
181190
try:
182191
yield tmpdir
183192
finally:
184-
if tmpdir.is_dir():
185-
shutil.rmtree(tmpdir)
193+
_utils.rmtree_tempdir(tmpdir)
186194

187195

188196
def _key_directory(cache_dir: Path, key) -> Path:
@@ -193,43 +201,6 @@ def _key_tmpdir(cache_dir: Path, key) -> Path:
193201
return cache_dir / "v0" / Path("fetching", *key)
194202

195203

196-
def _swap_in_fetched_file(target, tmpfile, timeout, progress=False):
197-
# On POSIX, we only need to try once to move tmpfile to target; this will
198-
# work even if target is opened by others, and any failure (e.g.
199-
# insufficient permissions) is permanent.
200-
# On Windows, there is the case where the file is open by others (busy); we
201-
# should wait a little and retry in this case. It is not possible to do
202-
# this cleanly, because the error we get when the target is busy is "Access
203-
# is denied" (PermissionError, a subclass of OSError, with .winerror = 5),
204-
# which is indistinguishable from the case where target permanently has bad
205-
# permissions.
206-
# But because this implementation is only intended for small files that
207-
# will not be kept open for long, and because permanent bad permissions is
208-
# not expected in the typical use case, we can do something that almost
209-
# always results in the intended behavior.
210-
WINDOWS_ERROR_ACCESS_DENIED = 5
211-
212-
target.parent.mkdir(parents=True, exist_ok=True)
213-
with _progress.indefinite(
214-
enabled=progress, text="File busy; waiting"
215-
) as update_pbar:
216-
for wait_seconds in _backoff_seconds(0.001, 0.5, timeout):
217-
try:
218-
tmpfile.replace(target)
219-
except OSError as e:
220-
if (
221-
hasattr(e, "winerror")
222-
and e.winerror == WINDOWS_ERROR_ACCESS_DENIED
223-
and wait_seconds > 0
224-
):
225-
time.sleep(wait_seconds)
226-
update_pbar()
227-
continue
228-
raise
229-
else:
230-
return
231-
232-
233204
def _move_in_fetched_directory(target, tmpdir):
234205
target.parent.mkdir(parents=True, exist_ok=True)
235206
tmpdir.replace(target)
@@ -241,10 +212,18 @@ def _add_url_file(keydir, key_url):
241212

242213

243214
def _wait_for_dir_to_vanish(directory, timeout, progress=True):
215+
print(
216+
"cjdk: Another process is currently downloading the same file",
217+
file=sys.stderr,
218+
)
219+
print(
220+
f"cjdk: If you are sure this is not the case (e.g., previous download crashed), try again after deleting the directory {directory}",
221+
file=sys.stderr,
222+
)
244223
with _progress.indefinite(
245224
enabled=progress, text="Already downloading; waiting"
246225
) as update_pbar:
247-
for wait_seconds in _backoff_seconds(0.001, 0.5, timeout):
226+
for wait_seconds in _utils.backoff_seconds(0.001, 0.5, timeout):
248227
if not directory.is_dir():
249228
return
250229
if wait_seconds < 0:
@@ -253,30 +232,3 @@ def _wait_for_dir_to_vanish(directory, timeout, progress=True):
253232
)
254233
time.sleep(wait_seconds)
255234
update_pbar()
256-
257-
258-
def _backoff_seconds(initial_interval, max_interval, max_total, factor=1.5):
259-
"""
260-
Yield intervals to sleep after repeated attempts with exponential backoff.
261-
262-
The last-yielded value is -1. When -1 is received, the caller should make
263-
the final attempt before giving up.
264-
"""
265-
assert initial_interval > 0
266-
assert max_total >= 0
267-
assert factor > 1
268-
total = 0
269-
next_interval = initial_interval
270-
while max_total > 0:
271-
next_total = total + next_interval
272-
if next_total > max_total:
273-
remaining = max_total - total
274-
if remaining > 0.01:
275-
yield remaining
276-
break
277-
yield next_interval
278-
total = next_total
279-
next_interval *= factor
280-
if next_interval > max_interval:
281-
next_interval = max_interval
282-
yield -1

src/cjdk/_download.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import requests
1313

14-
from . import _progress
14+
from . import _progress, _utils
1515

1616
__all__ = [
1717
"download_and_extract",
@@ -49,14 +49,17 @@ def download_and_extract(
4949
url = http + url.removeprefix(scheme)
5050
with tempfile.TemporaryDirectory(prefix="cjdk-") as tempd:
5151
file = Path(tempd) / f"archive.{ext}"
52-
download_file(
53-
file,
54-
url,
55-
checkfunc=checkfunc,
56-
progress=progress,
57-
_allow_insecure_for_testing=_allow_insecure_for_testing,
58-
)
59-
extract(destdir, file, progress)
52+
try:
53+
download_file(
54+
file,
55+
url,
56+
checkfunc=checkfunc,
57+
progress=progress,
58+
_allow_insecure_for_testing=_allow_insecure_for_testing,
59+
)
60+
extract(destdir, file, progress)
61+
finally:
62+
_utils.unlink_tempfile(file)
6063

6164

6265
def download_file(
@@ -79,18 +82,18 @@ def download_file(
7982
f"Cannot handle {scheme} (must be https)"
8083
)
8184

82-
response = requests.get(url, stream=True)
83-
response.raise_for_status()
84-
total = response.headers.get("content-length", None)
85-
total = int(total) if total else None
86-
with open(dest, "wb") as outfile:
87-
for chunk in _progress.data_transfer(
88-
total,
89-
response.iter_content(chunk_size=16384),
90-
enabled=progress,
91-
text="Download",
92-
):
93-
outfile.write(chunk)
85+
with requests.get(url, stream=True) as response:
86+
response.raise_for_status()
87+
total = response.headers.get("content-length", None)
88+
total = int(total) if total else None
89+
with open(dest, "wb") as outfile:
90+
for chunk in _progress.data_transfer(
91+
total,
92+
response.iter_content(chunk_size=16384),
93+
enabled=progress,
94+
text="Download",
95+
):
96+
outfile.write(chunk)
9497

9598
if checkfunc:
9699
checkfunc(dest)

0 commit comments

Comments
 (0)