Skip to content

Commit a58561a

Browse files
committed
fix(cover): add cache generation to prevent stale download writes
Introduce a generation counter on DownloadState that increments on game deletion or cache clear. Download tasks capture the generation at start and check it at multiple points (post-download, pre-write, post-write, pre-retry) to bail out early if the cache has been invalidated. Also append updated_at as a cache-busting query param on cover URLs so the browser protocol layer does not serve stale images after a source switch.
1 parent 17f2e56 commit a58561a

2 files changed

Lines changed: 140 additions & 26 deletions

File tree

src-tauri/src/utils/game_cover.rs

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,54 @@ const COVER_USER_AGENT: &str = concat!(
3030

3131
static COVER_HTTP_CLIENT: OnceLock<Client> = OnceLock::new();
3232

33-
/// 正在下载中的任务表:key=game_id,value=watch sender(false=进行中,true=已结束)
34-
type DownloadingMap = Arc<Mutex<HashMap<u32, Arc<watch::Sender<bool>>>>>;
33+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
34+
struct DownloadKey {
35+
game_id: u32,
36+
generation: u64,
37+
}
38+
39+
/// 正在下载中的任务表:key=(game_id, generation),value=watch sender(false=进行中,true=已结束)
40+
type DownloadingMap = Arc<Mutex<HashMap<DownloadKey, Arc<watch::Sender<bool>>>>>;
3541

3642
pub struct DownloadState {
3743
semaphore: Arc<Semaphore>,
3844
/// 内存中已确认缓存完毕的 game_id 集合,避免每次请求都扫描磁盘
3945
cached_ids: Arc<RwLock<HashSet<u32>>>,
46+
/// 缓存代数:删缓存时递增,使旧下载任务失去写盘资格
47+
cache_generations: Arc<RwLock<HashMap<u32, u64>>>,
4048
/// 删除墓碑:记录已删除游戏,阻止下载任务继续写入封面
4149
tombstoned_ids: Arc<RwLock<HashSet<u32>>>,
42-
/// 正在下载中的任务;同 game_id 的后续请求订阅 watch channel 等待其完成
50+
/// 正在下载中的任务;同 game_id + generation 的后续请求订阅 watch channel 等待其完成
4351
downloading: DownloadingMap,
4452
}
4553

4654
impl DownloadState {
4755
pub async fn mark_game_deleted(&self, game_id: u32) {
56+
self.bump_cache_generation(game_id).await;
4857
self.cached_ids.write().await.remove(&game_id);
4958
self.tombstoned_ids.write().await.insert(game_id);
5059
}
5160

61+
async fn cache_generation(&self, game_id: u32) -> u64 {
62+
self.cache_generations
63+
.read()
64+
.await
65+
.get(&game_id)
66+
.copied()
67+
.unwrap_or(0)
68+
}
69+
70+
async fn bump_cache_generation(&self, game_id: u32) -> u64 {
71+
let mut generations = self.cache_generations.write().await;
72+
let generation = generations.entry(game_id).or_insert(0);
73+
*generation = generation.saturating_add(1);
74+
*generation
75+
}
76+
77+
async fn is_cache_generation_current(&self, game_id: u32, generation: u64) -> bool {
78+
self.cache_generation(game_id).await == generation
79+
}
80+
5281
async fn clear_game_deleted(&self, game_id: u32) {
5382
self.tombstoned_ids.write().await.remove(&game_id);
5483
}
@@ -61,7 +90,7 @@ impl DownloadState {
6190
/// RAII guard:下载结束时(无论成功/失败/panic/取消)自动唤醒等待者,并清理 downloading 表
6291
struct DownloadCleanupGuard {
6392
downloading: DownloadingMap,
64-
game_id: u32,
93+
key: DownloadKey,
6594
sender: Arc<watch::Sender<bool>>,
6695
}
6796

@@ -72,7 +101,7 @@ impl Drop for DownloadCleanupGuard {
72101
self.downloading
73102
.lock()
74103
.unwrap_or_else(|e| e.into_inner())
75-
.remove(&self.game_id);
104+
.remove(&self.key);
76105
}
77106
}
78107

@@ -203,6 +232,7 @@ async fn remove_file_if_exists(path: &Path) {
203232
enum CoverDownloadError {
204233
Retryable(String),
205234
GameDeleted(String),
235+
Stale(String),
206236
NonRetryable(String),
207237
}
208238

@@ -266,7 +296,8 @@ pub async fn delete_cloud_cache(
266296
let game_cover_dir = get_game_cover_dir(game_id)?;
267297
let expected_prefix = format!("{}.", cloud_cover_file_stem(game_id));
268298

269-
// 同步清理内存缓存标记
299+
// 先递增缓存代数,阻止已在途的旧下载继续写回云端缓存。
300+
state.bump_cache_generation(game_id).await;
270301
state.cached_ids.write().await.remove(&game_id);
271302

272303
if !game_cover_dir.exists() {
@@ -305,6 +336,7 @@ async fn try_download_once(
305336
url: &str,
306337
game_cover_dir: &Path,
307338
game_id: u32,
339+
generation: u64,
308340
db: &DatabaseConnection,
309341
state: &DownloadState,
310342
) -> Result<Vec<u8>, CoverDownloadError> {
@@ -332,6 +364,12 @@ async fn try_download_once(
332364
.to_vec();
333365

334366
ensure_game_cover_writable(state, db, game_id).await?;
367+
if !state.is_cache_generation_current(game_id, generation).await {
368+
return Err(CoverDownloadError::Stale(format!(
369+
"封面下载已过期 game_id={} generation={}",
370+
game_id, generation
371+
)));
372+
}
335373

336374
if let Err(e) = tokio::fs::write(&temp_path, &bytes).await {
337375
remove_file_if_exists(&temp_path).await;
@@ -341,6 +379,14 @@ async fn try_download_once(
341379
)));
342380
}
343381

382+
if !state.is_cache_generation_current(game_id, generation).await {
383+
remove_file_if_exists(&temp_path).await;
384+
return Err(CoverDownloadError::Stale(format!(
385+
"封面下载写盘前已过期 game_id={} generation={}",
386+
game_id, generation
387+
)));
388+
}
389+
344390
if let Err(e) = tokio::fs::rename(&temp_path, &cache_path).await {
345391
remove_file_if_exists(&temp_path).await;
346392
// rename 失败(如跨盘符)不阻止本次返回,bytes 仍有效
@@ -351,13 +397,22 @@ async fn try_download_once(
351397
);
352398
}
353399

400+
if !state.is_cache_generation_current(game_id, generation).await {
401+
remove_file_if_exists(&cache_path).await;
402+
return Err(CoverDownloadError::Stale(format!(
403+
"封面下载写盘后已过期 game_id={} generation={}",
404+
game_id, generation
405+
)));
406+
}
407+
354408
Ok(bytes)
355409
}
356410

357411
/// 带指数退避重试的封面下载(总尝试次数 = 1 + COVER_MAX_RETRIES)
358412
/// 成功时返回图片字节,并已写入磁盘缓存
359413
async fn fetch_and_cache_cover(
360414
game_id: u32,
415+
generation: u64,
361416
url: &str,
362417
game_cover_dir: &Path,
363418
db: &DatabaseConnection,
@@ -372,6 +427,13 @@ async fn fetch_and_cache_cover(
372427
.map_err(|e| CoverDownloadError::NonRetryable(format!("创建缓存目录失败: {}", e)))?;
373428

374429
for attempt in 0..=COVER_MAX_RETRIES {
430+
if !state.is_cache_generation_current(game_id, generation).await {
431+
return Err(CoverDownloadError::Stale(format!(
432+
"封面下载重试前已过期 game_id={} generation={}",
433+
game_id, generation
434+
)));
435+
}
436+
375437
if attempt > 0 {
376438
let delay_ms = COVER_RETRY_BASE_DELAY_MS * (1u64 << (attempt - 1));
377439
log::debug!(
@@ -384,9 +446,20 @@ async fn fetch_and_cache_cover(
384446
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
385447
}
386448

387-
match try_download_once(url, game_cover_dir, game_id, db, state).await {
449+
match try_download_once(url, game_cover_dir, game_id, generation, db, state).await {
388450
Ok(bytes) => {
389-
log::debug!("封面缓存完成 game_id={} attempt={}", game_id, attempt);
451+
if !state.is_cache_generation_current(game_id, generation).await {
452+
return Err(CoverDownloadError::Stale(format!(
453+
"封面下载返回前已过期 game_id={} generation={}",
454+
game_id, generation
455+
)));
456+
}
457+
log::debug!(
458+
"封面缓存完成 game_id={} generation={} attempt={}",
459+
game_id,
460+
generation,
461+
attempt
462+
);
390463
return Ok(bytes);
391464
}
392465
Err(CoverDownloadError::Retryable(e)) => {
@@ -409,6 +482,16 @@ async fn fetch_and_cache_cover(
409482
);
410483
return Err(CoverDownloadError::GameDeleted(e));
411484
}
485+
Err(CoverDownloadError::Stale(e)) => {
486+
log::debug!(
487+
"封面下载终止(已过期) game_id={} attempt={}/{}: {}",
488+
game_id,
489+
attempt,
490+
COVER_MAX_RETRIES,
491+
e
492+
);
493+
return Err(CoverDownloadError::Stale(e));
494+
}
412495
Err(CoverDownloadError::NonRetryable(e)) => {
413496
log::warn!(
414497
"封面下载终止(不可重试) game_id={} attempt={}/{}: {}",
@@ -435,6 +518,7 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
435518
.manage(DownloadState {
436519
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_COVER_DOWNLOADS)),
437520
cached_ids: Arc::new(RwLock::new(HashSet::new())),
521+
cache_generations: Arc::new(RwLock::new(HashMap::new())),
438522
tombstoned_ids: Arc::new(RwLock::new(HashSet::new())),
439523
downloading: Arc::new(Mutex::new(HashMap::new())),
440524
})
@@ -490,6 +574,7 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
490574
return;
491575
}
492576
Err(CoverDownloadError::Retryable(_)) => unreachable!(),
577+
Err(CoverDownloadError::Stale(_)) => unreachable!(),
493578
}
494579
}
495580

@@ -529,18 +614,41 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
529614
return;
530615
};
531616

532-
// 检查是否已有相同 game_id 的下载正在进行
533-
// 在持有锁期间订阅 watch channel,避免"通知已发但还未订阅"的竞态
617+
let generation = state.cache_generation(game_id).await;
618+
let download_key = DownloadKey {
619+
game_id,
620+
generation,
621+
};
622+
623+
// 检查是否已有相同 game_id + generation 的下载正在进行。
624+
// generation 不同表示缓存已被更新/切源操作失效,不能等待旧任务。
625+
let (tx, _) = watch::channel(false);
626+
let tx = Arc::new(tx);
534627
let existing_rx = {
535-
let downloading = state.downloading.lock().unwrap_or_else(|e| e.into_inner());
536-
downloading.get(&game_id).map(|tx| tx.subscribe())
628+
let mut downloading =
629+
state.downloading.lock().unwrap_or_else(|e| e.into_inner());
630+
if let Some(existing_tx) = downloading.get(&download_key) {
631+
Some(existing_tx.subscribe())
632+
} else {
633+
downloading.insert(download_key, tx.clone());
634+
None
635+
}
537636
};
538637

539638
if let Some(mut rx) = existing_rx {
540-
// 有下载在进行,等待其完成(watch channel 当前值已是 true 时立即返回)
541-
log::debug!("等待已有下载任务完成 game_id={}", game_id);
639+
// 有同代下载在进行,等待其完成(watch channel 当前值已是 true 时立即返回)
640+
log::debug!(
641+
"等待已有下载任务完成 game_id={} generation={}",
642+
game_id,
643+
generation
644+
);
542645
let _ = rx.wait_for(|done| *done).await;
543646

647+
if !state.is_cache_generation_current(game_id, generation).await {
648+
responder.respond(make_status_response(StatusCode::CONFLICT));
649+
return;
650+
}
651+
544652
// 下载结束后尝试读取磁盘缓存
545653
if let Some(cache_path) = get_cached_cloud_cover(&game_cover_dir, game_id).await
546654
&& let Ok(bytes) = tokio::fs::read(&cache_path).await
@@ -560,18 +668,10 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
560668
}
561669

562670
// ── 步骤 4:成为本次下载的执行者 ────────────────────────
563-
let (tx, _) = watch::channel(false);
564-
let tx = Arc::new(tx);
565-
{
566-
let mut downloading =
567-
state.downloading.lock().unwrap_or_else(|e| e.into_inner());
568-
downloading.insert(game_id, tx.clone());
569-
}
570-
571671
// RAII guard:超出作用域时自动通知等待者并清理 downloading 表
572672
let _cleanup = DownloadCleanupGuard {
573673
downloading: state.downloading.clone(),
574-
game_id,
674+
key: download_key,
575675
sender: tx,
576676
};
577677

@@ -586,8 +686,15 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
586686
};
587687

588688
// 执行下载(含指数退避重试)
589-
let fetch_result =
590-
fetch_and_cache_cover(game_id, &url, &game_cover_dir, db.inner(), &state).await;
689+
let fetch_result = fetch_and_cache_cover(
690+
game_id,
691+
generation,
692+
&url,
693+
&game_cover_dir,
694+
db.inner(),
695+
&state,
696+
)
697+
.await;
591698
match fetch_result {
592699
Ok(bytes) => {
593700
// 回填内存缓存集合
@@ -599,6 +706,9 @@ pub fn register_game_cover_protocol<R: tauri::Runtime>(
599706
log::debug!("封面下载终止 game_id={}: {}", game_id, e);
600707
responder.respond(make_status_response(StatusCode::NOT_FOUND));
601708
}
709+
Err(CoverDownloadError::Stale(_)) => {
710+
responder.respond(make_status_response(StatusCode::CONFLICT));
711+
}
602712
Err(CoverDownloadError::NonRetryable(e)) => {
603713
log::warn!("封面下载终止 game_id={}: {}", game_id, e);
604714
responder.respond(make_status_response(StatusCode::INTERNAL_SERVER_ERROR));

src/utils/game/gameDisplay.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export const getGameCover = (game: GameData): string => {
4141
type() === "windows"
4242
? "http://reina-cover.localhost"
4343
: "reina-cover://localhost";
44-
return `${base}/${game.id}?url=${game.image}`;
44+
const params = new URLSearchParams({
45+
url: game.image,
46+
v: String(game.updated_at ?? ""),
47+
});
48+
return `${base}/${game.id}?${params.toString()}`;
4549
}
4650

4751
return "/images/default.png";

0 commit comments

Comments
 (0)