Skip to content
C.W.K.
Stream
Lesson 03 of 05 · published

Presence on Its Own Lane

~13 min · watchconnectivity, watchos, heartbeat, swift

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

One bit the phone needs at every turn

When a walk's turn comes, the phone has to answer one question before it posts a lock-screen notification: is the watch app already tapping this turn itself? If its navigation session runs, it is, and a mirrored notification on top would tap Dad twice for one corner. Track 5 walked through that rule (TurnAlertPolicy.shouldAlert) and the watch's mirror of it. This lesson is about the input it depends on: how the phone comes to know the watch's session is live, and why the first answer was wrong.

The watch is the only one who knows, so the watch has to say it. While its session runs it says so every watchingEvery = 30 seconds; when the session ends it says so once, at once. The phone believes a "live" for watchingTrust = 75 seconds: two missed beats and a margin. A watch whose app was swiped away never gets to say "ended", so the phone simply stops believing it, and the notifications come back.

First version: a heartbeat in a durable queue

The first wrist sent that heartbeat the same way it sent Dad's taps, with link.capture(.watching(true)). Lesson 1 described that lane: the watch writes each entry to its own outbox, hands it to transferUserInfo, rings the phone if it can, and lets go only when the transfer reports it landed. For a tap, that is exactly right. For a heartbeat it is a slow leak.

