From b53f51a0b4d20e27784f4d926fae0fe47861065b Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Thu, 4 Jun 2026 22:40:20 +0900 Subject: [PATCH] v0.4.34: purge() cancels in-flight download (per-URL cancellation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit videoCache remove(=purge) 가 다운로드 중인 항목도 취소하도록 per-URL 세대 (DOWNLOAD_GEN) 채널 추가. 기존엔 READY/디스크 최종 파일만 지워서, 느린/큰 영상이 다운로드 중에 evict 되면 완료 후 다시 디스크+READY 에 되살아남. 이제 purge 가 세대를 제거 → download 의 cancelled() 검사(읽기 루프 + 모든 publish 지점)가 실패 → .part/승격 파일 self-clean. clearAll/wipe/clearIndex 도 DOWNLOAD_GEN 정리. 데이터팩 keep(FIFO) 메모리 관리 보장에 필요. Co-Authored-By: Claude Opus 4 --- gradle.properties | 2 +- .../client/playback/VideoCache.java | 74 ++++++++++++++----- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/gradle.properties b/gradle.properties index 38ad183..bb11be0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.configuration-cache=false # Mod mod_id=video_player -mod_version=0.4.33 +mod_version=0.4.34 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java index 2283913..2f0640b 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java @@ -52,6 +52,18 @@ public final class VideoCache { */ private static final AtomicLong CACHE_EPOCH = new AtomicLong(0); + /** + * Per-URL cancellation channel for {@link #purge(String)}. The global {@link #CACHE_EPOCH} + * can only cancel all in-flight downloads at once; a targeted purge needs to stop + * exactly one. Each submitted download captures a unique generation here at submit time; + * {@link #purge(String)} removes the URL's entry, which makes the running download's + * {@link #cancelled(String, long, long)} checks (read loop + every publish point) fail and + * self-clean its {@code .part}/promoted file instead of re-publishing post-purge. + */ + private static final Map DOWNLOAD_GEN = new ConcurrentHashMap<>(); + /** Monotonic source for per-download generations stored in {@link #DOWNLOAD_GEN}. */ + private static final AtomicLong GEN_SEQ = new AtomicLong(0); + /** * Single-thread executor that serializes all downloads. Cap enforcement (the total * cache size check) needs an authoritative view of the cache directory at the moment a @@ -103,6 +115,12 @@ public final class VideoCache { /** Server-driven delete of a cached URL. Removes from READY and from disk. */ public static void purge(String url) { if (url == null || url.isEmpty()) return; + // Cancel any in-flight download of this exact URL first. Dropping its generation + // makes the download thread's cancelled() checks fail, so it deletes its own .part + // (or rolls back a just-promoted file) instead of finishing and re-publishing after + // we've purged. Without this, a slow/large download evicted mid-flight would silently + // reappear on disk + in READY once it completed. + DOWNLOAD_GEN.remove(url); Path p = READY.remove(url); if (p == null) { // Not in this session's index, but the file may still be on disk from a prior run. @@ -158,6 +176,7 @@ public final class VideoCache { // Phase 2: drop indexes. READY.clear(); IN_FLIGHT.clear(); + DOWNLOAD_GEN.clear(); // Phase 3: delete files on disk. int deleted = 0; @@ -209,7 +228,11 @@ public final class VideoCache { // CACHE_EPOCH later — any mismatch means clearAll() ran in between and this // download must abort without publishing. long epoch = CACHE_EPOCH.get(); - DOWNLOAD_POOL.submit(() -> download(url, epoch)); + // Register a unique generation for this download so a targeted purge() can cancel + // exactly this URL (see DOWNLOAD_GEN / cancelled()). + long gen = GEN_SEQ.incrementAndGet(); + DOWNLOAD_GEN.put(url, gen); + DOWNLOAD_POOL.submit(() -> download(url, epoch, gen)); } /** Return the local cached file path for this URL, or {@code null} if not yet ready. */ @@ -224,6 +247,7 @@ public final class VideoCache { public static void clearIndex() { READY.clear(); IN_FLIGHT.clear(); + DOWNLOAD_GEN.clear(); } /** @@ -237,6 +261,7 @@ public final class VideoCache { CACHE_EPOCH.incrementAndGet(); READY.clear(); IN_FLIGHT.clear(); + DOWNLOAD_GEN.clear(); int deleted = 0; int failed = 0; try { @@ -283,16 +308,16 @@ public final class VideoCache { * @return {@code true} if the entry is now published; {@code false} if the * caller's work was cancelled and {@code path} has been cleaned up. */ - private static boolean publishIfNotCancelled(String url, Path path, long startEpoch) { - // If the wipe ran between download's pre-move epoch check and Files.move, its - // directory scan won't have seen this just-promoted file. We must delete it here, - // not just bail — otherwise the freshly-moved final file leaks across shutdown. - if (CACHE_EPOCH.get() != startEpoch) { + private static boolean publishIfNotCancelled(String url, Path path, long startEpoch, long startGen) { + // If a wipe (clearAll) or a targeted purge ran between download's pre-move check and + // Files.move, its directory scan won't have seen this just-promoted file. We must + // delete it here, not just bail — otherwise the freshly-moved final file leaks. + if (cancelled(url, startEpoch, startGen)) { try { Files.deleteIfExists(path); } catch (Throwable ignored) {} return false; } READY.put(url, path); - if (CACHE_EPOCH.get() != startEpoch) { + if (cancelled(url, startEpoch, startGen)) { READY.remove(url, path); try { Files.deleteIfExists(path); } catch (Throwable ignored) {} return false; @@ -300,7 +325,19 @@ public final class VideoCache { return true; } - private static void download(String url, long startEpoch) { + /** + * True if the in-flight download for {@code url} should abort and self-clean. Two channels: + * a global wipe ({@link #clearAll()}/{@link #wipeOnShutdown()} bumped {@link #CACHE_EPOCH}), + * or a targeted {@link #purge(String)} that removed/replaced this URL's generation in + * {@link #DOWNLOAD_GEN}. + */ + private static boolean cancelled(String url, long startEpoch, long startGen) { + if (CACHE_EPOCH.get() != startEpoch) return true; + Long cur = DOWNLOAD_GEN.get(url); + return cur == null || cur.longValue() != startGen; + } + + private static void download(String url, long startEpoch, long startGen) { Path cacheDir = cacheDir(); Path partPath = null; try { @@ -311,8 +348,8 @@ public final class VideoCache { } // Pre-flight cancellation check — if clearAll already ran between submit and now, // skip the whole thing. Avoids creating directories / .part files post-clear. - if (CACHE_EPOCH.get() != startEpoch) { - VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll ran) — {}", + if (cancelled(url, startEpoch, startGen)) { + VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll/purge ran) — {}", VideoPlayerMod.MOD_ID, url); return; } @@ -326,8 +363,8 @@ public final class VideoCache { // Resume-friendly: if the file's already on disk from an earlier session, just // index it without re-downloading. if (Files.exists(finalPath) && Files.size(finalPath) > 0) { - if (!publishIfNotCancelled(url, finalPath, startEpoch)) { - VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}", + if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) { + VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll/purge ran) — {}", VideoPlayerMod.MOD_ID, url); return; } @@ -378,7 +415,7 @@ public final class VideoCache { // Cancellation check inside the loop. Break (not return) so the try-with // closes the output stream first — on Windows, deleting an open .part can // fail with AccessDeniedException, so we always close before deleting. - if (CACHE_EPOCH.get() != startEpoch) { + if (cancelled(url, startEpoch, startGen)) { cancelled = true; break; } @@ -416,8 +453,8 @@ public final class VideoCache { // Pre-move cancellation check. If clearAll ran during the read loop, abort // before promoting .part to final — saves a wasted move + delete. - if (CACHE_EPOCH.get() != startEpoch) { - VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll ran) — {}", + if (cancelled(url, startEpoch, startGen)) { + VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll/purge ran) — {}", VideoPlayerMod.MOD_ID, url); try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {} return; @@ -431,8 +468,8 @@ public final class VideoCache { // the window between READY.put and the "완료" notification (clearAll wipes // READY then we re-publish + emit stale completion chat). publishIfNotCancelled // rolls back both READY and the on-disk file if the epoch moved at any time. - if (!publishIfNotCancelled(url, finalPath, startEpoch)) { - VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}", + if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) { + VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll/purge ran) — {}", VideoPlayerMod.MOD_ID, url); return; } @@ -451,6 +488,9 @@ public final class VideoCache { } } finally { IN_FLIGHT.remove(url); + // Only clear our own generation — a re-preload after a purge may have installed a + // newer gen for this URL that must survive. + DOWNLOAD_GEN.remove(url, startGen); } }