@@ -30,25 +30,54 @@ const COVER_USER_AGENT: &str = concat!(
3030
3131static 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
3642pub 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
4654impl 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 表
6291struct 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) {
203232enum 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/// 成功时返回图片字节,并已写入磁盘缓存
359413async 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 ) ) ;
0 commit comments