Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## [Unreleased]

### Bug Fixes

- **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).
- **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.

## [v1.6.0] - 2026-04-27

### System Tray
Expand Down
68 changes: 66 additions & 2 deletions src-tauri/src/timer/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ fn run_loop(
Transition::To(Phase::Idle)
}
Ok(TimerCommand::Prime { duration_secs: d }) => {
total_secs = d;
// Clamp so a stale Prime never causes immediate completion
// on the next Resume tick.
total_secs = d.max(elapsed_secs.saturating_add(1));
Transition::Stay
}
Ok(TimerCommand::Shutdown) | Err(_) => Transition::Break,
Expand Down Expand Up @@ -230,7 +232,10 @@ fn run_loop(
Transition::To(Phase::Idle)
}
Ok(TimerCommand::Prime { duration_secs: d }) => {
total_secs = d;
// Clamp so a stale Prime arriving while the timer is
// running never causes an immediate spurious completion
// on the next tick.
total_secs = d.max(elapsed_secs.saturating_add(1));
Transition::Stay
}
Ok(TimerCommand::Shutdown) => Transition::Break,
Expand Down Expand Up @@ -524,6 +529,65 @@ mod tests {
);
}

#[test]
fn prime_below_elapsed_while_running_does_not_complete_immediately() {
// If Prime fires with duration_secs < elapsed_secs while the timer is
// running, the engine must not complete on the very next tick — it
// should run at least one more tick first.
let (handle, rx) = spawn(10, TICK);
handle.send(TimerCommand::Start);
// Let 5 ticks fire so elapsed_secs = 5.
std::thread::sleep(TICK * 5 + TICK / 2);
// Prime with duration below elapsed — without the clamp this would fire
// Complete on the very next tick.
handle.send(TimerCommand::Prime { duration_secs: 2 });

let events = collect_until_complete(&rx, Duration::from_secs(2));
let ticks_after_prime: Vec<_> = events
.iter()
.filter(|e| matches!(e, TimerEvent::Tick { elapsed_secs, .. } if *elapsed_secs > 5))
.collect();
assert!(
!ticks_after_prime.is_empty(),
"at least one tick must fire after Prime before Complete"
);
assert!(
matches!(events.last(), Some(TimerEvent::Complete { .. })),
"timer must still complete after clamped Prime"
);
}

#[test]
fn prime_below_elapsed_while_paused_does_not_complete_immediately_on_resume() {
// Same edge case in Paused phase: Prime with duration_secs < elapsed_secs
// must not cause immediate completion on the first tick after Resume.
let (handle, rx) = spawn(10, TICK);
handle.send(TimerCommand::Start);
// Let 5 ticks fire, then pause.
std::thread::sleep(TICK * 5 + TICK / 2);
handle.send(TimerCommand::Pause);
std::thread::sleep(TICK); // let Paused event arrive
drain(&rx);

// Prime with duration below elapsed.
handle.send(TimerCommand::Prime { duration_secs: 2 });
handle.send(TimerCommand::Resume);

let events = collect_until_complete(&rx, Duration::from_secs(2));
let ticks_after_resume: Vec<_> = events
.iter()
.filter(|e| matches!(e, TimerEvent::Tick { elapsed_secs, .. } if *elapsed_secs > 5))
.collect();
assert!(
!ticks_after_resume.is_empty(),
"at least one tick must fire after Resume before Complete"
);
assert!(
matches!(events.last(), Some(TimerEvent::Complete { .. })),
"timer must still complete after clamped Prime in Paused phase"
);
}

#[test]
fn drift_complete_within_tolerance() {
// 5 ticks at TICK (20 ms) = nominal 100 ms.
Expand Down
Loading