Skip to content

prasadpamidi/SessionMesh

SessionMesh

CI Swift Platforms SPM compatible License: MIT

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.

Why it exists

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.

The 30-second mental model

┌──────────────────────────┐        ┌──────────────────────────┐
│        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

Products

.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 production WCSession-backed transport. Lives in a separate library so the core framework stays platform-clean.

Core types

Identity

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 raise SessionSyncError.sessionMismatch.
  • featureID: String — what kind of session this is. Lets a single WCSession multiplex multiple unrelated session types; each adapter filters by featureID.
  • SessionPeer — describes one device. Carries id (stable per-install), deviceClass (.phone | .watch | .tablet | …), appInstanceID, optional userID, and topology (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.

Wire format

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 Codable event payload. "X happened at time T."
  • Snapshot = metadata + a revision: UInt64 + a Codable state 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.

The convergence brain

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:

  1. .applied(state) — event was new, in-order, and the conflict policy accepted it. State updated.
  2. .duplicate(state) — we've seen this eventID before. Drop it.
  3. .ignored(state) — the conflict policy rejected it. State unchanged but the metadata is recorded so the sequence counter advances.
  4. .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.

Feature contracts

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.

Time

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:

  1. Pause/resume math is local and never drifts. No reconstructed totalPausedTime field.
  2. Both devices compute elapsed from the same wall-clock formula. There are no per-second tick events on the wire.

Transport interface

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.

The WatchConnectivity transport

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 — transferUserInfo and updateApplicationContext either fail or silently drop on some OS versions when called before activation completes. The transport composes a PendingDeliveryQueue to buffer payloads and drain them in arrival order on activationDidCompleteWith(.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-level applicationContext "latest only" channel.
  • Delegate forwarding. The transport claims WCSession.default.delegate = self at init and captures whatever delegate was already there into a forwardingDelegate slot. Non-mesh WCSession traffic (legacy auth, custom message handlers) still flows through. This is what lets mesh and legacy WCSession traffic coexist on one session.
  • Application-context as session-discovery hook. updateApplicationContext is preserved across both apps' launches by WatchOS — a peer joining mid-session reads the canonical peer's last snapshot via latestPeerSnapshot(featureID:) and is immediately at the same state. No handshake required.

Adopting SessionMesh for a feature

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.

Key principles

  • Carry at: Date on 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 .completed or .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. sessionID should be the per-instance epoch (e.g. when the user tapped Start). A new session = new UUID; the engine's invariants do the rest.

Testing

Tests/SessionMeshTests/ exercises the engine, reducer, and conflict policy in isolation. Two transport implementations support deterministic tests:

LoopbackSessionSyncTransport

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.

ScenarioSessionSyncTransport + ScenarioNetwork

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.

Defense-in-depth

Three components harden the framework against the failure modes that bit us repeatedly in production:

  1. PendingDeliveryQueue (transport layer) — buffers payloads submitted before WCSession.activate() completes; drains in arrival order on activation; collapses per-featureID snapshots.
  2. Sequence-gated dedup (engine layer) — appliedEventIDs set + per-peer lastSequenceByPeerID map combined with the snapshot-required outcome detect missed events and recover via snapshot rather than gap-fill.
  3. Awaitable teardown (adapter layer) — closing the session should await the final phaseChange(.closed) envelope's transport completion (both the transferUserInfo AND the updateApplicationContext snapshot 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.

Versioning

Semver. Breaking protocol changes bump the major; additive / non-breaking changes bump minor. SessionSyncProtocol.currentVersion reflects the on-wire protocol version, currently 1.

License

MIT — see LICENSE.

About

Deterministic state sync between paired Apple devices — identity, envelopes, snapshots, reducers. WCSession-backed in production, transport-agnostic by design.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages