Skip to content

Make dismiss_alarm safe to call twice for the same dismiss - #305

Open
ScottMorris wants to merge 3 commits into
feat/255-p4b-wear-sync-stopfrom
feat/255-p4c-dismiss-idempotency
Open

Make dismiss_alarm safe to call twice for the same dismiss#305
ScottMorris wants to merge 3 commits into
feat/255-p4b-wear-sync-stopfrom
feat/255-p4c-dismiss-idempotency

Conversation

@ScottMorris

@ScottMorris ScottMorris commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What this is

A follow-up fix, found and fixed during #301's implementation, not a pre-planned phase of #255. Making in-app dismiss publish onto the native bus uniformly (#301) means it now reaches AlarmCoordinator::dismiss_alarm twice for the same user action: once via the direct TS AlarmService.dismiss command, and once via the native alarm-manager:dismiss-requested round trip — with no shared event_id between the two paths to dedup on. dismiss_alarm was not idempotent: it recomputed next_trigger relative to whatever was currently stored, so the second call would silently advance a repeating alarm's next occurrence a second time, skipping a whole day.

What's here

  • A time-based debounce (5s), not a next_trigger-shape heuristic — an earlier attempt at this fix used "is next_trigger already in the future" as the duplicate signal, but that's indistinguishable from a legitimate case: the "dismiss upcoming alarm" notification action fires ~10 minutes before the alarm rings, while next_trigger is still in the future. That heuristic silently broke early dismiss. Time elapsed since the alarm's last successful dismissal is the signal that actually distinguishes "duplicate" from "new."
  • Per-alarm-id locking (not one global lock) — unrelated alarms' dismisses never block each other; concurrent calls for the same alarm fully serialize.
  • The debounce marker is only recorded after the operation succeeds, not before — so a transient failure doesn't poison the retry window and silently swallow a legitimate retry.
  • Monotonic clock (std::time::Instant), not wall-clock, for the debounce comparison — immune to NTP/manual clock adjustments.
  • Bounded map growth — idle, expired entries are opportunistically swept, mirroring EventDedup's own capped design.
  • Extracted AlarmInput::from_record (was duplicated between dismiss_alarm and toggle_alarm).

Known, disclosed tradeoff

The time-window debounce is a stopgap for a real plumbing gap (no shared event_id across the TS-command and native-round-trip delivery paths). The more correct long-term fix — threading a shared event_id through both paths and reusing the existing, exact-match EventDedup mechanism — would require touching already-merged files in #301. Documented in code and filed as #304 rather than expanding this fix's scope.

Testing checklist (automated, no device needed)

  • 68 threshold tests, including: normal dismiss unaffected; dismissing a still-upcoming alarm correctly skips the occurrence (the regression case); near-simultaneous duplicates via tokio::join! don't double-advance; a failed dismiss doesn't poison the retry window; one-shot double-dismiss stays safe
  • cargo check --workspace / cargo test --workspace clean, no regressions
  • cargo clippy / cargo fmt --check clean

Review status

Reviewed via /code-review high, three rounds given the correctness sensitivity of this path. First pass caught the original next_trigger-shape heuristic's regression against early dismiss. Second pass caught: no rollback on failure, a wall-clock backward-jump bug, unnecessary cross-alarm serialization, and unbounded map growth. All fixed; final pass clean.

Stacked on #302. Part of #255 (a fix arising from its implementation, not a pre-planned phase).

@ScottMorris ScottMorris added rust Rust tooling and build and test work architecture System design labels Aug 14, 2026
@ScottMorris ScottMorris changed the title feat/255 p4c dismiss idempotency Make dismiss_alarm safe to call twice for the same dismiss Aug 14, 2026
@ScottMorris ScottMorris added core Core app logic bug Something isn't working labels Aug 14, 2026
@ScottMorris
ScottMorris marked this pull request as ready for review August 14, 2026 04:44
@ScottMorris

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76f3838d57

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/threshold/src-tauri/src/alarm/mod.rs
Comment thread apps/threshold/src-tauri/src/alarm/mod.rs
@ScottMorris
ScottMorris force-pushed the feat/255-p4c-dismiss-idempotency branch from 76f3838 to 088b5f3 Compare August 18, 2026 16:56
ScottMorris and others added 3 commits August 18, 2026 13:06
Issue #255 made in-app dismiss on Android reach `AlarmCoordinator::dismiss_alarm`
through two independent paths for one user action -- the direct TS
`AlarmService.dismiss` command and the newly-uniform native round trip
(`alarm-manager:dismiss-requested`, no shared `event_id` to dedup on). Because
`dismiss_alarm` recomputed `next_trigger` relative to whatever was *currently*
stored on the alarm, a second call used the first call's already-advanced trigger
as its new reference, silently skipping a whole scheduled occurrence for a
repeating alarm.

