Skip to content

Protect standalone Watch workouts from stale stops (#46) - #55

Merged
wuersch merged 1 commit into
mainfrom
fix/standalone-watch-protection
Jun 13, 2026
Merged

Protect standalone Watch workouts from stale stops (#46)#55
wuersch merged 1 commit into
mainfrom
fix/standalone-watch-protection

Conversation

@wuersch

@wuersch wuersch commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Closes #46.

Standalone Watch workouts (started on the wrist, no iPhone involvement) were exposed two ways.

Finding 7 (Medium) — stale stop killed a live standalone workout

The StopTimerMessage staleness check in WatchMessageCoordinator.handleStopTimerMessage only ran when activeSyncService != nil, but standalone timers never register a sync service. A stale persisted stop from an earlier iPhone-led timer (delivered when reachability resumed mid-workout) therefore fell to the "no active timer" branch and handleOrphanedStop() ended the live standalone HKWorkoutSession — HR/kcal stopped, a truncated workout was saved, no HealthKit UUID — without ever comparing the stop's (foreign) correlation ID.

Fix: track timer presence independently of the sync service.

  • New isTimerPresented flag on WatchMessageCoordinator, set in WatchPresetListView for both standalone and iPhone-led presentations.
  • New pure routeStop(...) decision function the handler switches on:
    • no timer on screen → orphanedCleanup (end any leftover HK session);
    • standalone on screen → ignoreStandalone (a wrist workout is never remote-stopped — the iPhone has no correlation for it);
    • iPhone-led, correlation mismatch → ignoreStaleMismatch;
    • iPhone-led, match (or nil) → forward.

Finding 12 (Low) — swallowed standalone save failure

WatchWorkoutCoordinator.handleWorkoutCompleted's standalone branch did try? modelContext.save() then logged success unconditionally — a failure lost the only SwiftData record with zero diagnostics. Now do/catch with Logger.workoutLogging.error, matching DefaultWorkoutLoggingService on iPhone.

Testing

  • xcodebuild build + build-for-testing succeed (app + embedded Watch app + tests).
  • WatchMessageCoordinatorTests (watchOS sim, all green): 5 pure routeStop cases including the regression routeStop(isTimerPresented: true, hasSyncService: false, …) == .ignoreStandalone; the existing iPhone-led stop tests updated to set isTimerPresented (forwarding now requires a presented timer).
  • Extracting routeStop as a pure function makes the protection decision testable without HealthKit (sessionState is otherwise only reachable via a real HK session).

Manual check (pending, user): start a standalone workout on the Watch; on the iPhone start then stop an unrelated iPhone-led timer; confirm the standalone Watch workout keeps running (not truncated). Finding 12 is observability-only.

🤖 Generated with Claude Code

Standalone Watch workouts (started on the wrist, no iPhone) were exposed two
ways:

- Finding 7 (Medium): the StopTimerMessage staleness check only ran when a sync
  service was registered, but standalone timers never register one. A stale
  persisted stop from an earlier iPhone-led timer therefore fell to the
  "no active timer" branch and handleOrphanedStop ended the LIVE standalone
  HKWorkoutSession — truncating HR/kcal and the saved workout. Track timer
  presence independently of the sync service (new isTimerPresented flag, set for
  both standalone and iPhone-led presentations) and route the stop through a pure
  routeStop(...) decision: no timer → orphaned cleanup; standalone on screen →
  ignore (the iPhone has no correlation for a wrist workout); iPhone-led mismatch
  → ignore stale; iPhone-led match → forward.

- Finding 12 (Low): handleWorkoutCompleted's standalone save used
  try? modelContext.save() then logged success unconditionally — a save failure
  lost the only record with no diagnostics. Now do/catch with Logger.error,
  matching DefaultWorkoutLoggingService on iPhone.

Extracting routeStop as a pure function makes the standalone-protection decision
unit-testable without HealthKit. Adds routeStop tests (incl. the regression) and
updates the existing iPhone-led stop tests to set isTimerPresented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wuersch
wuersch merged commit b16a1ba into main Jun 13, 2026
2 checks passed
@wuersch
wuersch deleted the fix/standalone-watch-protection branch June 13, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit: stale stop command can kill an active standalone Watch workout; save failures swallowed

1 participant