UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: MCNearbyServiceBrowser fails to connect after resetting services

Forums > Swift

Problem:

I'm currently working on a SwiftUI app that involves a multi-peer connectivity feature. The app allows one device to act as the host, while others can join the session. However, I'm encountering issues when trying to establish the connection between the host and the joining peers.

App Context:

In my app, I have implemented the concept of a "lobby," which represents a network of devices actively communicating with each other. The purpose of the lobby is to enable certain data transmission between connected devices. Before fully joining the lobby, users can receive and view basic lobby information, such as the existing members and their selected avatars. This ensures that each user has a unique avatar.

If you review my code, you will notice that I am resetting the MCPeerID every time I call reset(). The reason for doing this is to update the discoveryInfo property of the MCNearbyServiceAdvertiser. I was inspired by this following StackOverflow post. Now, the discoveryInfo does update properly. However, this led to me rewriting much of my codebase and fixing tons of bugs for weeks, only to end up stuck on this new problem that I can't seem to fix. Obviously, I don't want you to spend hours reviewing my actual source code, so I wrote an example project that produces the same result as my "real" project but with a lot less code. To see the whole example project, check out its GitHub repo.. I know this is unrelated to the actual question, but please let me know if you have any better solutions.

Code Overview:

I have a MPCManager class that handles the session, advertising, and browsing functionalities. The key properties and methods that may be causing the issue are as follows:

class MPCManager {
  private var session: MCSession?
  private var advertiser: MCNearbyServiceAdvertiser?
  private var browser: MCNearbyServiceBrowser?

  @Published var localPeer: MCPeerID
  @Published var discoveredPeers = [MCPeerID]()

  // lobbyPeers represents the peers I consider to be part of the lobby. In my app, the system is more complex than this, but I believe this should suffice for my example project
  @Published var lobbyPeers = [MCPeerID]()

  func createSession() {
    session = MCSession(peer: localPeer, securityIdentity: nil, encryptionPreference: .required)
    session?.delegate = self
  }

  func destroySession() {
    session?.disconnect()
    session?.delegate = nil
    session = nil
  }

  func advertise() {
    advertiser = MCNearbyServiceAdvertiser(peer: localPeer, discoveryInfo: discoveryInfo, serviceType: "test-lol")
    advertiser?.delegate = self
    advertiser?.startAdvertisingPeer()

    if browser == nil {
      browse()
    }
  }

  func stopAdvertising() {
    advertiser?.stopAdvertisingPeer()
    advertiser?.delegate = nil
    advertiser = nil
  }

  func browse() {
    browser = MCNearbyServiceBrowser(peer: localPeer, serviceType: "test-lol")
    browser?.delegate = self
    browser?.startBrowsingForPeers()

    if advertiser == nil {
      advertise()
    }
  }

  func stopBrowsing() {
    browser?.stopBrowsingForPeers()
    browser?.delegate = nil
    browser = nil
  }

  func reset() {
    destroySession()
    stopAdvertising()
    stopBrowsing()

    localPeer = MCPeerID(displayName: UUID().uuidString)
    discoveredPeers.removeAll(keepingCapacity: true)
    lobbyPeers.removeAll(keepingCapacity: true)

    createSession()
    advertise()
    browse()
  }

  func join(peer: MCPeerID) {
    // After sending an invitation request, the host of the lobby sends back an invite. When the joining player receives this invite, the code in the closure of `sendInvitationRequest` is run
    sendInvitationRequest(to: peer) { [weak self] in
            DispatchQueue.main.async {
                self!.reset()

                // Resend invitation request after resetting. This resets the MCPeerID. I explained the reason for resetting the MCPeerID in the AppContext
                self!.sendInvitationRequest(to: peer) {
                    self!.sendJoinRequest(to: peer)
                }
            }
        }
  }

  func sendInvitationRequest(to peer: MCPeerID, onConnection: @escaping () -> Void) {
    isJoining = true

    // InvitationContext.invitationRequest is an enum that conforms to Codable
    let invitationRequest = try! JSONEncoder().encode(InvitationContext.invitationRequest)

    connectionClosures[peer] = { [weak self] in
        onConnection()

        // Remove the closure from the dictionary after it is run
        self!.connectionClosures.removeValue(forKey: peer)
    }

    browser!.invitePeer(peer, to: session!, withContext: invitationRequest, timeout: 30)
  }

  func sendInvite(to peer: MCPeerID) {

    // InvitationContext.invite is an enum that conforms to Codable
    let invite = try! JSONEncoder().encode(InvitationContext.invite)
    browser!.invitePeer(peer, to: session!, withContext: invite, timeout: 30)
  }

  // Sends request to join the "lobby"
  func sendLobbyJoinRequest(to peer: MCPeerID) {

    // Request.joinRequest is an enum that conforms to Codable
    let joinRequest = try! JSONEncoder().encode(Request.joinRequest)
    try! session!.send(joinRequest, toPeers: [peer], with: .reliable)
  }

  func acceptJoinRequest(from peer: MCPeerID) {
    // append peer to array of lobbyPeers
    lobbyPeers.append(peer)

    // Request.acceptJoinRequest is an enum that conforms to Codable
    let acceptance = try! JSONEncoder().encode(Request.acceptJoinRequest)
    try! session!.send(acceptance, toPeers: [peer], with: .reliable)
  }
}

Now, aside from the actual class, I feel I should include the relevant delegation methods.

Here is the conformance of MPCManager to MCSessionDelegate:

extension MPCManager: MCSessionDelegate {
  func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        if state == .connected && isJoining {
            isJoining = false
            connectionClosures[peerID]?()
        }
    }

    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        let dataSend = try! JSONDecoder().decode(DataSend.self, from: data)

        switch dataSend {
        case .joinRequest:
            acceptJoinRequest(from: peerID)

        case .acceptJoinRequest:
            lobbyPeers.append(peerID)
        }
    }

  func session(_ session: MCSession, didReceiveCertificate certificate: [Any]?, fromPeer peerID: MCPeerID, certificateHandler: @escaping (Bool) -> Void) {
        certificateHandler(true)
    }
}

Here is the conformance of MPCManager to MCNearbyServiceAdvertiserDelegate:

extension MPCManager: MCNearbyServiceAdvertiserDelegate {
  func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        let invitationContext = try! JSONDecoder().decode(InvitationContext.self, from: context!)

        switch invitationContext {
        case .invite:
            invitationHandler(true, session)

        case .invitationRequest:
            invitationHandler(false, nil)
            sendInvite(to: peerID)
        }
    }
}

Finally, here is the conformace of MPCManager to MCNearbyServiceBrowserDelegate:

extension MPCManager: MCNearbyServiceBrowserDelegate {
  func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
        guard peerID.displayName != localPeer.displayName else { return }
        guard !discoveredPeers.contains(where: { $0.displayName == peerID.displayName }) else { return }

        print("Found peer")
        discoveredPeers.append(peerID)
    }

    func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
        print("Lost peer")
        discoveredPeers.removeAll { $0.displayName == peerID.displayName }
    }
}

In my SwiftUI app, I have a ContentView that provides buttons for hosting and joining, as well as displaying the lobby member count and the peer ID:

struct ContentView: View {
    @EnvironmentObject var mpcManager: MPCManager
    @State private var showJoin = false

    var body: some View {
        VStack {
            Button("Host") {
                mpcManager.advertise()
            }

            Button("Join") {
                mpcManager.browse()
                showJoin = true
            }

            Text("Lobby Member Count: \(mpcManager.lobbyPeers.count)")
            Text("Peer ID: \(mpcManager.localPeer.displayName)")
        }
        .sheet(isPresented: $showJoin, content: JoinView.init)
    }
}

The JoinView displays the discovered peers and allows the user to join them:

struct JoinView: View {
    @EnvironmentObject var mpcManager: MPCManager

    var body: some View {
        VStack {
            ForEach(mpcManager.discoveredPeers) { peer in
                Button("\(peer.displayName)") {
                    mpcManager.join(peer: peer)
                }
            }
        }
        .padding()
    }
}

Issue Details:

When I run the project on the join device and tap on the "Join" button to connect to the host's lobby, I receive the following errors:

