Skip to content

Commit 98d9ee5

Browse files
authored
Merge pull request #414 from Splode/bugfix/prime-elapsed-clamp
fix(timer): clamp Prime duration to prevent spurious completion
2 parents 4d5f3df + b2fa341 commit 98d9ee5

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## [Unreleased]
22

3+
### Bug Fixes
4+
5+
- **Timer not restarting correctly after quickly starting the next round** — when a round completed and the user clicked Start before the engine's follow-up duration update arrived, the update (a `Reconfigure` command) would force the engine back to Idle, cancelling the freshly started timer. The follow-up is now sent as a lighter-weight `Prime` command that updates the stored duration in place without affecting the running phase. Contributed by [@SeanTong11](https://github.com/SeanTong11).
6+
- **Timer completing instantly when a stale duration update arrives mid-round** — in a rare race, the engine could receive a `Prime` command carrying a duration shorter than the already-elapsed time (e.g. if the round duration was shortened in settings while a timer was running). Without a guard this caused the timer to complete on the very next tick. The `Prime` handler now clamps the new duration to at least one tick beyond the current elapsed position so the timer always advances at least once before completing.
7+
38
## [v1.6.0] - 2026-04-27
49

510
### System Tray

src-tauri/src/timer/engine.rs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ fn run_loop(
174174
Transition::To(Phase::Idle)
175175
}
176176
Ok(TimerCommand::Prime { duration_secs: d }) => {
177-
total_secs = d;
177+
// Clamp so a stale Prime never causes immediate completion
178+
// on the next Resume tick.
179+
total_secs = d.max(elapsed_secs.saturating_add(1));
178180
Transition::Stay
179181
}
180182
Ok(TimerCommand::Shutdown) | Err(_) => Transition::Break,
@@ -230,7 +232,10 @@ fn run_loop(
230232
Transition::To(Phase::Idle)
231233
}
232234
Ok(TimerCommand::Prime { duration_secs: d }) => {
233-
total_secs = d;
235+
// Clamp so a stale Prime arriving while the timer is
236+
// running never causes an immediate spurious completion
237+
// on the next tick.
238+
total_secs = d.max(elapsed_secs.saturating_add(1));
234239
Transition::Stay
235240
}
236241
Ok(TimerCommand::Shutdown) => Transition::Break,
@@ -524,6 +529,65 @@ mod tests {
524529
);
525530
}
526531

532+
#[test]
533+
fn prime_below_elapsed_while_running_does_not_complete_immediately() {
534+
// If Prime fires with duration_secs < elapsed_secs while the timer is
535+
// running, the engine must not complete on the very next tick — it
536+
// should run at least one more tick first.
537+
let (handle, rx) = spawn(10, TICK);
538+
handle.send(TimerCommand::Start);
539+
// Let 5 ticks fire so elapsed_secs = 5.
540+
std::thread::sleep(TICK * 5 + TICK / 2);
541+
// Prime with duration below elapsed — without the clamp this would fire
542+
// Complete on the very next tick.
543+
handle.send(TimerCommand::Prime { duration_secs: 2 });
544+
545+
let events = collect_until_complete(&rx, Duration::from_secs(2));
546+
let ticks_after_prime: Vec<_> = events
547+
.iter()
548+
.filter(|e| matches!(e, TimerEvent::Tick { elapsed_secs, .. } if *elapsed_secs > 5))
549+
.collect();
550+
assert!(
551+
!ticks_after_prime.is_empty(),
552+
"at least one tick must fire after Prime before Complete"
553+
);
554+
assert!(
555+
matches!(events.last(), Some(TimerEvent::Complete { .. })),
556+
"timer must still complete after clamped Prime"
557+
);
558+
}
559+
560+
#[test]
561+
fn prime_below_elapsed_while_paused_does_not_complete_immediately_on_resume() {
562+
// Same edge case in Paused phase: Prime with duration_secs < elapsed_secs
563+
// must not cause immediate completion on the first tick after Resume.
564+
let (handle, rx) = spawn(10, TICK);
565+
handle.send(TimerCommand::Start);
566+
// Let 5 ticks fire, then pause.
567+
std::thread::sleep(TICK * 5 + TICK / 2);
568+
handle.send(TimerCommand::Pause);
569+
std::thread::sleep(TICK); // let Paused event arrive
570+
drain(&rx);
571+
572+
// Prime with duration below elapsed.
573+
handle.send(TimerCommand::Prime { duration_secs: 2 });
574+
handle.send(TimerCommand::Resume);
575+
576+
let events = collect_until_complete(&rx, Duration::from_secs(2));
577+
let ticks_after_resume: Vec<_> = events
578+
.iter()
579+
.filter(|e| matches!(e, TimerEvent::Tick { elapsed_secs, .. } if *elapsed_secs > 5))
580+
.collect();
581+
assert!(
582+
!ticks_after_resume.is_empty(),
583+
"at least one tick must fire after Resume before Complete"
584+
);
585+
assert!(
586+
matches!(events.last(), Some(TimerEvent::Complete { .. })),
587+
"timer must still complete after clamped Prime in Paused phase"
588+
);
589+
}
590+
527591
#[test]
528592
fn drift_complete_within_tolerance() {
529593
// 5 ticks at TICK (20 ms) = nominal 100 ms.

0 commit comments

Comments
 (0)