Maintenance Log
Real problems I ran into during development, how I traced the root cause,
and how I resolved them - written up in a maintenance-log format.
Not everything got fixed, and structural limitations are labeled honestly as limitations.
- FINDING
Identifying the problem
Even while mirroring, the iPhone and Watch were each running their own LocationService and RunningCenter independently. Starting from the Watch still caused a delay while the iPhone acquired its own GPS lock.
- ACTION
Changing direction
Extended the existing startOrigin property to also govern whether a device tracks location. Only the leading device turns on GPS and sends the computed results to the other; the mirroring device just receives and displays.
- DISCOVERY
Implementing both directions
sendFlightData() only existed on the iOS side, so data could only flow iPhone → Watch. In a Watch-led mirroring session, there was no path at all for the Watch's computed data to reach the iPhone - a hidden gap.
- RESULT
Closing the gap
Bundling elapsedTime into the 3-second-throttled FlightData payload would let drift accumulate, so it's synced separately every 1 second via its own message (sendElapsedTime()). All four scenarios - iPhone-only, Watch-only, and bidirectional mirroring - now resolve to a single startOrigin-based flow.
- FINDING
Discovery
Stack trace tracing pointed to a crash inside session(_:activationDidCompleteWith:error:).
- ROOT CAUSE
Root cause
The class itself was declared nonisolated, but without marking the delegate methods inside the extension explicitly, Xcode 26's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor default re-inferred them as @MainActor.
- FINDING
Recurrence
Even after the first fix, the same crash pattern reappeared - only on real devices - inside didReceiveUserInfo.
- ACTION
Full audit
Audited every WCSessionDelegate callback across both the +iOS and +watchOS extensions and added the missing nonisolated everywhere it was needed.
- APPROACH
First implementation
Used Timer.publish().connect() to control the start point directly, choosing it over autoconnect() with pause support in mind.
- FINDING
Restart failure
The timer wouldn't run again after a restart. connect() is a one-time connection - once a Publisher is cancel()'d, it can't be reconnected.
- ACTION
Switching to autoconnect
Switched to subscribing fresh with autoconnect() + sink every time start() is called, storing it in a Set<AnyCancellable> and clearing it with removeAll() on stop().
- RESULT
Preventing duplicate subscriptions
Found that tapping start() repeatedly stacked up subscriptions, making seconds jump by 2 or 3 at once. Clearing any existing subscription right when start() runs fixed it for good.
- FINDING
Discovery
Instruments' Swift Concurrency profiler showed that a new AsyncStream and a new Task were being spun up on every single location update.
- ACTION
Promoting to a property
Promoted the continuation to an Actor property. The stream is now opened only once, and processLocation() yields directly through the stored continuation.
- FINDING
Sendable conflict
Setting continuation = nil directly inside onTermination threw 'Actor-isolated property can not be mutated from a Sendable closure' - the closure runs on an arbitrary thread, so it can't touch an Actor-protected property directly.
- RESULT
Splitting init from a task
Created an Actor-isolated method (clearContinuation()) and wrapped it in Task { await ... }. Later split this out into startStream(), called from the View's .task, to clean up the flow further.
- FINDING
Identifying the problem
Only the normal flow (tapping a button) handled state cleanup - abnormal exits like tapping the tab bar or turning the Watch crown weren't covered at all, something I only caught through real-device testing.
- FINDING
Choosing the right signal
Tried using an isRunning flag, but on the iPhone during mirroring, start() is never called, so the flag could read false even though a session was still alive. Switched the check to HealthKitService.shared.session != nil instead.
- ACTION
Flag pattern
Button actions now set a flag like didNavigateToTouchdown to true first, and .onDisappear interprets the absence of that flag as an 'abnormal exit.' Applied the same pattern consistently across five Views.
- RESULT
Handling the exception
FlightSummaryView is also reused for browsing the Logbook, so it's distinguished with a selectedFlight == nil condition. On Watch, only WatchSummaryView is set to always clean up unconditionally.
- FINDING
First attempt
Tried treating a session as a zombie if 5 seconds had passed since startDate, but couldn't verify it - force-quitting via the debugger immediately severed the connection, making it impossible to check the logs.
- DISCOVERY
Pinning down the cause
Only after switching to os_log / Console.app and resolving a privacy-masking issue did the real cause come into focus: retrieveRemoteSession was re-detecting a session that was already alive on relaunch.
- ACTION
Flag-based detection
A UserDefaults flag (wasZombieSuspected) detected the condition accurately, but calling .end() on it triggered a side effect - it cascaded into ending the Watch session too.
- FINDING
Exploring alternatives
Tried comparing appLaunchTime instead. Ignoring a detected zombie blocked new mirroring sessions, while calling end() reintroduced the earlier side effect - a genuine dilemma.
- RESULT
Confirming the structural limit
Concluded that HKWorkoutSession is a healthd (system daemon) level resource that app code simply can't fully control. Rolled back all related code and kept only the logging infrastructure.
Concluded that HKWorkoutSession is a healthd (system daemon) level resource that app code can't fully control. os_log/Console.app tracing pinpointed the exact cause, but with no fix possible without side effects, this was documented as a known limitation for v1.0.
- FINDING
The symptom moved
The issues seen in phone-led mirroring (Watch display not updating, stop not syncing) disappeared after a rebuild with zero code changes. In their place, Watch-led mirroring started showing a new symptom: location never acquired at all.
- ROOT CAUSE
Async assignment vs. sync check
The startOrigin = .local assignment inside updatePhase(.cruise) sits inside a Task {}, making it async. The very next line calls start(), which checks that value once, synchronously. If the check runs before the assignment lands, GPS never turns on for the rest of that run.
- DISCOVERY
Why the iPhone was unaffected
Same call order, yet the iPhone never hit this. Adding the pre-flight check to TakeoffView had it call prepareTracking() ahead of time, so GPS was already running by the time start() ran - the race never had a chance to matter. The Watch had no such pre-step, so it was fully exposed.
- ACTION
Removing the race entirely
Brought the same prepareTracking()/stopTracking() pattern to the Watch. Split the GPS-start logic out of start() completely, so start() no longer reads startOrigin at all.
- RESULT
Unified platform architecture
Added a didStartFlight flag to distinguish a normal ROTATE entry from bailing out mid-countdown. iOS and Watch now share the same architecture, which has made it easier to read the two platforms' code side by side since.
- FINDING
Screen and storage disagreed
The live PFD pace calculation already had an isFinite guard, so the screen safely showed --'--" whenever the math broke down. saveRunningData(), which persists to SwiftData, had no such guard.
- ROOT CAUSE
inf propagating downstream
Dividing by zero distance returns inf in Swift - no crash, just a silently invalid Double. That inf got saved as-is, and a single inf mixed into the reduce that computes the monthly average was enough to corrupt the entire sum, wiping out that month's average.
- ACTION
Guarding both write and read paths
Added an isFinite guard at save time so unrepresentable values are stored as 0, plus a second isFinite filter at aggregation time as a backstop. WatchPFDView.swift had the identical calculation, so the same guard went there too.
- FINDING
A follow-up bug
Found a separate issue where stopping a run seconds after starting produced a wildly spiked pace. Not inf this time - just an unrealistically large finite value from dividing a tiny distance by a tiny time, which slipped right past the isFinite guard.
- RESULT
Thresholds and cleanup
Added a minimum valid distance (50m) and a realistic pace ceiling (30 min/km). Deleted the 14 already-corrupted records with a one-off cleanup script. The lesson: 'invisible on screen' isn't the same as 'safe' - what actually gets persisted needs its own guarantee.
- FINDING
The pattern
Re-auditing all four mirroring combinations (app-led/Watch-led x app-ends/Watch-ends) in code, more than half of every bug found so far traced back to exactly one combination: Watch leading while iPhone had to mirror it live.
- DISCOVERY
What the crossed GPS tracking actually was
WatchPFDView's doc comment claimed '.onDisappear cleans up state,' but there was no such handler in the code. Leaving via the Digital Crown never stopped GPS tracking, so an orphaned session kept running and overlapped with the next run.
- DISCOVERY
Same root cause behind the Dynamic Island
updateCruise(), which refreshes the Live Activity, was only ever called from iPhone's own GPS stream - never from the flightData messages received from Watch. When Watch led, the Live Activity was structurally stuck at its start screen.
- ACTION
The trade-off
Instead of full bidirectional mirroring, kept app-led mirroring (still needed for Watch sensor data) and branched on startOrigin so Watch never even attempts mirroring when it leads. On stop, it just falls back to the existing standalone-run path, delivering the record to iPhone's Logbook.
Accepted the reality that most people aren't looking at their phone while a Watch-led run is happening. Decided iPhone didn't need to mirror it live, and deliberately narrowed the scope to match actual usage instead of chasing full bidirectional mirroring. This walks back one of the four scenarios that entry #01 (mirroring redesign) had originally built out.
- FINDING
The real-device symptom
Watch ended a run while mirroring, but the iPhone was still alive. Resuming on Watch right after, Watch and iPhone both kept collecting their own location data at the same time, and ending the Watch run again logged the same run three separate times.
- ROOT CAUSE
No shared identifier for "this is the same run"
Each device just saved whatever run ended on its own side, with no way to check whether the other device was talking about the same run. Even a slightly delayed stop signal was enough for a new run and a stale session to get tangled together.
- ACTION
Introduced a shared session ID
The device that starts the run issues a UUID and holds it as runSessionID, sending it along with every flightData message and the final save message. At save time, that same value becomes SwiftDataFlight.id, so a record that already exists under that id is never saved again.
- ACTION
Guarded the receiving side too
Once Watch starts its own new run (startOrigin == .local), it now ignores any stale flightData still arriving from iPhone. The same guard was mirrored on iPhone's side for the opposite direction.
- DISCOVERY
Cleaned up iPhone's native mirroring receiver too
While digging into this, found that HealthKitService+iOS.swift's retrieveRemoteSession() wasn't actually used for heart-rate delivery or for saving to Apple Health - either way already went through a different path. Keeping it registered only risked breaking the save guard or pushing PFDView onto the navigation stack twice, so the registration was disabled.
Entry #09 already removed Watch-led mirroring itself, but the code that let iPhone receive Watch's mirrored session back was still sitting there. Now the start side is decided by iPhone alone, full stop, and the stop side confirms it's the same run via the shared session ID.
- FINDING
The real-device symptom
When iPhone led mirroring by launching the Watch app through startWatchApp, ending the run on Watch never ended the run on iPhone at all.
- ROOT CAUSE
Quietly depending on the code entry #10 had just disabled
WatchViewModel decided whether to tell iPhone about its own stop based on runningMode == .mirrored, but runningMode only flips to .mirrored when startMirroringToCompanionDevice() succeeds. Entry #10 had disabled retrieveRemoteSession() - the listener that handshake needs on iPhone's side - so that call could now fail, leaving runningMode stuck at .standalone and sendStopSignal() never firing.
- ACTION
Swapped the condition
Switched the check from runningMode to startOrigin != .local. startOrigin gets set unconditionally in AppDelegate.handle() regardless of whether that handshake succeeds, and stopWorkout() never touches it, so it's still there by the time the stop event gets handled.
A side effect of disabling retrieveRemoteSession() in entry #10. A reminder that removing code means checking what else was quietly leaning on it too - this time, a completely different function's branching logic.
- FINDING
The real-device symptom
Running with music on, the km-split announcement was inaudible entirely. SpeechAnnouncerService configures AVAudioSession with .duckOthers so music ducks only while announcing, but that activation call only fired once at run start. Once a music app grabbed the session mid-run and interrupted it, nothing ever reactivated it.
- ACTION
First fix, then the opposite problem
Reactivating the session right before every speak() call fixed that, but a second real-device test showed music staying ducked for the entire run instead of just a few seconds. A leftover activation call in start() was the cause. Ducking follows session activation, not the speak() call itself, so that call was removed and an AVSpeechSynthesizerDelegate callback (didFinish/didCancel) now deactivates the session the instant speech ends.
- DISCOVERY
Audio sessions don't cross devices
During a watch-only run with music playing on iPhone, the TTS was never audible at all. AirPods stay bluetooth-paired to both devices, but only one device owns the actual audio stream at a time. AVAudioSession is a fully separate instance per device, so nothing the Watch configures can reach iPhone's audio.
- FINDING
Nike Run Club hits the same wall
Community reports showed Nike Run Club has the identical limitation, and the only fix they offer is playing music from the Watch itself. This wasn't our app's bug. It's a structural limit across the whole Apple ecosystem.
Fixed both on-device ducking bugs, but the cross-device audio isolation itself can't be solved in code. Added a note in the app's announcements screen instead, asking users to play music from the Watch itself. Not every problem gets solved by code. Sometimes an honest heads-up is the actual answer.