~15 min · watchconnectivity, watchos, debugging, regression, swift
Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
The regression report
Walking in Seville with the phone guiding, Dad opened Pathfinder on his watch and it showed nothing of the walk. His report, dictated on the street, said what had been lost: "패스파인더가 원래 워치에서 열면 그냥 바로 연동이 돼서 워치만으로도 갈 수 있었거든" (opening Pathfinder on the watch used to pick the guidance up at once, so he could walk on the watch alone). It became coop todo #391, filed as a regression, and fixed in commit ad8d066.
The first finding was awkward for the word "regression": the wrist lane's code had not changed in more than a week. The second was worse for the usual excuse. On a pair of simulators (an iOS 26.5 phone paired with a watchOS 26.5 watch) the glance arrived at a cold launch with the phone app in front, in the background, and frozen. The simulators could not reproduce it because what they cannot show is real WatchConnectivity timing, and that is where the lane had two holes.
Hole one: a read that ran too early, once
The watch read receivedApplicationContext at launch and when it came to the front, and again whenever the kit counted a new arrival. Both of the first two can run before the watch's session has activated, and a session that has not activated has nothing to read. That read saw nothing, and nothing made it read again until the phone published a new context. A phone that was guiding steadily would publish within seconds. A phone app that iOS had suspended in a pocket would not.
The fix is PathfinderWrist.LinkChange, a small value computed from the old and new link status. It says to read when the arrival count changed, as before, or when the session has just become activated. The launch read can still run early. It is no longer the only chance.
Hole two: a watch that could not ask
Reading again only helps if there is something new to read. A suspended phone publishes nothing, and until #391 the watch had no way to ask it to. Now it has WristCommand.pull. A watch at the front that holds no glance, or one older than NavigationGlance.pullAfter = 10 seconds, sends a pull while the phone is reachable, at most once per 10 seconds. It asks when it comes to the front and when LinkChange reports the link has just come up.
Ten seconds is "several beats of silence", in PathfinderKit's own comment: a guiding phone publishes every couple of seconds while Dad moves. Reachable-only is a deliberate choice. The pull rides the capture lane, so its ring is what wakes a phone app asleep in a pocket. A watch that queued pulls for an absent phone would rebuild the heartbeat problem from lesson 3, so it does not.
The phone's half is two lines at the top of obey: a pull publishes the glance at once, forced past the two-second gate, and changes nothing else. It goes through the same door as every command, so it is fresh and once like the rest; the hosted test hands the bridge one pull twice and gets one.
The silence that left no record
The other half of the fix is about the next failure. Before it, a glance the phone could not send was swallowed: publish returned early or swallowed the refusal, and nothing anywhere said the watch was hearing nothing. Now WatchBridge names why. Its blocker walks the setup in order (this iPhone cannot pair, the link has not activated, no watch is paired, Pathfinder is not installed on the watch), and trouble keeps a refusal from updateApplicationContext. The app writes each change into the drive's log as a note, "watch: …", and "watch: hears the guidance again" when it clears. A drive that starts with the watch hearing fine writes nothing.
And a gotcha that was wrong
The project's own notes had said the simulator has no watch pairing, so the wrist was proven only on Dad's devices. That was wrong, and the fix said so in the same commit: paired simulators do carry the wrist, with a recipe (an iOS 26.5 phone with a watchOS 26.5 watch; iOS 27 will not pair with watchOS 26) and a UI test, WristHoldWalkthrough, that starts a walk and holds it. The same note keeps the limit honest: real timing, activation and a suspended phone are still the devices' to prove. The commit itself ended "Not yet proven on Dad's watch." Dad then checked it on his own watch, and #391 was closed on his word.
Read on every event that could change the answer, and give the reader a way to ask. A latest-wins value is only as good as the moments someone reads it. If the writer may be asleep, the reader needs a cheap, idempotent "say it again".
/// What a change in the watch's link status asks of the watch (todo #391).
///
/// The phone's glance is read from `receivedApplicationContext` at launch,
/// at the front, and on each arrival. On Dad's watch the launch reads ran
/// before the session had activated, read nothing, and nothing read again
/// until the phone published once more, which a phone app suspended in a
/// pocket may never do. So the read also follows the session's activation,
/// and a link that comes up is when the watch may ask the phone for its
/// latest (``WristCommand/Action/pull``): only reachable, so a pull never
/// waits in the durable queue for a phone that is away.
struct LinkChange: Equatable {
var readsContext: Bool
var linkCameUp: Bool
init(from old: WristWatchStatus?, to new: WristWatchStatus) {
let activated = new.activation == .activated
let wasActivated = old?.activation == .activated
readsContext =
new.applicationContextArrivals != old?.applicationContextArrivals || (activated && !wasActivated)
linkCameUp = activated && new.reachable && !(wasActivated && old?.reachable == true)
}
}
/// Asks the phone for its latest glance when this watch holds none, or an
/// old one, and the phone can hear it now (``PathfinderWrist/LinkChange``
/// has the story). At most once per ``NavigationGlance/pullAfter``.
private func pullIfStale(now: Date = Date()) {
let status = link.status
guard sceneActive, status.activation == .activated, status.reachable,
NavigationGlance.wantsPull(glance, now: now),
now.timeIntervalSince(pulledAt) > NavigationGlance.pullAfter
else { return }
pulledAt = now
Task { await link.capture(WristCommand(action: .pull)) }
}
A minimal sketch: when the watch reads again, and when it may pull (compiled with swiftc and run)·swift
import Foundation
// A minimal sketch of the two #391 fixes, with the link status reduced to
// three facts: activated, reachable, and how many contexts have arrived.
struct Status: Equatable {
var activated = false
var reachable = false
var arrivals = 0
}
// When to read the context again, and when the link has just come up.
func change(from old: Status?, to new: Status) -> (reads: Bool, cameUp: Bool) {
let wasActivated = old?.activated == true
let reads = new.arrivals != old?.arrivals || (new.activated && !wasActivated)
let cameUp = new.activated && new.reachable && !(wasActivated && old?.reachable == true)
return (reads, cameUp)
}
// Whether a watch holding a glance this old asks the phone again.
let pullAfter: TimeInterval = 10
func wantsPull(glanceAge: TimeInterval?) -> Bool {
guard let glanceAge else { return true }
return glanceAge > pullAfter
}
let steps: [(String, Status?, Status)] = [
("launch, session not up yet", nil, Status()),
("session activates", Status(), Status(activated: true)),
("phone comes into reach", Status(activated: true), Status(activated: true, reachable: true)),
("phone publishes a glance", Status(activated: true, reachable: true), Status(activated: true, reachable: true, arrivals: 1)),
("nothing changes", Status(activated: true, reachable: true, arrivals: 1), Status(activated: true, reachable: true, arrivals: 1)),
]
for (what, old, new) in steps {
let c = change(from: old, to: new)
print("\(what.padding(toLength: 27, withPad: " ", startingAt: 0)) read: \(c.reads) pull may go: \(c.cameUp)")
}
for age in [nil, 2.0, 10.0, 11.0, 3600.0] as [TimeInterval?] {
print("glance age \(age.map { "\(Int($0)) s" } ?? "none"): pull = \(wantsPull(glanceAge: age))")
}
// Output:
// launch, session not up yet read: true pull may go: false
// session activates read: true pull may go: false
// phone comes into reach read: false pull may go: true
// phone publishes a glance read: true pull may go: false
// nothing changes read: false pull may go: false
// glance age none: pull = true
// glance age 2 s: pull = false
// glance age 10 s: pull = false
// glance age 11 s: pull = true
// glance age 3600 s: pull = true
Pick a place where your code reads shared state on startup: a cached config, a feature-flag snapshot, a session token, a last-known value from another process. List every event after which that read could return something different (the connection becoming ready, a sync finishing, the other side writing). Check which of those events actually trigger a re-read. For each one that does not, decide: add the re-read, or give the reader a cheap way to ask for the current value. Then write a test that runs the startup read before the source is ready.
Hint
The bug hides in the gap between "started" and "ready". If your startup read can run before a connection or session reports ready, make readiness itself one of the events that triggers the read, as LinkChange does with activation.
Progress
Progress is local-only — sign in to sync across devices.