`dismiss_alarm` now only recomputes/advances `next_trigger` when the alarm's
stored trigger is due/overdue or unset (`dismiss_should_advance`, pulled out as a
pure function alongside the existing `classify_scheduling_transition`) -- the
case where the alarm genuinely just fired and hasn't been advanced past this
occurrence yet. A second call sees a trigger already in the future and treats
itself as an idempotent replay: no DB write, no revision bump, but it still
re-emits `alarm:dismissed` so every dismiss source gets the same confirmation,
since every known consumer (Ringing.tsx's `isClosingRef`-guarded close,
wear-sync's dismiss mirror) already tolerates a duplicate.

For a non-repeating alarm (no active days), `next_trigger` lands on `None` after
its one occurrence is dismissed, same as before it ever fires -- so a double
dismiss can't be distinguished from a fresh one by that signal alone. This stays
safe regardless: recomputing from `None` reproduces `None` no matter how many
times it runs, so double-dismissing a one-shot alarm never re-enables or
re-schedules it, just costs a redundant (harmless) DB write.

`snooze_alarm` doesn't share this structural problem -- its anchor is computed
fresh by the caller each time rather than chained off the alarm's own stored
state -- so it's left alone here; flagging it is out of scope for this fix.

Adds coordinator-level tests (via `tauri::test::mock_app()` and a new
`AlarmDatabase::new_in_memory()` test helper) proving: a normal single dismiss is
unaffected, a double dismiss on a repeating alarm advances exactly one occurrence
(not two), and a double dismiss on a one-shot alarm is safe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
…ce and lock

Code review caught two real problems with the previous commit's approach
(next_trigger-in-the-future implies duplicate):

- It broke the pre-existing "dismiss upcoming alarm" notification action
  (`AlarmManagerService.dismissNextOccurrence` -> `AlarmService.dismiss`), which
  fires up to ~10 minutes *before* the alarm rings, while `next_trigger` is still
  legitimately in the future. The old heuristic couldn't tell that apart from a
  duplicate replay of an already-processed dismiss, so it silently no-opped the
  early dismiss and let the alarm ring anyway -- contradicting the "dismissing an
  upcoming alarm skips this occurrence" comment right above it.
- There was no lock around the read-decide-write sequence, so two genuinely
  concurrent calls for the same alarm could both read pre-dismiss state and both
  pass the guard before either committed -- reproducing the double-advance bug
  this exists to prevent.

Replaces the heuristic with a time-based debounce: `dismiss_alarm` now tracks
each alarm's last-dismissed wall-clock time in a `dismiss_debounce` map on
`AlarmCoordinator` (`is_duplicate_dismiss`, a pure/testable predicate), and
treats a second call for the same id within `DISMISS_DEBOUNCE_WINDOW_MS` (5s) as
a replay rather than a new request. 5 seconds comfortably absorbs the async
plumbing latency between the TS-invoked command and the native
`alarm-manager:dismiss-requested` round trip Ringing.tsx's Dismiss button now
also triggers, while being far shorter than any realistic gap between two
genuinely separate dismissals (a day, for a repeating alarm's next occurrence)
or between an early "dismiss upcoming" action and the alarm's actual due time.

A `tokio::sync::Mutex` guards the whole critical section (not just the debounce
map), held across the entire read-decide-write-emit sequence, closing the
concurrency gap outright rather than relying on timing. It's coarse-grained
across every alarm id rather than per-id -- dismiss isn't a hot path, so
serializing unrelated alarms' dismisses against each other is an acceptable
simplicity trade, mirroring the existing `ImportLock` precedent in `lib.rs`.

New/updated tests prove: a normal single dismiss is unaffected; dismissing a
still-upcoming (future next_trigger) alarm correctly skips that occurrence
(the notification-action regression case); near-simultaneous *and* genuinely
concurrent (via `tokio::join!`) duplicate calls don't double-advance; a
dismissal outside the debounce window is never treated as a duplicate even with
a stale debounce entry present; and double-dismissing a one-shot alarm stays
safe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
…clock

Second code review round found the debounce approach itself was structurally
sound but had four real problems:

- The debounce marker was recorded *before* the fallible recompute/save/emit
  work, with no rollback on failure. A transient failure (e.g. a bad schedule
  computation) would still poison the debounce window, so a legitimate retry
  within it silently no-op'd and returned `Ok(())` without ever actually
  dismissing anything -- worse than before this fix, where a redundant retry
  could self-heal a failed attempt.
- The debounce compared wall-clock `chrono::Utc::now()` millis via
  `saturating_sub`, which goes *negative* (not zero) under a backward clock
  jump (NTP correction, manual clock change), making every dismiss look like a
  duplicate until wall-clock time caught back up.
- One mutex guarded the whole map for every alarm id, so dismissing two
  unrelated alarms at nearly the same moment serialized against each other for
  no reason.
- The map had no eviction, unlike `EventDedup` (the codebase's own prior art
  for this class of problem), so it grew by one entry for every alarm id ever
  dismissed for the life of the process.

Addressed all four without another redesign:

- The debounce marker (`last_dismissed_at`) is now written only immediately
  before the final `Ok(())`, after every fallible step has already succeeded.
  Any early return via `?` leaves it untouched, so a retry after a failure
  gets a fresh attempt rather than being swallowed as a duplicate.
- Switched from `chrono::Utc::now()` to `std::time::Instant`, a monotonic
  clock immune to wall-clock adjustments by construction -- this is a purely
  internal, in-memory mechanism with no need to relate to wall-clock time at
  all, so this sidesteps the whole class of clock-jump bugs rather than
  special-casing them.
- Replaced the single coarse mutex with a lazily-created per-alarm-id
  `tokio::sync::Mutex<Option<Instant>>` (`dismiss_lock_for`), holding the
  *outer* map lock only for the brief synchronous get-or-create, never across
  an `.await`. The per-id lock is held for that alarm's entire `dismiss_alarm`
  call, so unrelated alarms never block each other, while a genuinely
  concurrent second call for the *same* id still can't slip past the
  duplicate check before the first resolves.
- `dismiss_lock_for` opportunistically sweeps other idle (not currently
  locked -- checked via a non-blocking `try_lock`) and expired entries out of
  the map on every call, bounding it to alarms with recent or in-flight
  dismiss activity instead of growing forever.

Also, per review: extracted `AlarmInput::from_record` (models.rs) so
`toggle_alarm` and `dismiss_alarm` share one field-by-field copy from
`AlarmRecord` instead of two, so a future field addition can't be updated in
one and silently missed in the other.

Documented the debounce mechanism as a deliberate, disclosed short-term
tradeoff rather than the final design -- the more correct fix is threading a
shared `event_id` through every dismiss delivery path (today only the native
listeners in lib.rs carry one) and reusing `EventDedup` for exact-match dedup
instead of a time window. That requires changes to `Ringing.tsx` and
`AlarmManagerPlugin.kt` already implemented in PR #301, so it's out of scope
here; filed as a follow-up: #304 (cross-referencing #303,
a related but distinct EventDedup gap).

New/updated tests: the per-id concurrent-dismiss test now exercises the real
per-id lock; a new test proves a failed dismiss doesn't poison the debounce
window for a retry; pure `is_duplicate_dismiss` tests now use `Instant`
arithmetic (a dedicated backward-wall-clock-jump test is no longer applicable
since `Instant` is monotonic by construction -- documented as such, with a
narrower test for the one adjacent edge case `saturating_duration_since` still
guards defensively); and a new test exercises `dismiss_lock_for`'s sweep
directly (evicts idle+expired, keeps recent and busy entries).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
@ScottMorris
ScottMorris force-pushed the feat/255-p4c-dismiss-idempotency branch from 088b5f3 to 7439f95 Compare August 18, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

architecture System design bug Something isn't working core Core app logic rust Rust tooling and build and test work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant