diff --git a/Sources/BandwidthRTC/BandwidthRTC.swift b/Sources/BandwidthRTC/BandwidthRTC.swift index 3cdcbf5..760eb73 100644 --- a/Sources/BandwidthRTC/BandwidthRTC.swift +++ b/Sources/BandwidthRTC/BandwidthRTC.swift @@ -31,6 +31,11 @@ public final class BandwidthRTCClient: @unchecked Sendable { /// Called when the remote side disconnects (subscribe ICE disconnected/failed). public var onRemoteDisconnected: (@Sendable () -> Void)? + /// Called when the signaling WebSocket closes, with a classified reason. + /// `.invalidToken` and `.endpointOccupied` are non-retryable — the token is bad, or another + /// device already holds this endpoint — so the app should not blindly call `connect()` again. + public var onDisconnected: (@Sendable (BandwidthRTCError) -> Void)? + /// Called with Float32 audio samples for visualization after each mic capture or file chunk. /// Array contains 480+ samples (10ms+ at 48kHz). public var onLocalAudioLevel: (@Sendable ([Float32]) -> Void)? @@ -376,7 +381,7 @@ public final class BandwidthRTCClient: @unchecked Sendable { } // Handle disconnect - await signaling.onEvent("close") { [weak self] _ in + await signaling.onEvent("close") { [weak self] data in Logger.shared.warn("WebSocket closed") self?.isConnected = false // Nil out the peer connection manager so a subsequent connect() call @@ -384,6 +389,16 @@ public final class BandwidthRTCClient: @unchecked Sendable { self?.peerConnectionManager?.cleanup() self?.peerConnectionManager = nil self?.mixingDevice = nil + + let statusCode = (try? JSONDecoder().decode(WebSocketCloseInfo.self, from: data))?.statusCode + switch statusCode { + case 403: + self?.onDisconnected?(.invalidToken) + case 409: + self?.onDisconnected?(.endpointOccupied) + default: + self?.onDisconnected?(.webSocketDisconnected) + } } } diff --git a/Sources/BandwidthRTC/Signaling/SignalingClient.swift b/Sources/BandwidthRTC/Signaling/SignalingClient.swift index 5c2fa0e..232c73d 100644 --- a/Sources/BandwidthRTC/Signaling/SignalingClient.swift +++ b/Sources/BandwidthRTC/Signaling/SignalingClient.swift @@ -9,6 +9,19 @@ private let sdkVersion = SDKVersion.current /// Ping interval in seconds. private let pingInterval: TimeInterval = 60 +/// HTTP statuses on a rejected WebSocket handshake that will keep recurring for as long as the +/// underlying condition holds (e.g. a stale token, or another device holding the endpoint). +/// Callers should treat these as non-retryable rather than reconnecting. +private let fatalHandshakeStatusMessages: [Int: String] = [ + 403: "Authentication error: Invalid token", + 409: "Endpoint already has an active connection from a different device", +] + +/// Payload passed to the "close" event handler describing why the socket closed, when known. +struct WebSocketCloseInfo: Codable { + let statusCode: Int? +} + /// Actor that manages the WebSocket connection and JSON-RPC signaling with the BRTC gateway. actor SignalingClient { private let log = Logger.shared @@ -320,7 +333,12 @@ actor SignalingClient { } private func handleReceiveError(_ error: Error) { - log.error("WebSocket receive error: \(error.localizedDescription)") + let statusCode = (webSocket?.response as? HTTPURLResponse)?.statusCode + if let statusCode, let fatalMessage = fatalHandshakeStatusMessages[statusCode] { + log.error(fatalMessage) + } else { + log.error("WebSocket receive error: \(error.localizedDescription)") + } let wasConnected = isConnected isConnected = false @@ -331,9 +349,10 @@ actor SignalingClient { pendingRequests.removeAll() if wasConnected { - // Notify disconnect handler + // Notify disconnect handler with the classified close reason, if known if let handler = eventHandlers["close"] { - handler(Data()) + let closeInfo = WebSocketCloseInfo(statusCode: statusCode) + handler((try? JSONEncoder().encode(closeInfo)) ?? Data()) } } } diff --git a/Sources/BandwidthRTC/Signaling/WebSocketProtocol.swift b/Sources/BandwidthRTC/Signaling/WebSocketProtocol.swift index 7e556a9..7b8099b 100644 --- a/Sources/BandwidthRTC/Signaling/WebSocketProtocol.swift +++ b/Sources/BandwidthRTC/Signaling/WebSocketProtocol.swift @@ -6,6 +6,10 @@ protocol WebSocketProtocol: AnyObject { func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + + /// The HTTP response for the WebSocket upgrade request, once available. + /// Used to detect a rejected handshake (e.g. 403/409) after `receive()` throws. + var response: URLResponse? { get } } extension URLSessionWebSocketTask: WebSocketProtocol {} diff --git a/Sources/BandwidthRTC/Types/BandwidthRTCError.swift b/Sources/BandwidthRTC/Types/BandwidthRTCError.swift index b232196..644699d 100644 --- a/Sources/BandwidthRTC/Types/BandwidthRTCError.swift +++ b/Sources/BandwidthRTC/Types/BandwidthRTCError.swift @@ -3,6 +3,8 @@ import Foundation /// Errors that can occur during BRTC operations. public enum BandwidthRTCError: Error, LocalizedError, Equatable { case invalidToken + /// The gateway rejected the connection because another device already holds this endpoint (HTTP 409). + case endpointOccupied case connectionFailed(String) case signalingError(String) case webSocketDisconnected @@ -19,6 +21,8 @@ public enum BandwidthRTCError: Error, LocalizedError, Equatable { switch self { case .invalidToken: return "Invalid or expired endpoint token" + case .endpointOccupied: + return "Endpoint already has an active connection from a different device" case .connectionFailed(let detail): return "Connection failed: \(detail)" case .signalingError(let detail): diff --git a/Tests/BandwidthRTCTests/Mocks/MockWebSocket.swift b/Tests/BandwidthRTCTests/Mocks/MockWebSocket.swift index 098eb33..268a7d0 100644 --- a/Tests/BandwidthRTCTests/Mocks/MockWebSocket.swift +++ b/Tests/BandwidthRTCTests/Mocks/MockWebSocket.swift @@ -17,6 +17,9 @@ final class MockWebSocket: @unchecked Sendable, WebSocketProtocol { private(set) var cancelCalled: Bool = false private(set) var capturedCancelCode: URLSessionWebSocketTask.CloseCode? + /// Settable so tests can simulate a rejected handshake (e.g. HTTP 403/409). + var response: URLResponse? + // MARK: - WebSocketProtocol func resume() { diff --git a/Tests/BandwidthRTCTests/ResourceLifecycleTests.swift b/Tests/BandwidthRTCTests/ResourceLifecycleTests.swift index 4e1e752..eab5b0d 100644 --- a/Tests/BandwidthRTCTests/ResourceLifecycleTests.swift +++ b/Tests/BandwidthRTCTests/ResourceLifecycleTests.swift @@ -115,6 +115,51 @@ final class ResourceLifecycleTests: XCTestCase { XCTAssertNil(sut.mixingDevice) } + func testCloseEventWith409ReportsEndpointOccupied() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + var reported: BandwidthRTCError? + sut.onDisconnected = { reported = $0 } + + // Another device already holds this endpoint. + let data = try JSONEncoder().encode(WebSocketCloseInfo(statusCode: 409)) + sig.triggerEvent("close", data: data) + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(reported, .endpointOccupied) + } + + func testCloseEventWith403ReportsInvalidToken() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + var reported: BandwidthRTCError? + sut.onDisconnected = { reported = $0 } + + let data = try JSONEncoder().encode(WebSocketCloseInfo(statusCode: 403)) + sig.triggerEvent("close", data: data) + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(reported, .invalidToken) + } + + func testCloseEventWithNoStatusCodeReportsWebSocketDisconnected() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + var reported: BandwidthRTCError? + sut.onDisconnected = { reported = $0 } + + sig.triggerEvent("close") + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(reported, .webSocketDisconnected) + } + func testOperationsAfterCloseEventFail() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) diff --git a/Tests/BandwidthRTCTests/SignalingClientTests.swift b/Tests/BandwidthRTCTests/SignalingClientTests.swift index c50274b..0239308 100644 --- a/Tests/BandwidthRTCTests/SignalingClientTests.swift +++ b/Tests/BandwidthRTCTests/SignalingClientTests.swift @@ -273,4 +273,29 @@ final class SignalingClientTests: XCTestCase { let connected = await sut.isConnected XCTAssertFalse(connected) } + + func testCloseEventCarriesStatusCodeOnRejectedHandshake() async throws { + let mockWS = MockWebSocket() + let sut = SignalingClient { _ in (mockWS, nil) } + + let connectTask = Task { try await sut.connect(authParams: self.validAuthParams, options: nil) } + try await Task.sleep(nanoseconds: 50_000_000) + connectTask.cancel() + + // Another device already holds this endpoint — gateway rejects the handshake with 409. + mockWS.response = HTTPURLResponse(url: URL(string: "wss://example.com")!, statusCode: 409, httpVersion: nil, headerFields: nil) + + let closeExpectation = expectation(description: "close event fired") + var receivedData: Data? + await sut.onEvent("close") { data in + receivedData = data + closeExpectation.fulfill() + } + + mockWS.enqueueError(URLError(.badServerResponse)) + await fulfillment(of: [closeExpectation], timeout: 2.0) + + let closeInfo = try JSONDecoder().decode(WebSocketCloseInfo.self, from: receivedData ?? Data()) + XCTAssertEqual(closeInfo.statusCode, 409) + } } diff --git a/VERSION b/VERSION index ece61c6..f9cbc01 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 \ No newline at end of file +1.0.7 \ No newline at end of file