Protect standalone Watch workouts from stale stops (#46) - #55
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
StopTimerMessagestaleness check inWatchMessageCoordinator.handleStopTimerMessageonly ran whenactiveSyncService != 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 andhandleOrphanedStop()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.
isTimerPresentedflag onWatchMessageCoordinator, set inWatchPresetListViewfor both standalone and iPhone-led presentations.routeStop(...)decision function the handler switches on:orphanedCleanup(end any leftover HK session);ignoreStandalone(a wrist workout is never remote-stopped — the iPhone has no correlation for it);ignoreStaleMismatch;forward.Finding 12 (Low) — swallowed standalone save failure
WatchWorkoutCoordinator.handleWorkoutCompleted's standalone branch didtry? modelContext.save()then logged success unconditionally — a failure lost the only SwiftData record with zero diagnostics. Nowdo/catchwithLogger.workoutLogging.error, matchingDefaultWorkoutLoggingServiceon iPhone.Testing
xcodebuild build+build-for-testingsucceed (app + embedded Watch app + tests).WatchMessageCoordinatorTests(watchOS sim, all green): 5 purerouteStopcases including the regressionrouteStop(isTimerPresented: true, hasSyncService: false, …) == .ignoreStandalone; the existing iPhone-led stop tests updated to setisTimerPresented(forwarding now requires a presented timer).routeStopas a pure function makes the protection decision testable without HealthKit (sessionStateis 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