Skip to content

Commit f54773b

Browse files
committed
fix(cli): stop the update surface from erasing config, hanging teardown, or hiding a notice
The [update] table in ~/.perry/config.toml was silently erased on every save because it was not a field on the struct the saver serializes. The teardown cache write took a blocking lock in a path documented to proceed unlocked, so one perry could hang another's terminal. A background check that had not answered within its 100 ms budget was read as "no update", throwing away a notice the previous run had already earned.
1 parent b9415d7 commit f54773b

5 files changed

Lines changed: 250 additions & 40 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
### Fixed
2+
3+
Five defects in the update surface, all found in review of
4+
[#7749](https://github.com/PerryTS/perry/pull/7749) after it had merged.
5+
6+
**The config warning escaped the rules that were meant to silence it.** The
7+
"unrecognized `[update] mode`" line was printed inside `UpdatePolicy::resolve`,
8+
before the precedence rules it sits behind had been applied — so it reached
9+
stderr during `--format json`, in CI, with a piped stderr, and under `--quiet`.
10+
Those rules exist to keep exactly those runs silent, and the one line whose job
11+
was to report a config problem was the one line ignoring them. It is now held on
12+
the policy and emitted at the single point where the run is known to be speaking
13+
at all.
14+
15+
**The notify interval throttled on time alone, so it swallowed the next
16+
release.** The documented contract is that the interval throttles repeats of
17+
*the same* update. Keyed only on a timestamp, it also suppressed a **different**
18+
version that arrived inside the window — so somebody setting a week-long
19+
interval to stop being nagged about one release would also have been denied the
20+
release that fixed it. The cache now records which version it announced, and a
21+
different version is announced regardless of the interval.
22+
23+
**The interval comparison was signed.** `Duration::as_secs() as i64` goes
24+
negative for a large enough configured value, and a negative interval reads as
25+
already-elapsed — so an absurd value would have notified on *every* run instead
26+
of suppressing. The comparison is unsigned.
27+
28+
**Two `perry` processes could corrupt the cache.** Every write used one shared
29+
`*.json.tmp`, so two writers each wrote it and each renamed it: the loser's
30+
rename landed a file the winner was still writing into. Each write now builds
31+
its own temporary name.
32+
33+
**A refresh could erase a notice recorded while its request was in flight.**
34+
`fetch_latest_version` read the notice state *before* issuing its request and
35+
wrote it back afterwards, overwriting anything recorded in between — telling the
36+
user about the same release twice. The read-modify-write pairs are now
37+
serialized by a lock file, and the refresh re-reads inside that lock immediately
38+
before replacing.
39+
40+
<details>
41+
<summary><b>Tests</b></summary>
42+
43+
Two new contract tests, both sabotage-verified — reverting either fix turns its
44+
test red:
45+
46+
- a different version is announced regardless of the interval, and never having
47+
announced anything counts as "not this version";
48+
- an enormous interval still suppresses rather than wrapping into notifying.
49+
50+
The existing interval tests are unchanged in intent: they now go through a
51+
helper that holds the announced version constant, so they still exercise only
52+
the interval arithmetic.
53+
54+
`cargo test -p perry`: 904 passed, 0 failed.
55+
</details>

crates/perry/src/commands/update.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ pub fn run(
6868
} else {
6969
println!("Update available: {} -> {}", cur, latest);
7070
}
71-
println!(" Release: {}", release_url);
71+
if !release_url.is_empty() {
72+
println!(" Release: {}", release_url);
73+
}
7274
}
7375
OutputFormat::Text => {}
7476
}

crates/perry/src/main.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -542,12 +542,19 @@ fn main_inner() -> Result<()> {
542542

543543
// Print update notice if available (to stderr, non-blocking)
544544
if update_surface_active {
545+
// The config complaint, if any, goes out only now — this is the first
546+
// point at which we know the run is allowed to say anything at all.
547+
if let Some(warning) = update_policy.config_warning {
548+
eprintln!("{warning}");
549+
}
545550
let use_stderr_color = !cli.no_color && std::io::stderr().is_terminal();
546-
let status = if let Some(rx) = bg_check {
547-
rx.recv_timeout(std::time::Duration::from_millis(100)).ok()
548-
} else {
549-
Some(update_checker::check_cached_status())
550-
};
551+
// A background check that has not answered within 100 ms falls back to
552+
// the cache rather than saying nothing. Reading the timeout as "no
553+
// update" suppressed a notice the previous run had already earned — the
554+
// check being slow is not evidence that the version is current.
555+
let status = bg_check
556+
.and_then(|rx| rx.recv_timeout(std::time::Duration::from_millis(100)).ok())
557+
.or_else(|| Some(update_checker::check_cached_status()));
551558

552559
if let Some(update_checker::UpdateStatus::UpdateAvailable {
553560
current,
@@ -558,10 +565,14 @@ fn main_inner() -> Result<()> {
558565
// `notify_interval_hours` throttles repeats of the SAME available
559566
// update. It defaults to 0 — a notice every run, which is what
560567
// Perry did before — so this is inert until someone asks for it.
561-
let last = update_checker::load_cache().and_then(|c| c.last_notification);
568+
let cached = update_checker::load_cache();
562569
if update_policy::should_notify(
563570
update_policy.notify_interval,
564-
last.as_deref(),
571+
cached.as_ref().and_then(|c| c.last_notification.as_deref()),
572+
cached
573+
.as_ref()
574+
.and_then(|c| c.last_notified_version.as_deref()),
575+
&latest,
565576
&update_checker::now_rfc3339_public(),
566577
) {
567578
update_checker::print_update_notice(
@@ -570,7 +581,7 @@ fn main_inner() -> Result<()> {
570581
&release_url,
571582
use_stderr_color,
572583
);
573-
update_checker::record_notification();
584+
update_checker::record_notification(&latest);
574585
}
575586
}
576587
}

crates/perry/src/update_checker.rs

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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!("\nUpdate: {} -> {} 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

Comments
 (0)