Back to RunWayMaintenance Log

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.

10
Resolved
2
Known Limitation
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  1. FINDING

    Discovery

    Stack trace tracing pointed to a crash inside session(_:activationDidCompleteWith:error:).

  2. 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.

  3. FINDING

    Recurrence

    Even after the first fix, the same crash pattern reappeared - only on real devices - inside didReceiveUserInfo.

  4. ACTION

    Full audit

    Audited every WCSessionDelegate callback across both the +iOS and +watchOS extensions and added the missing nonisolated everywhere it was needed.

  1. APPROACH

    First implementation

    Used Timer.publish().connect() to control the start point directly, choosing it over autoconnect() with pause support in mind.

  2. 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.

  3. 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().

  4. 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.

  1. FINDING

    Discovery

    Instruments' Swift Concurrency profiler showed that a new AsyncStream and a new Task were being spun up on every single location update.

  2. 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.

  3. 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.

  4. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

FINAL VERDICT

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

FINAL VERDICT

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

FINAL VERDICT

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.

  1. 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.

  2. 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.

  3. 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.

FINAL VERDICT

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

FINAL VERDICT

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.