A small Swift toolkit for syncing one feature's state across paired Apple devices — iPhone ↔ Watch in production, but the engine and transport interfaces are generic enough for anything that ships an event stream over a bidirectional pipe.
What it is: identity + envelopes + snapshots + a deterministic reducer per feature. What it is not: a chat protocol, a CRDT library, or a generic message queue.
Ad-hoc WCSession-based sync drifts over time. Each feature reinvents its own gating, its own "is the other side awake?" heuristic, its own timing math. The user-visible symptoms repeat:
- Cold-launched peer joins a session already in flight and shows stale state.
- Second session reuses leftover state from the first because nothing identified one instance from another.
- Pause on one device fails to propagate; resume returns at 0:00.
- The same metric flows over two channels with no dedup.
SessionMesh makes those bugs structurally impossible: every event carries identity (sessionID + sourcePeerID + sequence + eventID), the engine drops duplicates and stale events, and snapshots provide a recovery substrate so a peer waking mid-session always finds current state.
┌──────────────────────────┐ ┌──────────────────────────┐
│ Device A │ │ Device B │
│ ┌────────────────────┐ │ │ ┌────────────────────┐ │
│ │ Your feature VM │ │ │ │ Your feature VM │ │
│ └─────────┬──────────┘ │ │ └─────────┬──────────┘ │
│ │ submit │ │ │ submit │
│ ┌─────────▼──────────┐ │ │ ┌─────────▼──────────┐ │
│ │ Your adapter │ │ │ │ Your adapter │ │
│ │ ┌──────────────┐ │ │ │ │ ┌──────────────┐ │ │
│ │ │SessionSync │ │ │ │ │ │SessionSync │ │ │
│ │ │ Engine │ │ │ │ │ │ Engine │ │ │
│ │ └──────────────┘ │ │ │ │ └──────────────┘ │ │
│ └─────────┬──────────┘ │ │ └─────────┬──────────┘ │
└────────────┼─────────────┘ └────────────┼─────────────┘
│ │
│ SessionSyncTransport │
│ (any bidirectional pipe) │
└───────────────────────────────────┘
The protocol is symmetric. Both devices run an identical engine over an identical reducer. There is no client/server, no master/slave. Whichever device the user taps on becomes the originator of that event; both devices apply it to the same shape of state.
Three data-paths flow on the wire:
| Path | Purpose | Example |
|---|---|---|
| Envelope | "Something happened — apply this event" | user tapped pause |
| Snapshot | "Here is the full state right now" | for cold-launching peers |
| Snapshot request | "I'm out of sync, send me a snapshot" | engine detected a sequence gap |
.library(name: "SessionMesh", targets: ["SessionMesh"])
.library(name: "SessionMeshWatchConnectivity", targets: ["SessionMeshWatchConnectivity"])
SessionMesh— the pure protocol: engine, types, time anchor, transport interface, plus three in-memory transports for tests (LoopbackSessionSyncTransport,ScenarioSessionSyncTransport,DiagnosticsSession).SessionMeshWatchConnectivity— the productionWCSession-backed transport. Lives in a separate library so the core framework stays platform-clean.
Three identifiers cooperate to make every wire payload unambiguous:
sessionID: UUID— one instance of a syncing session. Both devices must agree on this UUID for their adapters to talk; mismatches raiseSessionSyncError.sessionMismatch.featureID: String— what kind of session this is. Lets a singleWCSessionmultiplex multiple unrelated session types; each adapter filters by featureID.SessionPeer— describes one device. Carriesid(stable per-install),deviceClass(.phone | .watch | .tablet | …),appInstanceID, optionaluserID, andtopology(typically.pairedCompanion).
Every envelope and every snapshot carries the originating peer's id in metadata.sourcePeerID. The engine uses it to filter own-echoes and to track the highest sequence number it has seen from each peer.
Every byte of feature payload that crosses the wire is wrapped in metadata. The metadata is identical for envelopes and snapshots:
struct SessionSyncMetadata {
let protocolVersion: Int // currently 1
let sessionID: UUID
let featureID: String
let sourcePeerID: String
let eventID: UUID // unique per event, used for dedup
let sequence: UInt64 // monotonic per (sourcePeerID, sessionID)
let causalTimestamp: Date
let monotonicTimestamp: TimeInterval?
let payloadType: String // e.g. "MyFeatureEvent" | "MyFeatureSyncState"
let acknowledgedSequence: UInt64?
}Two payload shapes the engine consumes:
- Envelope = metadata + a
Codableevent payload. "X happened at time T." - Snapshot = metadata + a
revision: UInt64+ aCodablestate payload. "The full state right now is …"
Each shape comes in two flavors: a typed version (SessionSyncEnvelope<Payload>, SessionSyncSnapshot<State>) used inside the engine and reducer where the concrete type is known, and an erased version (AnySessionSyncEnvelope, AnySessionSyncSnapshot) used on the wire and at transport boundaries. The erased form lets a single transport move multiple feature types over one channel without knowing any of them.
SessionSyncEngine is a pure value type generic over four parameters:
SessionSyncEngine<State, Event, Reducer, ConflictPolicy>Two operations a feature uses:
// I just produced a local event. Apply to my state and return the envelope to ship.
mutating func submitLocal(_ event: Event, at: Date) throws
-> SessionSyncEnvelope<Event>
// A peer just sent me an envelope. Decide what to do with it.
mutating func receive(_ envelope: SessionSyncEnvelope<Event>) throws
-> SessionSyncReceiveResult<State>receive returns one of four outcomes:
.applied(state)— event was new, in-order, and the conflict policy accepted it. State updated..duplicate(state)— we've seen thiseventIDbefore. Drop it..ignored(state)— the conflict policy rejected it. State unchanged but the metadata is recorded so the sequence counter advances..snapshotRequired(expectedSequence, receivedSequence)— the peer's sequence skipped ahead, meaning we missed at least one event. Don't try to reconstruct — ask for a fresh snapshot.
Plus snapshots:
mutating func applySnapshot(_ snapshot: SessionSyncSnapshot<State>) throws
-> SessionSyncSnapshotResult<State>
// → .applied(state) | .staleIgnored(state, currentRevision)The engine enforces these invariants on every call: sessionID, featureID, payloadType, and protocolVersion must all match — otherwise it throws a SessionSyncError. That's what stops a workout-feature envelope from accidentally being applied to a chat-feature engine.
The engine defers two decisions to your feature:
protocol SessionSyncReducer<State, Event> {
func reduce(state: inout State, event: Event,
context: SessionEventContext) throws
}
protocol SessionConflictPolicy<State, Event> {
func shouldApply(event: Event,
context: SessionConflictContext<State>) -> Bool
}The reducer is a pure function: given the current state and an event, mutate the state. It must be deterministic and side-effect-free. Determinism is what guarantees two devices applying the same event sequence end up at the same state.
The conflict policy is a pre-application filter. It runs before reduce for remote events, with a context containing the current state and the most recently applied metadata. Returning false produces an .ignored outcome — useful for "once we're terminal, only metrics may apply" style rules.
Cross-device timing is the single largest source of bugs in sync code, so SessionMesh ships a primitive that gets it right by construction:
struct SessionTimerAnchor {
let startedAt: Date // anchor moment
let accumulatedElapsedBeforeAnchor: TimeInterval // active time before anchor
let pauseStartedAt: Date?
let isPaused: Bool
let expectedEndAt: Date? // for countdown / fixed-duration
let stepStartedAt: Date? // for stepped sessions
func elapsed(at: Date) -> TimeInterval { ... }
}The model is anchor + accumulator, not "startTime + pausedTotal." On every resume the anchor is rebased to the resume moment, and the accumulator absorbs the time spent active before that resume. Two consequences:
- Pause/resume math is local and never drifts. No reconstructed
totalPausedTimefield. - Both devices compute elapsed from the same wall-clock formula. There are no per-second tick events on the wire.
protocol SessionSyncTransport: Sendable {
var localPeer: SessionPeer { get }
var capabilities: SessionTransportCapabilities { get }
func send(_: AnySessionSyncEnvelope, mode: SessionDeliveryMode, to: SessionPeer?) async throws
func sendSnapshot(_: AnySessionSyncSnapshot, mode: SessionDeliveryMode, to: SessionPeer?) async throws
func requestSnapshot(sessionID: UUID, from: SessionPeer?) async throws
func receiveEnvelopes() -> AsyncStream<AnySessionSyncEnvelope>
func receiveSnapshots() -> AsyncStream<AnySessionSyncSnapshot>
func receiveSnapshotRequests() -> AsyncStream<SessionSnapshotRequest>
func reachabilityChanges() -> AsyncStream<SessionPeerReachability>
func latestPeerSnapshot(featureID: String) -> AnySessionSyncSnapshot?
}Two key abstractions:
SessionDeliveryMode— a hint about delivery semantics, not a specific wire primitive:.realtimeBestEffort— try to deliver immediately; durability preserved if the peer is asleep..reliableEventually— durability matters more than latency..latestSnapshot— last value wins; older publications are pre-empted..snapshotRequest/.acknowledgement— for the request/reply path.
SessionTransportCapabilities— a static description of what the transport can do (supportsRealtime,supportsDurableQueue,supportsLatestStateReplacement, etc). Adapters probe this to fail fast on unsupported delivery modes.
latestPeerSnapshot(featureID:) is a synchronous read of the most recent snapshot the peer published in latestSnapshot mode for a given feature. On WatchConnectivity this reads session.receivedApplicationContext — preserved by the OS across launches — which gives a peer waking mid-session a deterministic recovery surface.
WatchConnectivitySessionSyncTransport (in SessionMeshWatchConnectivity) maps mesh delivery modes onto WCSession primitives:
| Delivery mode | WCSession primitive | Fallback |
|---|---|---|
.realtimeBestEffort |
sendMessage |
transferUserInfo on error or unreachable |
.reliableEventually |
transferUserInfo |
— |
.latestSnapshot |
updateApplicationContext |
— |
.snapshotRequest / .acknowledgement |
sendMessage |
transferUserInfo |
A couple of design choices worth calling out:
- Pre-activation buffering.
WCSession.activate()is async —transferUserInfoandupdateApplicationContexteither fail or silently drop on some OS versions when called before activation completes. The transport composes aPendingDeliveryQueueto buffer payloads and drain them in arrival order onactivationDidCompleteWith(.activated). Envelopes preserve FIFO order (capped at 200 items, oldest dropped). Snapshots collapse per-featureID— only the most recent snapshot per feature survives a drain, matching the OS-levelapplicationContext"latest only" channel. - Delegate forwarding. The transport claims
WCSession.default.delegate = selfat init and captures whatever delegate was already there into aforwardingDelegateslot. Non-meshWCSessiontraffic (legacy auth, custom message handlers) still flows through. This is what lets mesh and legacyWCSessiontraffic coexist on one session. - Application-context as session-discovery hook.
updateApplicationContextis preserved across both apps' launches by WatchOS — a peer joining mid-session reads the canonical peer's last snapshot vialatestPeerSnapshot(featureID:)and is immediately at the same state. No handshake required.
Five types define one feature's contract with the framework. Conventionally they live in a directory like MyFeature/Sync/:
| Type | Role |
|---|---|
MyFeatureSyncState |
The shape on the wire. Config + state + timer + sample fields. Codable & Sendable & Equatable. |
MyFeatureEvent |
Every user action and sample. Codable & Sendable & Equatable. Each case carries at: Date (the originating tap moment, not receive time). |
MyFeatureReducer |
Conforms to SessionSyncReducer. (state, event) → state. Pure. |
MyFeatureConflictPolicy |
Conforms to SessionConflictPolicy. Decides whether a given event should apply right now. |
MyFeatureSyncAdapter |
@MainActor bridge between the engine and your app code. ViewModels talk to the adapter; they never import SessionMesh directly. |
The adapter holds the engine, exposes submit*() methods that ViewModels call, and runs async loops over the transport's receive streams to surface remote events / snapshots back to the app via callback closures.
- Carry
at: Dateon every event. Stamping timer boundaries at the originating tap moment eliminates a whole class of "event arrived 200ms late, my pause time is wrong by 200ms" bugs. - The reducer is pure and deterministic. Same input → same output, every time, on every device. Side effects (notifications, HK writes, UI updates) belong in the adapter or the ViewModel, not the reducer.
- Terminal states are sticky. Most features want "once we're
.completedor.abandoned, only late metrics apply." Implement this in the reducer's top-level guard AND mirror it in the conflict policy. - Identity stays stable for a session's lifetime.
sessionIDshould be the per-instance epoch (e.g. when the user tapped Start). A new session = new UUID; the engine's invariants do the rest.
Tests/SessionMeshTests/ exercises the engine, reducer, and conflict policy in isolation. Two transport implementations support deterministic tests:
A single-peer in-memory transport. Sending an envelope yields it back into the peer's own receive stream. Used for "submit + receive on one side" unit tests.
A multi-peer test harness. Construct a ScenarioNetwork, then network.makeTransport(localPeer: A) and network.makeTransport(localPeer: B). Sending from A yields on B's receive stream and vice versa. The network supports:
- Reordering deliveries
- Latency injection
- Partitioning (drop messages between specific peers)
- Reachability flips
This is what lets convergence tests prove that for any sequence of events from any peer (with arbitrary reordering), both engines converge to the same final state.
Three components harden the framework against the failure modes that bit us repeatedly in production:
PendingDeliveryQueue(transport layer) — buffers payloads submitted beforeWCSession.activate()completes; drains in arrival order on activation; collapses per-featureID snapshots.- Sequence-gated dedup (engine layer) —
appliedEventIDsset + per-peerlastSequenceByPeerIDmap combined with the snapshot-required outcome detect missed events and recover via snapshot rather than gap-fill. - Awaitable teardown (adapter layer) — closing the session should
awaitthe finalphaseChange(.closed)envelope's transport completion (both thetransferUserInfoAND theupdateApplicationContextsnapshot republish) before stopping the adapter. Without the await, the prior fire-and-forget can race the immediate stop and the close envelope never reaches the peer, leaving the OS serving the last "live" snapshot on every relaunch.
Semver. Breaking protocol changes bump the major; additive / non-breaking changes bump minor. SessionSyncProtocol.currentVersion reflects the on-wire protocol version, currently 1.
MIT — see LICENSE.