@@ -33,6 +33,14 @@ pub struct UpdateCache {
3333 /// always was.
3434 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
3535 pub last_notification : Option < String > ,
36+ /// Which version that notice was about.
37+ ///
38+ /// Without this the notify interval throttles on time alone, which
39+ /// swallows the NEXT release when it lands inside the window — so a
40+ /// week-long interval set to stop nagging about one version would also hide
41+ /// the one that fixed it.
42+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
43+ pub last_notified_version : Option < String > ,
3644}
3745
3846#[ derive( Debug , Deserialize ) ]
@@ -90,7 +98,14 @@ fn save_cache(cache: &UpdateCache) {
9098 // `replace_path` rather than `fs::rename`: on Windows a rename onto an
9199 // EXISTING file fails, so every write after the first would silently do
92100 // nothing and the throttle would never advance.
93- let tmp = path. with_extension ( "json.tmp" ) ;
101+ // A per-write name. With one shared `*.json.tmp`, two `perry` processes
102+ // each write it and each rename it: the loser's rename lands a file the
103+ // winner is still writing into, and the cache ends up truncated or mixed.
104+ let tmp = path. with_extension ( format ! (
105+ "json.tmp.{}.{}" ,
106+ std:: process:: id( ) ,
107+ NEXT_TMP . fetch_add( 1 , std:: sync:: atomic:: Ordering :: Relaxed )
108+ ) ) ;
94109 if fs:: write ( & tmp, content) . is_err ( ) {
95110 let _ = fs:: remove_file ( & tmp) ;
96111 return ;
@@ -100,15 +115,44 @@ fn save_cache(cache: &UpdateCache) {
100115 }
101116}
102117
118+ /// Distinguishes the temporary files of concurrent writes in one process.
119+ static NEXT_TMP : std:: sync:: atomic:: AtomicU64 = std:: sync:: atomic:: AtomicU64 :: new ( 0 ) ;
120+
121+ /// Take the cross-process lock guarding read-modify-write of the cache.
122+ ///
123+ /// A background refresh and a notice can be recorded at the same moment, and
124+ /// each is a load-mutate-store: without a lock the later store overwrites the
125+ /// earlier one's field, so a notice recorded while a request was in flight
126+ /// vanishes and the user is told twice. Returns `None` when the lock cannot be
127+ /// taken, in which case the caller proceeds unlocked — losing a cache update is
128+ /// better than refusing to update a cache.
129+ fn lock_cache ( ) -> Option < fslock:: LockFile > {
130+ let path = cache_path ( ) . with_extension ( "json.lock" ) ;
131+ if let Some ( parent) = path. parent ( ) {
132+ let _ = fs:: create_dir_all ( parent) ;
133+ }
134+ let mut lock = fslock:: LockFile :: open ( & path) . ok ( ) ?;
135+ // `try_lock`, NOT `lock`. This runs at teardown, after the command the user
136+ // asked for has finished — so blocking here would hang their terminal on
137+ // another `perry`'s cache write, for a cache. The doc above promises we
138+ // proceed unlocked rather than wait, and `lock()` did not honour it.
139+ match lock. try_lock ( ) {
140+ Ok ( true ) => Some ( lock) ,
141+ _ => None ,
142+ }
143+ }
144+
103145/// Record that the user has just been told about an available update.
104146///
105147/// A no-op when there is no cache: the notice can only have come from one, and
106148/// inventing a file here would fabricate a `last_check` that never happened.
107- pub fn record_notification ( ) {
149+ pub fn record_notification ( version : & str ) {
150+ let _guard = lock_cache ( ) ;
108151 let Some ( mut cache) = load_cache ( ) else {
109152 return ;
110153 } ;
111154 cache. last_notification = Some ( now_rfc3339 ( ) ) ;
155+ cache. last_notified_version = Some ( version. to_string ( ) ) ;
112156 save_cache ( & cache) ;
113157}
114158
@@ -329,7 +373,6 @@ fn fetch_latest_version() -> Result<UpdateCache> {
329373
330374 let servers = get_update_servers ( ) ;
331375 let mut last_err = None ;
332- let prior_notification = load_cache ( ) . and_then ( |c| c. last_notification ) ;
333376
334377 for url in & servers {
335378 match client. get ( url) . send ( ) {
@@ -347,16 +390,22 @@ fn fetch_latest_version() -> Result<UpdateCache> {
347390 ) ) ;
348391 continue ;
349392 }
393+ // Re-read the notice state INSIDE the lock rather than
394+ // before the request. This struct is rebuilt from scratch,
395+ // and a notice recorded while the request was in flight
396+ // would otherwise be overwritten with the stale value read
397+ // minutes earlier — telling the user twice about the same
398+ // release.
399+ let _guard = lock_cache ( ) ;
400+ let prior = load_cache ( ) ;
350401 let cache = UpdateCache {
351402 last_check : now_rfc3339 ( ) ,
352403 latest_version : version,
353404 release_url : info. html_url ,
354- // Carry the notice timestamp across the refresh. This
355- // struct is rebuilt from scratch, so dropping the field
356- // here would reset the notify throttle on every check
357- // and `notify_interval_hours` would silently do nothing
358- // beyond one check interval.
359- last_notification : prior_notification. clone ( ) ,
405+ last_notification : prior. as_ref ( ) . and_then ( |c| c. last_notification . clone ( ) ) ,
406+ last_notified_version : prior
407+ . as_ref ( )
408+ . and_then ( |c| c. last_notified_version . clone ( ) ) ,
360409 } ;
361410 save_cache ( & cache) ;
362411 return Ok ( cache) ;
@@ -429,14 +478,24 @@ pub fn print_update_notice(current: &str, latest: &str, url: &str, use_color: bo
429478 current,
430479 console:: style( latest) . green( ) . bold( ) ,
431480 ) ;
432- eprintln ! (
433- " Run {} to update, or visit {}" ,
434- console:: style( "perry update" ) . cyan( ) ,
435- url,
436- ) ;
481+ // A custom manifest may carry only `version`, and "or visit " with
482+ // nothing after it reads like a bug.
483+ if url. is_empty ( ) {
484+ eprintln ! ( " Run {} to update" , console:: style( "perry update" ) . cyan( ) ) ;
485+ } else {
486+ eprintln ! (
487+ " Run {} to update, or visit {}" ,
488+ console:: style( "perry update" ) . cyan( ) ,
489+ url,
490+ ) ;
491+ }
437492 } else {
438493 eprintln ! ( "\n Update: {} -> {} available" , current, latest) ;
439- eprintln ! ( " Run `perry update` to update, or visit {}" , url) ;
494+ if url. is_empty ( ) {
495+ eprintln ! ( " Run `perry update` to update" ) ;
496+ } else {
497+ eprintln ! ( " Run `perry update` to update, or visit {}" , url) ;
498+ }
440499 }
441500}
442501
@@ -1553,6 +1612,7 @@ mod tests {
15531612 latest_version : "0.2.171" . to_string ( ) ,
15541613 release_url : "https://github.com/PerryTS/perry/releases/tag/v0.2.171" . to_string ( ) ,
15551614 last_notification : Some ( "2025-01-15T11:00:00Z" . to_string ( ) ) ,
1615+ last_notified_version : Some ( "0.2.171" . to_string ( ) ) ,
15561616 } ;
15571617
15581618 let json = serde_json:: to_string ( & cache) . unwrap ( ) ;
0 commit comments