DEV Community

swainwri
swainwri

Posted on

Swift 6 + MultipeerConnectivity

A Practical, Concurrency‑Safe Tutorial (UIKit, Actors, and Real Devices)

Audience: iOS developers who already know UIKit & basic MultipeerConnectivity and want a correct, Swift 6–compliant architecture that survives real devices, real timing, and real bugs.


Why this tutorial exists

Most MultipeerConnectivity (MPC) examples:

  • ignore concurrency
  • hide delegate chaos behind singletons
  • work in the simulator, then fail on devices
  • break completely under Swift 6

This tutorial documents a production‑grade approach using:

  • Swift 6 actors
  • explicit isolation boundaries
  • UIKit (not SwiftUI)
  • real devices
  • deterministic peer identity and state handling

Everything here was built, debugged, and fixed the hard way.


Architectural overview

We separate responsibilities strictly:

UIKit (ViewController)
        ↓
PeerSessionManager   (@MainActor)
        ↓
MPCActor             (actor)
        ↓
MCSession / Browser / Advertiser (delegates)
Enter fullscreen mode Exit fullscreen mode

Why this matters

  • UIKit must stay on the main thread
  • MultipeerConnectivity delegates are not main‑safe
  • Swift 6 enforces rules older code only implied

Actors are not optional — they are the design.


Core types

PeerSnapshot (immutable, UI‑safe)

struct PeerSnapshot: Identifiable, Equatable {
    let id: UUID
    let displayName: String
}
Enter fullscreen mode Exit fullscreen mode

This is the only peer object the UI ever sees.


PeerConnectionState

enum PeerConnectionState {
    case notConnected
    case connecting
    case connected
}
Enter fullscreen mode Exit fullscreen mode

Never expose MCSessionState to the UI.


MPCActor (the authority)

The actor owns all mutable MPC state:

  • MCPeerID ↔ UUID mapping
  • peer records
  • discovery lifecycle
  • session callbacks
actor MPCActor {

    private var uuidByPeerID: [MCPeerID: UUID] = [:]
    private var peerIDByUUID: [UUID: MCPeerID] = [:]
    private var peersByID: [UUID: PeerRecord] = [:]

    func didDiscoverPeer(_ peerID: MCPeerID) -> PeerSnapshot {
        if let uuid = uuidByPeerID[peerID],
           let snapshot = peersByID[uuid]?.snapshot {
            return snapshot
        }

        let uuid = UUID()
        let snapshot = PeerSnapshot(id: uuid, displayName: peerID.displayName)

        uuidByPeerID[peerID] = uuid
        peerIDByUUID[uuid] = peerID
        peersByID[uuid] = PeerRecord(id: uuid, peerID: peerID, snapshot: snapshot)

        return snapshot
    }
}
Enter fullscreen mode Exit fullscreen mode

Key rule:

UUIDs are created once, inside the actor, and never guessed elsewhere.


PeerSessionManager (@MainActor)

This is the bridge between concurrency and UIKit.

Responsibilities:

  • owns UI‑safe arrays
  • exposes callbacks
  • translates actor events → UI updates
@MainActor
final class PeerSessionManager {

    static let shared = PeerSessionManager()

    private let mpc = MPCActor()

    private(set) var peers: [PeerSnapshot] = []
    private(set) var peerStates: [UUID: PeerConnectionState] = [:]

    var onPeersUpdated: (() -> Void)?
    var onPeerStateChanged: (() -> Void)?
}
Enter fullscreen mode Exit fullscreen mode

Nothing async leaks into UIKit.


Delegate bridging (the critical pattern)

MultipeerConnectivity delegates cannot be actors.

So we bridge safely:

nonisolated func session(
    _ session: MCSession,
    peer peerID: MCPeerID,
    didChange state: MCSessionState
) {
    actor.peerStateChangedFromDelegate(peerID, state: state)
}
Enter fullscreen mode Exit fullscreen mode

Inside the actor:

nonisolated func peerStateChangedFromDelegate(
    _ peerID: MCPeerID,
    state: MCSessionState
) {
    Task {
        await self.handlePeerStateChange(peerID: peerID, state: state)
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

private func handlePeerStateChange(
    peerID: MCPeerID,
    state: MCSessionState
) {
    guard let uuid = uuidByPeerID[peerID] else { return }

    let mapped: PeerConnectionState =
        state == .connected ? .connected :
        state == .connecting ? .connecting :
        .notConnected

    emit(.stateChanged(uuid, mapped))
}
Enter fullscreen mode Exit fullscreen mode

This is the Swift 6‑correct flow.


Invitation handling (the subtle bug)

Wrong approach:

Calling invitationHandler(true, session) immediately.

Why it breaks:

  • the sender flips to .connected too early
  • UI state desyncs
  • debugging becomes impossible

Correct approach:

func advertiser(
    _ advertiser: MCNearbyServiceAdvertiser,
    didReceiveInvitationFromPeer peerID: MCPeerID,
    withContext context: Data?,
    invitationHandler: @escaping (Bool, MCSession?) -> Void
) {
    guard let manager = self.manager,
          let session = manager.session else {
        invitationHandler(false, nil)
        return
    }

    Task { @MainActor in
        if let snapshot = await manager.mpcActorSnapshot(for: peerID) {
            manager.invitationReceived(from: snapshot) { accept in
                invitationHandler(accept, accept ? session : nil)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

UI decides. Transport waits.


UITableView state rendering

let peerSnapshot = ps.peers[indexPath.row]
let state = ps.peerStates[peerSnapshot.id] ?? .notConnected
Enter fullscreen mode Exit fullscreen mode

If this returns .notConnected incorrectly, you have:

  • duplicate UUIDs
  • mismatched snapshot sources
  • actor bypassing

Never patch this in the UI.


Common MPC + Swift 6 mistakes (Appendix)

1. Creating UUIDs outside the actor

Symptom: duplicate peers, state never updates

Fix: one source of truth


2. Calling async APIs from delegate synchronously

Symptom: Swift warnings, race conditions

Fix: nonisolatedTaskawait


3. Updating UI from the actor

Symptom: random crashes, undefined behavior

Fix: actor emits → manager updates → UI reloads


4. Treating @MainActor as a thread

It is not a queue.
It is a semantic boundary.


5. Fighting Swift 6 diagnostics

The compiler is not being pedantic.
It is preventing bugs you will hit.


Final thoughts

MultipeerConnectivity is not hard.
State ownership is.

Swift 6 didn’t make this worse — it made the bugs visible.

If you respect isolation, identity, and flow direction:

  • MPC works
  • UI stays correct
  • debugging becomes sane

This architecture scales.


Built on real devices. Debugged the hard way.

Appendix: Common MultipeerConnectivity + Swift 6 Mistakes

This appendix exists because MultipeerConnectivity (MPC) predates Swift Concurrency. Swift 6 enforces rules that MPC was never designed for. The result is a long list of compiler errors that sound scary but are actually pointing at very specific architectural problems.

This section translates those errors into plain English, shows why they happen, and explains the correct fix.

  1. ❌ “Sending invitationHandler risks causing data races”

What you probably wrote

Task { @MainActor in
invitationHandler(true, session)
}

Why Swift 6 complains

invitationHandler is a non-Sendable escaping closure provided by Apple. It must be called synchronously, exactly once, from the delegate callback.

When you capture it inside a Task, Swift can no longer guarantee:
• when it runs
• which thread runs it
• whether it runs twice

From Swift’s point of view, this is a data race risk.

✅ Correct pattern

Call the handler immediately, then do async work afterwards:

guard let session = manager.session else {
invitationHandler(false, nil)
return
}

invitationHandler(true, session) // synchronous, safe

Task { @MainActor in
// UI updates here
}

Rule: MPC invitation handlers are not async-aware. Treat them like C callbacks.

  1. ❌ “Main actor-isolated property cannot be referenced from a nonisolated context”

Where this appears

Delegate methods such as:
• MCSessionDelegate
• MCNearbyServiceAdvertiserDelegate
• MCNearbyServiceBrowserDelegate

Why this happens

MPC delegate methods are:
• not actor-isolated
• called on arbitrary system threads

Swift 6 correctly forbids this:

nonisolated func advertiser(...) {
manager.session // ❌ illegal
}

Because manager.session lives on the @MainActor.

✅ Correct mental model

Think of MPC delegates as interrupt handlers.

They must:
1. Capture data synchronously
2. Forward events elsewhere
3. Do no UI, no actor state access directly

Correct pattern

nonisolated func advertiser(...) {
Task { @MainActor in
manager.handleInvite(...)
}
}

Rule: Delegates → hop → actor / main

  1. ❌ Calling async code from sync flow (the “backwards async” bug)

Classic example

func didReceiveInvitation(...) {
let snapshot = await mpc.didDiscoverPeer(peerID) // ❌ impossible
}

Why this is wrong

Delegate callbacks are synchronous entry points. You cannot block them waiting for async work.

Trying to do so:
• breaks MPC timing guarantees
• causes invitations to be rejected
• creates subtle deadlocks

✅ Correct approach

Invert control flow:

nonisolated func advertiser(...) {
invitationHandler(true, session)

Task {
    await actor.recordPeer(peerID)
}
Enter fullscreen mode Exit fullscreen mode

}

Rule: Sync system APIs must emit events, not wait for answers.

  1. ❌ Creating multiple UUIDs for the same peer

Symptom
• peerStates[peerSnapshot.id] is always nil
• UI never updates
• Multiple UUIDs for the same peer appear in logs

Root cause

You generated PeerSnapshot in multiple layers:
• Browser delegate
• Advertiser delegate
• Session delegate

Each one did:

let snapshot = PeerSnapshot(id: UUID(), ...)

So the same peer had different identities everywhere.

✅ Correct fix

One source of truth: the actor

uuidByPeerID: [MCPeerID: UUID]
peerIDByUUID: [UUID: MCPeerID]

All snapshots come from MPCActor.didDiscoverPeer().

Rule: IDs are born once, owned once.

  1. ❌ Letting the actor create MCSession / Advertiser / Browser

Why this fails

These objects:
• have delegates
• expect synchronous callbacks
• are not Sendable

Putting them in an actor causes:
• reentrancy issues
• dropped delegate calls
• connection failures

✅ Correct ownership

Object Owner
MCSession PeerSessionManager
Advertiser PeerSessionManager
Browser PeerSessionManager
State + mapping MPCActor

Rule: Actors own logic, not system objects.

  1. ❌ Updating UI from actors or delegates

Wrong

actor.emit { tableView.reloadData() }

Why

UI must:
• run on main thread
• be isolated from concurrency

✅ Correct pipeline

System → Delegate → Actor → Manager → UI

Only the manager talks to UIKit.

  1. ❌ Misunderstanding nonisolated

What nonisolated actually means

“This method can be called without actor protection.”

It does not mean:
• thread-safe
• async-safe
• free to access actor state

When to use it

Only for:
• delegate entry points
• forwarding events

And immediately hop away.

  1. ❌ Trusting MPC error logs too much

Messages like:

Not in connected state, so giving up...

Are symptoms, not causes.

They usually mean:
• invitation handler called too late
• wrong session passed
• peer identity mismatch

Fix architecture first — logs will disappear.

MultipeerConnectivity (Apple)

├─ MCSession / Advertiser / Browser
│ (Apple-owned, ObjC, delegate-based, non-Sendable)

├─ Delegate Bridges (nonisolated)
│ • MCSessionDelegateBridge
│ • MPCAdvertiserDelegateBridge
│ • MPCBrowserDelegateBridge

├─ MPCActor (Swift 6 actor)
│ • Owns peer identity
│ • Owns UUID ↔ MCPeerID mapping
│ • Owns peer lifecycle truth
│ • Emits events

├─ PeerSessionManager (@MainActor)
│ • Owns MCSession lifetime
│ • Owns advertiser/browser lifetime
│ • Owns UI-facing state (arrays, dictionaries)
│ • Translates actor events → UI callbacks

└─ ViewController (UIKit)
• Displays state
• Sends intents (invite, send, etc.)

Final Rule of Thumb

Swift 6 doesn’t make MPC harder — it makes mistakes visible.

If Swift complains:
• it’s usually right
• but the fix is architectural, not syntactic


https://github.com/swainwri/MPCAndSwift6 for project.

Top comments (0)