2 Commits

Author SHA1 Message Date
tkrmagid
1913181d02 v0.4.20: close reindex publish race + harden preload guard
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: the existing-cache reindex branch in VideoCache.download
only ran one epoch check before READY.put, so a /videoCache clear landing
between the check and the put could leave a stale entry pointing at a
now-deleted file. Same pattern as the post-move fix in v0.4.19, applied
to the reindex path: pre-check, put, post-check + rollback on mismatch.

Also: preload() previously gated on READY.containsKey(url), which silently
blocks a re-preload if READY holds a stale key whose backing file is gone
(e.g. user deleted the file manually, or the cleanup half of a clear race).
Switched to lookup(url) — same intent, but lookup verifies the file
actually exists on disk, so stale keys self-heal on the next preload.
2026-05-16 22:54:20 +09:00
tkrmagid
d11289309b v0.4.19: fix post-move publish race in VideoCache.download
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: v0.4.18 only checked epoch BEFORE Files.move. The window
between the move completing and READY.put / "완료" chat was still racy —
if /videoCache clear landed in that window, clearAll would epoch++ +
clear READY + delete files on disk, then the download thread would do
READY.put(url, finalPath) anyway, resurrecting a cleared entry and emitting
a stale "완료" message.

Add a second epoch check immediately AFTER Files.move(): if the epoch
changed, delete finalPath and return without publishing. The pre-move
check is kept too — it lets the common cancel-during-read case skip the
wasted move/delete round-trip.
2026-05-16 22:51:32 +09:00
2 changed files with 38 additions and 6 deletions

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod # Mod
mod_id=video_player mod_id=video_player
mod_version=0.4.18 mod_version=0.4.20
maven_group=com.ejclaw.videoplayer maven_group=com.ejclaw.videoplayer
archives_base_name=video_player archives_base_name=video_player

View File

@@ -190,7 +190,11 @@ public final class VideoCache {
public static void preload(String url) { public static void preload(String url) {
if (url == null || url.isEmpty()) return; if (url == null || url.isEmpty()) return;
if (!(url.startsWith("http://") || url.startsWith("https://"))) return; if (!(url.startsWith("http://") || url.startsWith("https://"))) return;
if (READY.containsKey(url)) { // Use lookup() (which also verifies the file exists on disk) instead of
// READY.containsKey — defends against READY containing a stale entry whose
// backing file was removed by clearAll() / a user-side file delete. Without this,
// a stale key would silently block a re-preload.
if (lookup(url) != null) {
VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url); VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY); notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY);
return; return;
@@ -255,8 +259,22 @@ public final class VideoCache {
// Resume-friendly: if the file's already on disk from an earlier session, just // Resume-friendly: if the file's already on disk from an earlier session, just
// index it without re-downloading. // index it without re-downloading.
if (Files.exists(finalPath) && Files.size(finalPath) > 0) { if (Files.exists(finalPath) && Files.size(finalPath) > 0) {
// Pre-publish check — bail if clearAll has run since submit.
if (CACHE_EPOCH.get() != startEpoch) return; if (CACHE_EPOCH.get() != startEpoch) return;
READY.put(url, finalPath); READY.put(url, finalPath);
// Post-publish re-check: same window as the post-move check on the download
// path. If clearAll landed between the epoch read above and the READY.put,
// it would have wiped READY + the file, and our put() resurrected a stale
// entry pointing at a now-deleted path. Roll it back: remove our entry
// (compareAndRemove via remove(key, value) to avoid clobbering a concurrent
// legitimate re-put) and best-effort delete the file.
if (CACHE_EPOCH.get() != startEpoch) {
READY.remove(url, finalPath);
try { Files.deleteIfExists(finalPath); } catch (Throwable ignored) {}
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}", VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName()); VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN); notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
@@ -340,11 +358,10 @@ public final class VideoCache {
return; return;
} }
// Final cancellation check before publishing. If clearAll ran during the read // Pre-move cancellation check. If clearAll ran during the read loop, abort
// loop's last iteration (after the loop check, before this point), we still // before promoting .part to final — saves a wasted move + delete.
// refuse to publish — otherwise we'd resurrect a cache entry post-clear.
if (CACHE_EPOCH.get() != startEpoch) { if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}", VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url); VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {} try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
return; return;
@@ -352,6 +369,21 @@ public final class VideoCache {
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING, Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE); StandardCopyOption.ATOMIC_MOVE);
// Post-move cancellation check. clearAll() may have run between the pre-move
// check above and the move itself — in that case clearAll's directory scan
// either missed our file (it didn't exist yet) or saw the .part and skipped /
// failed to delete it. Either way, finalPath now exists on disk but the
// user-visible cache state is "cleared", so we must delete the file and skip
// both the READY.put and the "완료" chat. Without this check, a clear right at
// this window leaves a resurrected file in READY and a stale "완료" message.
if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled after move (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(finalPath); } catch (Throwable ignored) {}
return;
}
READY.put(url, finalPath); READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}", VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}",
VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName()); VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName());