Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Sources/BandwidthRTC/BandwidthRTC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -376,14 +381,24 @@ 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
// creates a fresh one rather than reusing stale peer connections.
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)
}
}
}

Expand Down
25 changes: 22 additions & 3 deletions Sources/BandwidthRTC/Signaling/SignalingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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())
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/BandwidthRTC/Signaling/WebSocketProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
4 changes: 4 additions & 0 deletions Sources/BandwidthRTC/Types/BandwidthRTCError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions Tests/BandwidthRTCTests/Mocks/MockWebSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
45 changes: 45 additions & 0 deletions Tests/BandwidthRTCTests/ResourceLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions Tests/BandwidthRTCTests/SignalingClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.6
1.0.7
Loading