Store: pruning rechecks each APK before deleting and spares ones being installed

Deletion re-stats under a lock shared with touch() (F-Droid cache reuse) and
claim()/release() (held by the store around install), so a reused or
installing APK is never removed from an out-of-date scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 22:29:39 +10:00
1 parent 4fd8bb79af
commit a7261b1601
5 files changed
+91 -13

No files matched your search

+39 -8
View File
@@ -7,6 +7,36 @@ from . import SourceError, SourceLimited
UA = 'FrameControl/0.1'
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
RECENT = 3600 # an APK used this recently may be about to be installed; never pruned
_use_lock = threading.Lock() # pruning's final check and delete vs touch()/claim()
_in_use = {} # APK path -> installs using it
def touch(path):
"""Mark a cached APK as just used; False if pruning already removed it."""
with _use_lock:
try:
os.utime(str(path))
return True
except FileNotFoundError:
return False
def claim(path):
"""Protect a downloaded APK from pruning until release(path)."""
path = os.path.abspath(str(path))
with _use_lock:
os.utime(path) # raises if it has gone
_in_use[path] = _in_use.get(path, 0) + 1
def release(path):
path = os.path.abspath(str(path))
with _use_lock:
if _in_use.get(path, 0) > 1:
_in_use[path] -= 1
else:
_in_use.pop(path, None)
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
_limited = {} # host -> time.time() before which we don't contact it
_limited_lock = threading.Lock()
@@ -77,16 +107,17 @@ def prune():
except OSError:
pass
total = sum(size for _, size, _ in apks)
for mtime, size, path in sorted(apks):
for _, size, path in sorted(apks):
if total <= APK_CAP:
break
if now - mtime < 3600: # may be about to be installed
continue
try:
os.remove(path)
total -= size
except OSError:
pass
with _use_lock: # the scan is old news: check again right before deleting
try:
if os.path.abspath(path) in _in_use or time.time() - os.stat(path).st_mtime < RECENT:
continue
os.remove(path)
total -= size
except OSError:
pass
def checked_url(url, hosts):
+1 -3
View File
@@ -647,9 +647,7 @@ def download(source, entry_id, version_code=None):
sha = version['sha256']
path = frame_host.cache_dir('apk-sources', sha + '.apk')
try:
if path.exists() and _sha256(path) == sha:
os.utime(str(path)) # most recently used, for cache pruning
else:
if not (path.exists() and _sha256(path) == sha and _web.touch(path)): # touch: recently used, not pruned
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
+9 -1
View File
@@ -314,7 +314,15 @@ def install(source_id, entry_id, version_code=None, progress=None):
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
if progress:
progress('Installing', None)
result = frame_android.install(downloaded['apk'], **kwargs)
from apk_sources import _web
try:
_web.claim(downloaded['apk']) # no cache pruning while it installs
except OSError as e:
raise SourceError('The downloaded APK disappeared before installing; try again') from e
try:
result = frame_android.install(downloaded['apk'], **kwargs)
finally:
_web.release(downloaded['apk'])
if obb:
# OBB files go into the app's own instance, which only exists while the app runs.
with _lock: