Skip to content

Commit a88bcdd

Browse files
committed
fix(cli): address review findings on the update config surface
The unrecognized-mode warning was printed inside resolve(), before the precedence rules it sits behind had been applied, so it escaped onto stderr during --format json, in CI, with a piped stderr and under --quiet. It is now held on the policy and emitted at the one point where the run is known to be speaking at all. The notify interval is keyed to the announced version rather than to time alone. Time alone swallowed the NEXT release whenever it landed inside the window, so a week-long interval set to stop nagging about one version also hid the version that fixed it -- the opposite of what the setting says it does. The interval comparison is unsigned. Duration::as_secs() as i64 can go negative for a large enough configured value, and a negative interval compares as already-elapsed, which would notify every run. Cache writes survive a second perry process. Each write now uses its own temporary file instead of one shared name that two writers rename over each other, leaving a file the winner is still writing. The read-modify-write pairs are serialized by a lock file, and a refresh re-reads the notice state inside that lock rather than using a value read before its request went out -- which would otherwise drop a notice recorded while the request was in flight and tell the user twice.
1 parent 690063a commit a88bcdd

4 files changed

Lines changed: 178 additions & 27 deletions

File tree

changelog.d/7749-update-config-surface.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,18 @@ erasure regression itself. Verified by sabotage — marking the new field
9999
`#[serde(skip)]` turns the erasure test red.
100100

101101
`cargo test -p perry`: 902 passed, 0 failed.
102+
103+
**Review follow-ups.** The unrecognized-mode warning is now held on the policy
104+
and printed only once the run is known to be speaking, so it can no longer
105+
appear during `--format json`, in CI, with a piped stderr, or under `--quiet`
106+
the precedence rules exist to keep those runs silent and the warning was
107+
escaping them. The notify interval is keyed to the announced VERSION, not only
108+
to time: throttling on time alone swallowed the next release whenever it landed
109+
inside the window, so a long interval set to stop nagging about one version also
110+
hid the version that fixed it. The interval comparison is unsigned, because
111+
`Duration::as_secs() as i64` can go negative and a negative interval reads as
112+
already-elapsed. And the cache write is safe against a second `perry`: each
113+
write uses its own temporary file rather than one shared name that two writers
114+
would rename over each other, and the read-modify-write pairs are serialized by
115+
a lock file, with a refresh re-reading the notice state inside the lock instead
116+
of using a value read before its request went out.

crates/perry/src/main.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,11 @@ 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();
546551
let status = if let Some(rx) = bg_check {
547552
rx.recv_timeout(std::time::Duration::from_millis(100)).ok()
@@ -558,10 +563,14 @@ fn main_inner() -> Result<()> {
558563
// `notify_interval_hours` throttles repeats of the SAME available
559564
// update. It defaults to 0 — a notice every run, which is what
560565
// 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);
566+
let cached = update_checker::load_cache();
562567
if update_policy::should_notify(
563568
update_policy.notify_interval,
564-
last.as_deref(),
569+
cached.as_ref().and_then(|c| c.last_notification.as_deref()),
570+
cached
571+
.as_ref()
572+
.and_then(|c| c.last_notified_version.as_deref()),
573+
&latest,
565574
&update_checker::now_rfc3339_public(),
566575
) {
567576
update_checker::print_update_notice(
@@ -570,7 +579,7 @@ fn main_inner() -> Result<()> {
570579
&release_url,
571580
use_stderr_color,
572581
);
573-
update_checker::record_notification();
582+
update_checker::record_notification(&latest);
574583
}
575584
}
576585
}

crates/perry/src/update_checker.rs

Lines changed: 53 additions & 8 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,38 @@ 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+
lock.lock().ok()?;
136+
Some(lock)
137+
}
138+
103139
/// Record that the user has just been told about an available update.
104140
///
105141
/// A no-op when there is no cache: the notice can only have come from one, and
106142
/// inventing a file here would fabricate a `last_check` that never happened.
107-
pub fn record_notification() {
143+
pub fn record_notification(version: &str) {
144+
let _guard = lock_cache();
108145
let Some(mut cache) = load_cache() else {
109146
return;
110147
};
111148
cache.last_notification = Some(now_rfc3339());
149+
cache.last_notified_version = Some(version.to_string());
112150
save_cache(&cache);
113151
}
114152

@@ -347,16 +385,22 @@ fn fetch_latest_version() -> Result<UpdateCache> {
347385
));
348386
continue;
349387
}
388+
// Re-read the notice state INSIDE the lock rather than
389+
// before the request. This struct is rebuilt from scratch,
390+
// and a notice recorded while the request was in flight
391+
// would otherwise be overwritten with the stale value read
392+
// minutes earlier — telling the user twice about the same
393+
// release.
394+
let _guard = lock_cache();
395+
let prior = load_cache();
350396
let cache = UpdateCache {
351397
last_check: now_rfc3339(),
352398
latest_version: version,
353399
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(),
400+
last_notification: prior.as_ref().and_then(|c| c.last_notification.clone()),
401+
last_notified_version: prior
402+
.as_ref()
403+
.and_then(|c| c.last_notified_version.clone()),
360404
};
361405
save_cache(&cache);
362406
return Ok(cache);
@@ -1553,6 +1597,7 @@ mod tests {
15531597
latest_version: "0.2.171".to_string(),
15541598
release_url: "https://github.com/PerryTS/perry/releases/tag/v0.2.171".to_string(),
15551599
last_notification: Some("2025-01-15T11:00:00Z".to_string()),
1600+
last_notified_version: Some("0.2.171".to_string()),
15561601
};
15571602

15581603
let json = serde_json::to_string(&cache).unwrap();

crates/perry/src/update_policy.rs

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ pub(crate) struct UpdatePolicy {
124124
pub(crate) check_interval: Duration,
125125
pub(crate) notify_interval: Duration,
126126
pub(crate) prompt_default: bool,
127+
/// A complaint about the config, to print only if this run is going to
128+
/// speak at all.
129+
///
130+
/// Emitting it from `resolve` would write to stderr before the precedence
131+
/// rules below have been applied — so `--format json`, `CI`, a piped stderr
132+
/// or `--quiet` would each get a stray line in the middle of output nobody
133+
/// asked to be interrupted. The whole point of those rules is that this run
134+
/// stays silent.
135+
pub(crate) config_warning: Option<&'static str>,
127136
}
128137

129138
/// The environment inputs, gathered in one place so the decision itself is a
@@ -219,20 +228,21 @@ impl UpdatePolicy {
219228
let config = crate::commands::publish::load_config()
220229
.update
221230
.unwrap_or_default();
222-
if matches!(config.mode, Some(UpdateMode::Unknown)) {
223-
// One line, once, on the way past. Loud enough to fix, quiet
224-
// enough not to be the thing the user remembers about the run.
225-
eprintln!(
226-
"warning: unrecognized `[update] mode` in ~/.perry/config.toml; \
227-
using \"notify\". Valid values: off, notify, prompt, auto."
228-
);
229-
}
231+
// Held, not printed. Emitting here would write to stderr before the
232+
// precedence rules above have been applied, so a `--format json` run,
233+
// a CI job, a piped stderr or `--quiet` would each get a stray line in
234+
// the middle of output nobody asked to have interrupted.
235+
let config_warning = matches!(config.mode, Some(UpdateMode::Unknown)).then_some(
236+
"warning: unrecognized `[update] mode` in ~/.perry/config.toml; using \
237+
\"notify\". Valid values: off, notify, prompt, auto.",
238+
);
230239

231240
Self {
232241
mode: resolve_mode(env, config.mode),
233242
check_interval: config.check_interval(),
234243
notify_interval: config.notify_interval(),
235244
prompt_default: config.prompt_default.unwrap_or(false),
245+
config_warning,
236246
}
237247
}
238248

@@ -269,11 +279,21 @@ fn structured_output_selected() -> bool {
269279
pub(crate) fn should_notify(
270280
notify_interval: Duration,
271281
last_notification: Option<&str>,
282+
last_notified_version: Option<&str>,
283+
latest: &str,
272284
now_rfc3339: &str,
273285
) -> bool {
274286
if notify_interval.is_zero() {
275287
return true;
276288
}
289+
// The interval throttles repeats of the SAME update, which is what it has
290+
// always said it does. Keying it on time alone silently swallowed the next
291+
// release whenever it arrived inside the window — so a user who set a
292+
// week-long interval to stop being nagged about one version would also miss
293+
// the one that fixed it.
294+
if last_notified_version != Some(latest) {
295+
return true;
296+
}
277297
let (Some(last), Some(now)) = (
278298
crate::update_checker::parse_rfc3339(last_notification.unwrap_or("")),
279299
crate::update_checker::parse_rfc3339(now_rfc3339),
@@ -283,7 +303,7 @@ pub(crate) fn should_notify(
283303
// cache would hide updates indefinitely.
284304
return true;
285305
};
286-
now.saturating_sub(last) >= notify_interval.as_secs() as i64
306+
now.saturating_sub(last).max(0) as u64 >= notify_interval.as_secs()
287307
}
288308

289309
#[cfg(test)]
@@ -454,10 +474,20 @@ mod tests {
454474
);
455475
}
456476

477+
/// The interval alone, with the announced version held constant — which is
478+
/// what these cases were written to exercise.
479+
fn notified_before(interval: Duration, last_notification: Option<&str>, now: &str) -> bool {
480+
should_notify(interval, last_notification, Some("1.0.0"), "1.0.0", now)
481+
}
482+
457483
#[test]
458484
fn the_notify_throttle_defaults_to_every_run() {
459-
assert!(should_notify(Duration::ZERO, None, "2026-08-10T00:00:00Z"));
460-
assert!(should_notify(
485+
assert!(notified_before(
486+
Duration::ZERO,
487+
None,
488+
"2026-08-10T00:00:00Z"
489+
));
490+
assert!(notified_before(
461491
Duration::ZERO,
462492
Some("2026-08-10T00:00:00Z"),
463493
"2026-08-10T00:00:01Z"
@@ -468,27 +498,79 @@ mod tests {
468498
fn the_notify_throttle_honours_its_interval() {
469499
let day = Duration::from_secs(24 * 3600);
470500
assert!(
471-
!should_notify(day, Some("2026-08-10T00:00:00Z"), "2026-08-10T01:00:00Z"),
501+
!notified_before(day, Some("2026-08-10T00:00:00Z"), "2026-08-10T01:00:00Z"),
472502
"an hour into a one-day throttle must stay quiet"
473503
);
474504
assert!(
475-
should_notify(day, Some("2026-08-09T00:00:00Z"), "2026-08-10T01:00:00Z"),
505+
notified_before(day, Some("2026-08-09T00:00:00Z"), "2026-08-10T01:00:00Z"),
476506
"past the interval it must speak up"
477507
);
478508
}
479509

510+
/// ★ The interval throttles repeats of the SAME update, which is what it
511+
/// always claimed. Keyed on time alone it swallowed the NEXT release
512+
/// whenever that landed inside the window — so a week-long interval set to
513+
/// stop nagging about one version would also hide the version that fixed
514+
/// it.
515+
#[test]
516+
fn a_different_version_is_announced_regardless_of_the_interval() {
517+
let week = Duration::from_secs(7 * 24 * 3600);
518+
// One minute into a week-long throttle: the same version stays quiet...
519+
assert!(!should_notify(
520+
week,
521+
Some("2026-08-10T00:00:00Z"),
522+
Some("1.0.0"),
523+
"1.0.0",
524+
"2026-08-10T00:01:00Z"
525+
));
526+
// ...and a different one is announced anyway.
527+
assert!(should_notify(
528+
week,
529+
Some("2026-08-10T00:00:00Z"),
530+
Some("1.0.0"),
531+
"1.0.1",
532+
"2026-08-10T00:01:00Z"
533+
));
534+
// Never having announced anything is also "not this version".
535+
assert!(should_notify(
536+
week,
537+
Some("2026-08-10T00:00:00Z"),
538+
None,
539+
"1.0.0",
540+
"2026-08-10T00:01:00Z"
541+
));
542+
}
543+
544+
/// An enormous configured interval must still suppress, not wrap around
545+
/// into announcing every run. `as i64` on a `Duration`'s seconds can go
546+
/// negative, and a negative interval compares as "already elapsed".
547+
#[test]
548+
fn an_enormous_interval_still_suppresses() {
549+
let absurd = Duration::from_secs(u64::MAX);
550+
assert!(
551+
!should_notify(
552+
absurd,
553+
Some("2026-08-10T00:00:00Z"),
554+
Some("1.0.0"),
555+
"1.0.0",
556+
"2026-08-10T00:01:00Z"
557+
),
558+
"a signed conversion here would read as already-elapsed and notify"
559+
);
560+
}
561+
480562
/// A cache this build cannot read must not silence the notice forever —
481563
/// that would turn one bad write into a permanently muted checker.
482564
#[test]
483565
fn an_unreadable_timestamp_notifies_rather_than_staying_silent() {
484566
let day = Duration::from_secs(24 * 3600);
485-
assert!(should_notify(day, None, "2026-08-10T00:00:00Z"));
486-
assert!(should_notify(
567+
assert!(notified_before(day, None, "2026-08-10T00:00:00Z"));
568+
assert!(notified_before(
487569
day,
488570
Some("not-a-date"),
489571
"2026-08-10T00:00:00Z"
490572
));
491-
assert!(should_notify(
573+
assert!(notified_before(
492574
day,
493575
Some("2026-08-10T00:00:00Z"),
494576
"also-not-a-date"

0 commit comments

Comments
 (0)