[MCNearbyServiceAdvertiser] PeerConnection connectedHandler (advertiser side) - error [Unable to connect].
[MCNearbyServiceAdvertiser] PeerConnection connectedHandler remoteServiceName is nil.
[connection] nw_socket_handle_socket_event [C3:1] Socket SO_ERROR [54: Connection reset by peer]
...
[AGPSession] Couldn't check in handle iDstIDs (-1).
[AGPSession] Couldn't check in handle iDstIDs (0).

Additional Info:

  • The app is built using SwiftUI.
  • The issue seems to occur specifically when a joining device tries to connect to the host's lobby.
  • I have simplified the provided code snippets to focus on the relevant sections. Please consider this when reviewing the code.

I would appreciate any guidance or suggestions on how to resolve this issue. Specifically, I would like to know if there are any potential errors or misconfigurations in the provided code that could be causing the connectivity problem.

Thank you in advance for your help!


UPDATE 2023 Jul. 12 - 1:04 P.M. (PST):

This update includes me finding where the bug likely is

I made some changes to the logic of updating the discoveryInfo. Rather than resetting the MCPeerID right before joining and then attempting to rejoin, I simply do not set the MCNearbyServiceAdvertiser of the joining device until I actually attempt to join. That way, the discoveryInfo of the joining device is set to the the discoveryInfo of the hosting device in the MCNearbyServiceBrowser delegation methods. Then, I can reset all the services only when a device disconnects from a session. Interestingly, this gave me the result I wanted, but with a catch... It only successfully connects after tapping the "Join" button a second time. Let's see the updated code of the MPCManager so I can explain better:

class MPCManager {
  ...
  // Now, the `discoveredPeers` is a dictionary that links discovered peers to their respective discovery info
  @Published var discoveredPeers = [MCPeerID: [String: String]]
  ...
  // A new property `connectionClosures` that runs a closure after connecting to a peer
  private var connectionClosures = [MCPeerID: () -> Void]()

  // A boolean that determines whether the joining player is in the process of joining a lobby
  private var isJoining = false
  ...

  // A method that is called to "host" a lobby when the "Host" button is tapped
  func host() {
    advertise()
    browse()
  }

  // Updated `advertise` to include custom discovery
  // info if I do not want to use the default one.
  // This is for the joining player, where I want to use
  // the discovery info of the host of the lobby.
  func advertise(withDiscoveryInfo info: [String: String]? = nil) {
    // I ensure that the advertiser does not already exist
    guard advertiser == nil else { return }

    advertiser = MCNearbyServiceAdvertiser(peer: localPeer, discoveryInfo: info ?? discoveryInfo, serviceType: "test-lol")
    advertiser?.delegate = self
    advertiser?.startAdvertisingPeer()

    // I no longer browse automatically in this method. It felt weird doing it here
  }

  ...

  func browse() {
    // Ensure browser does not already exist
    guard browser == nil else { return }

    browser = MCNearbyServiceBrowser(peer: localPeer, serviceType: "test-lol")
    browser?.delegate = self
    browser?.startBrowsingForPeers()

    // I no longer automatically advertise here because it felt weird
  }

  ...

  func reset() {
    destroySession()
    stopAdvertising()
    stopBrowsing()

    // Using GCD bc, if I don't I will get the error:
    // `Publishing changes from background threads is not allowed...`
    DispatchQueue.main.async { [weak self] in
      // Though the only property I'm worried about is `localPeer`, 
      // I need the rest of the code in GCD bc I need it to run
      // in this order

      self?.localPeer = MCPeerID(displayName: UUID().uuidString)
      self?.discoveredPeers.removeAll(keepingCapacity: true)
      self?.lobbyPeers.removeAll(keepingCapacity: true)

      self?.createSession()
    }

    // I no longer start the services again. That should only be
    // done when the buttons are pressed
  }

  ...

