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.

5
Resolved
1
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

    Added elapsedTime to the FlightData payload and synced it with a 3-second throttle. 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.