On the bench, a watch simulator with no phone near, the controls page read 4 items waiting for the phone after three minutes and 10 after six (coop learning #734). Nothing can land while the phone is away, so nothing is let go, and every beat adds one. On a real walk with the phone out of reach that is two a minute. Then the phone comes back, the link's flush re-offers the whole pile, and every stale "I'm live" costs a transfer and a ring. The phone app gets woken repeatedly to be told, in order, a pile of things that stopped being true minutes ago.

Nothing was broken in the lane. The heartbeat was a fact riding a lane built for acts.

The fix: the watch's own application context

The application context has two ends. The phone's updateApplicationContext fills the watch's receivedApplicationContext, and the watch's own call fills the phone's. Pathfinder used only the downward half for the glance. Commit fc2558d used the upward half for presence: the watch seals WristCommand(action: .watching(live)) and publishes it as its application context. Latest wins, so while the phone is away there is one slot, overwritten every 30 seconds, never a queue. Nothing rings, so a phone asleep in a pocket is never woken for it. After the change, the bench outbox held at its old count through two minutes of a running session.

The phone does not listen for it either. WatchBridge.watchLive reads receivedApplicationContext at the moment a turn asks, opens it as a WristCommand, and hands it to TurnAlertPolicy.watchLive(presence:now:). That is the kit's rule from lesson 1 ("read, not hooked") working in the other direction: updateApplicationContext is a method, not a delegate callback, so the kit's proxy stays the session's only delegate on both devices.

Reading a fact: its own clock, its own kind

Two details make the read trustworthy.

  • The fact carries its age. Liveness is judged by the presence's own issuedAt, not by when the phone happened to read it. A context the phone reads ten minutes later says exactly how old it is. A lane that keeps only the latest word makes that word the watch's only word, so its timestamp is the whole truth.
  • Only a presence counts. The phone's context slot is opened with WristEnvelope.open(WristCommand.self, from:), which checks the envelope's kind first. Anything else in the slot reads as no word at all. The hosted test seals a glance, hands it to watchLive, and asserts false: "only a presence counts".

Two more lines of the watch's code are worth reading for what they leave out. announcePresence does nothing until the session has activated, and does not retry; a word lost that way is said again on the next beat. And the phone's command door still refuses a .watching that arrives through the capture lane. An old watch build, or a mistake, cannot turn a presence back into a command.

A fact goes on a latest-wins lane, with its timestamp, and the reader judges its age. An act goes on a durable lane, once per id, with an expiry at the receiver. When one message type needs a queue and a heartbeat, it is probably two messages.

Code

Watch/WatchModel.swift: announcePresence, verbatim·swift
  /// The presence goes up as the watch's own application context, never
  /// through the capture lane: latest wins, nothing queues while the phone is
  /// away, and nothing rings it awake. The phone reads it when a turn comes
  /// (`WristCommand.Action.watching` has the story). A word lost before the
  /// session activates is said again on the next beat, and a phone that heard
  /// none stops believing the last one after `watchingTrust`.
  private func announcePresence(_ live: Bool) {
    guard WCSession.isSupported(), WCSession.default.activationState == .activated,
      let sealed = try? WristEnvelope.seal(WristCommand(action: .watching(live)))
    else { return }
    try? WCSession.default.updateApplicationContext(sealed)
  }
PathfinderKit, Wrist/WristCommand.swift: TurnAlertPolicy.watchLive, verbatim·swift
  /// Whether the watch's session counts as live, from its last word.
  public static func watchLive(lastWatching: Date?, live: Bool, now: Date = Date()) -> Bool {
    guard live, let lastWatching else { return false }
    return now.timeIntervalSince(lastWatching) <= WristCommand.watchingTrust
  }

  /// The same, from the presence the watch published as its application
  /// context. That lane keeps only the latest, so its last word is its only
  /// word; anything but a presence counts as no word at all.
  public static func watchLive(presence: WristCommand?, now: Date = Date()) -> Bool {
    guard let presence, case .watching(let live) = presence.action else { return false }
    return watchLive(lastWatching: presence.issuedAt, live: live, now: now)
  }
A minimal sketch: one heartbeat, two lanes, six minutes without the phone (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch: the same 30 s heartbeat on two lanes while the phone is
// away for six minutes, then what the phone believes when a turn asks.
// Times are seconds, passed in, so the run is deterministic.
struct Presence {
  let live: Bool
  let issuedAt: TimeInterval
}

let watchingEvery: TimeInterval = 30
let watchingTrust: TimeInterval = 75

// The capture lane: a durable queue that lets go only when a transfer lands.
var queue: [Presence] = []
// The application context: one slot, the latest replaces the last.
var context: Presence?

let away: TimeInterval = 360
for beat in stride(from: 0.0, to: away, by: watchingEvery) {
  let word = Presence(live: true, issuedAt: beat)
  queue.append(word)  // nothing lands while the phone is away
  context = word  // the slot just holds the newest
}
print("after \(Int(away / 60)) min away: queue holds \(queue.count), context holds \(context == nil ? 0 : 1)")
print("on reconnect the queue re-offers \(queue.count) transfers and \(queue.count) rings")

// The phone's question at a turn: is the watch's session live?
func watchLive(_ presence: Presence?, now: TimeInterval) -> Bool {
  guard let presence, presence.live else { return false }
  return now - presence.issuedAt <= watchingTrust
}
let last = context!.issuedAt
for gap in [20.0, 70.0, 120.0] {
  print("turn \(Int(gap)) s after the last beat: live = \(watchLive(context, now: last + gap))")
}
print("session ended (live: false), 1 s later: live = \(watchLive(Presence(live: false, issuedAt: last), now: last + 1))")

// Output:
// after 6 min away: queue holds 12, context holds 1
// on reconnect the queue re-offers 12 transfers and 12 rings
// turn 20 s after the last beat: live = true
// turn 70 s after the last beat: live = true
// turn 120 s after the last beat: live = false
// session ended (live: false), 1 s later: live = false

External links

Exercise

Find a heartbeat, a status ping or a "still here" signal in a system of yours: a worker that reports it is alive, a client that sends presence, a device that reports battery. Write down the lane it rides. If it rides a queue, a retry loop or any channel that keeps every message, compute how many copies pile up during the longest outage you have actually seen, and what the receiver does with them when the outage ends. Then sketch the latest-wins version: one slot, a timestamp inside the value, and a trust window on the reader.
Hint
Pick the trust window from the beat, not from a feeling: a little over two beats tolerates one lost message without flapping. Check what the reader does when the slot holds something that is not a presence at all, and when it has never held anything.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.