  // THIS IS WHERE THE ISSUE OCCURS! FOR SOME REASON, CALLING THIS 
  // METHOD THE FIRST TIME DOES NOT RESULT IN SUCCESSFUL JOINING.
  // I TRIED CALLING THE `sendInvitationRequest(to: peer) { ... }`
  // CODE TWICE IN THE METHOD, BUT IT STILL DID NOT WORK. THE
  // CONNECTION ONLY HAPPENS SUCCESSFULLY WHEN PRESSING THE "Join"
  // BUTTON TWICE
  func join(peer: MCPeerID) {
    // All hosts have discovery info, so I need to make sure
    // the peer has some before I join it
    guard let info = discoveredPeers[peer] else { return }

    advertise(withDiscoveryInfo: info)

    // Now that I only want to reset when I leave, I don't include
    // it here. Instead, I simply send an invitation request after advertising.
    sendInvitationRequest(to: peer) { [weak self] in

      // Once connected to the lobby, I send a lobby join request
      self?.sendLobbyJoinRequest(to: peer)
    }
  }

  // This method is called when a player leaves the lobby
  // Note this is never called in the example project, just the actual one
  func leaveLobby() {
    reset()
    ...
  }
  ...
}

extension MPCManager: MCSessionDelegate {
  ...
  func session(_ session: MCSession, didReceive data: Data, fromPeer: MCPeerID) {
    let dataSend = try! JSONDecoder().decode(DataSend.self, from: data)

    switch dataSend {
    ...
    case .acceptJoinRequest:
      DispatchQueue.main.async { [weak self] in
        self?.lobbyPeers.append(peerID)
      }
    }
  }
}
...

extension MPCManager: MCNearbyServiceBrowserDelegate {
  ...
  func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
    guard peerID.displayName != localPeer.displayName else { return }
    guard !discoveredPeers.contains(where: { $0.key.displayName == peerID.displayName }) else { return }

    discoveredPeers[peerID] = info ?? [:]
  }

  func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
    discoveredPeers.removeValue(forKey: peerID)
  }
}

3      

Apologies for some of the formatting. Idk why some of the code is indented incorrectly; the code seems to be indented correctly in the markdown code snippets

3      

Went back and rewrote the code formatting to fix the indenting. The incorrect indentation was too annoying for me to ignore... Hope I didn't waste my time by doing this lol

3      

Updated my post; I added a section explaining that I found where the bug likely is. I just don't know how to fix the issue.

3      

I solved the problem, but with a "hacky" solution that I dislike. It is only temporary, so if someone has another idea, I would love to hear it.

In the join(peer:) method, if I recall the body, but a couple seconds later, the connection works. Safely recalling like this means I would need to add some delay until the "Join" button can be pressed again so that the method body isn't called too many times while in the process of joining. Here is the updated code of the join(peer:) method:

func join(peer: MCPeerID, recurse: Bool = true) {
  guard let info = discoveredPeers[peer] else { return }

  advertise(withDiscoveryInfo: info)

  sendInvitationRequest(to: peer) { [weak self] in
    self?.sendLobbyJoinRequest(to: peer)
  }

  if recurse { // Wait 2 seconds before recalling method
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
      self?.join(peer: peer, recurse: false)
    }
  }
}

For now, I will mark this as the accepted solution since it works, but if anyone provides a cleaner solution, I will gladly try it out and mark it as accepted.


Update 2023 Jul. 13 - 12:04 P.M. (PST):

This update provides a slightly cleaner approach, but not clean enough to my liking

I did some more testing and found out that I don't have to call the entire method all over again. It seems there is some sort of delay before the MCNearbyServiceAdvertiser completely starts advertising and gets set up. In this update, I added a delay of 2 seconds, but only to sendInvitationRequest(to:) inside the join(peer:) method. This resulted in the same result as the original solution, but is a little cleaner. Now I don't have to rely on recursion:

func join(peer: MCPeerID) {
  guard let info = discoveredPeers[peer] else { return }

  advertise(withDiscoveryInfo: info)

  DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
    self?.sendInvitationRequest(to: peer) {
      self?.sendLobbyJoinRequest(to: peer)
    }
  }
}

So now the question is: Is there a way I can detect when the MCNearbyServiceAdvertiser completely starts advertising? This way, I don't have to rely on a constant time of 2 seconds to run the code. I want it to take as much time as it needs to finish. This way, I can ensure that the delay is never too short/long.


See GitHub repo

3      

I know the MCNearbyServiceAdvertiser has some sort of way to check when it is advertising, because when I checked it as a frame variable in the low-level debugger, it showed a variable called _isAdvertising. But I don't know how I might be able to access that.

3      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.