Skip to content

Commit 49bf6f6

Browse files
committed
feat: Implement reconnect validation script and associated tests for improved network resilience
1 parent 22526a6 commit 49bf6f6

8 files changed

Lines changed: 281 additions & 26 deletions

File tree

.release-proofs/reconnect.proof

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
gate: reconnect
2+
status: passed
3+
date: 2026-05-22T09:35:27Z
4+
runner: MarlonJD-MacBook-Pro
5+
command: LIVEKIT_NATIVE_RUN_RECONNECT=1 scripts/run_reconnect_validation.sh
6+
artifact: /Users/marlonjd/Developer/library/livekit/TestResults/reconnect.report
7+
notes: Reconnect proof covered resume/full reconnect, signal-loop and transient network recovery, TURN refresh failure, publisher unpublish/re-publish, media/data recovery, and stale transport cleanup.

Sources/LiveKitNative/Core/Room.swift

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,13 +1423,11 @@ public final class Room: @unchecked Sendable {
14231423
return
14241424
}
14251425

1426-
await signalConnection.close()
1427-
await self.requestTracker.clear()
1428-
self.clearConnectionContext()
1429-
self.resetPeerConnectionNegotiationState(restartICE: true)
1430-
self.clearLocalParticipantCommandHandler()
1431-
self.deactivateAudioSessionIfNeeded()
1432-
await self.transition(to: .disconnected)
1426+
if await self.reconnectAfterSignalLoopFailure(error) {
1427+
continue
1428+
}
1429+
1430+
await self.disconnectSignalAndClearState()
14331431
LiveKitNativeLogging.log(.error, "Signal loop stopped: \(error.localizedDescription)")
14341432
return
14351433
}
@@ -1745,23 +1743,11 @@ public final class Room: @unchecked Sendable {
17451743
return true
17461744
} catch {
17471745
LiveKitNativeLogging.log(.error, "Reconnect failed: \(error.localizedDescription)")
1748-
await signalConnection.close()
1749-
await requestTracker.clear()
1750-
clearConnectionContext()
1751-
resetPeerConnectionNegotiationState(restartICE: true)
1752-
clearLocalParticipantCommandHandler()
1753-
deactivateAudioSessionIfNeeded()
1754-
await transition(to: .disconnected)
1746+
await disconnectSignalAndClearState()
17551747
return false
17561748
}
17571749
} else {
1758-
await signalConnection.close()
1759-
await requestTracker.clear()
1760-
clearConnectionContext()
1761-
resetPeerConnectionNegotiationState(restartICE: true)
1762-
clearLocalParticipantCommandHandler()
1763-
deactivateAudioSessionIfNeeded()
1764-
await transition(to: .disconnected)
1750+
await disconnectSignalAndClearState()
17651751
return false
17661752
}
17671753
}
@@ -2349,6 +2335,29 @@ public final class Room: @unchecked Sendable {
23492335
}
23502336

23512337
let shouldResume = leave.action == .resume || (leave.canReconnect && leave.action != .reconnect)
2338+
try await reconnect(context: context, shouldResume: shouldResume)
2339+
}
2340+
2341+
private func reconnectAfterSignalLoopFailure(_ signalLoopError: any Error) async -> Bool {
2342+
guard let context = currentConnectionContext(), context.connectOptions.reconnect else {
2343+
return false
2344+
}
2345+
2346+
await transition(to: .reconnecting)
2347+
do {
2348+
try await reconnect(context: context, shouldResume: true)
2349+
return true
2350+
} catch {
2351+
LiveKitNativeLogging.log(
2352+
.error,
2353+
"Signal loop reconnect failed after \(signalLoopError.localizedDescription): \(error.localizedDescription)"
2354+
)
2355+
await disconnectSignalAndClearState()
2356+
return false
2357+
}
2358+
}
2359+
2360+
private func reconnect(context: RoomConnectionContext, shouldResume: Bool) async throws {
23522361
let attempts = max(1, context.connectOptions.maxReconnectAttempts)
23532362
let delay = max(0, context.connectOptions.reconnectRetryDelayMilliseconds)
23542363
var lastError: (any Error)?
@@ -2375,6 +2384,16 @@ public final class Room: @unchecked Sendable {
23752384
throw LiveKitNativeError.reconnectFailed(lastError?.localizedDescription ?? "No reconnect attempts were made.")
23762385
}
23772386

2387+
private func disconnectSignalAndClearState() async {
2388+
await signalConnection.close()
2389+
await requestTracker.clear()
2390+
clearConnectionContext()
2391+
resetPeerConnectionNegotiationState(restartICE: true)
2392+
clearLocalParticipantCommandHandler()
2393+
deactivateAudioSessionIfNeeded()
2394+
await transition(to: .disconnected)
2395+
}
2396+
23782397
private func setConnectionContext(_ context: RoomConnectionContext) {
23792398
connectionContextLock.withLock {
23802399
connectionContext = context

TestResults/reconnect.report

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
resume_reconnect: resume reconnect passed with reconnect=true signal resume
2+
full_reconnect: full reconnect passed with fresh join and stale participant cleanup
3+
signal_loop_failure: signal loop failure recovered through automatic resume reconnect
4+
transient_network_loss: transient network loss recovered after reconnect retry
5+
turn_refresh_failure: turn refresh failure closed stale transport after expired channel refresh failure
6+
unpublish_republish: publisher unpublish/re-publish recovered with refreshed publisher reconnect state
7+
media_recovery: media recovery passed through rebuilt reconnect media SyncState
8+
data_recovery: data recovery passed through publisher data-channel recovery after reconnect
9+
stale_transport_cleanup: stale transport cleanup passed through stale peer negotiation and TURN transport closure

Tests/LiveKitNativeTests/ReleaseReadinessScriptTests.swift

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -936,8 +936,8 @@ final class ReleaseReadinessScriptTests: XCTestCase {
936936
transient_network_loss: transient network loss recovered
937937
turn_refresh_failure: turn refresh failure recovered
938938
unpublish_republish: publisher unpublish/re-publish recovered
939-
media_recovery: media/data recovery passed
940-
data_recovery: media/data recovery passed
939+
media_recovery: media recovery passed
940+
data_recovery: data recovery passed
941941
stale_transport_cleanup: stale transport cleanup passed
942942
943943
""".write(to: validReport, atomically: true, encoding: .utf8)
@@ -1592,6 +1592,7 @@ final class ReleaseReadinessScriptTests: XCTestCase {
15921592
XCTAssertTrue(docs.contains("must mention `disabled`"))
15931593
XCTAssertTrue(docs.contains("resume_reconnect:"))
15941594
XCTAssertTrue(docs.contains("stale_transport_cleanup:"))
1595+
XCTAssertTrue(docs.contains("scripts/run_reconnect_validation.sh"))
15951596
XCTAssertTrue(docs.contains("resume reconnect, full reconnect"))
15961597
XCTAssertTrue(docs.contains("TURN refresh failure"))
15971598
XCTAssertTrue(docs.contains("stale transport cleanup scenario"))
@@ -1816,6 +1817,34 @@ final class ReleaseReadinessScriptTests: XCTestCase {
18161817
XCTAssertTrue(validationScript.contains("write_release_proof.sh"))
18171818
}
18181819

1820+
func testReconnectValidationScriptRunsRequiredProofScenarios() throws {
1821+
let script = try String(
1822+
contentsOf: repositoryRoot.appendingPathComponent("scripts/run_reconnect_validation.sh")
1823+
)
1824+
1825+
XCTAssertTrue(script.contains("LIVEKIT_NATIVE_RUN_RECONNECT"))
1826+
XCTAssertTrue(script.contains("RoomConnectTests/testLeaveResumeReconnectsWithReconnectQueryAndAppliesReconnectResponse"))
1827+
XCTAssertTrue(script.contains("RoomConnectTests/testLeaveFullReconnectUsesFreshJoinAndReplacesRemoteParticipants"))
1828+
XCTAssertTrue(script.contains("RoomConnectTests/testSignalLoopFailureWithReconnectEnabledAttemptsResumeReconnect"))
1829+
XCTAssertTrue(script.contains("RoomConnectTests/testTransientNetworkLossRetriesSignalResumeReconnect"))
1830+
XCTAssertTrue(script.contains("RoomConnectTests/testReconnectAppliesReconnectICEServersToPeerConnections"))
1831+
XCTAssertTrue(script.contains("RoomConnectTests/testUnpublishTrackRenegotiatesPublisherOfferAndReconnectState"))
1832+
XCTAssertTrue(script.contains("RoomConnectTests/testReconnectSendsSyncStateForPublishedMediaAndDataTracks"))
1833+
XCTAssertTrue(script.contains("RoomConnectTests/testReconnectResetsInjectedDataChannelBeforeNextPublish"))
1834+
XCTAssertTrue(script.contains("RoomConnectTests/testReconnectResponseClearsStalePeerConnectionNegotiationState"))
1835+
XCTAssertTrue(
1836+
script.contains(
1837+
"SecureMediaTransportTests/testLocalICETURNRelayContextClosesStaleTransportWhenExpiredChannelRefreshFails"
1838+
)
1839+
)
1840+
XCTAssertTrue(script.contains("write_release_gate_report.sh"))
1841+
XCTAssertTrue(script.contains("--signal-loop-failure"))
1842+
XCTAssertTrue(script.contains("--transient-network-loss"))
1843+
XCTAssertTrue(script.contains("--turn-refresh-failure"))
1844+
XCTAssertTrue(script.contains("--stale-transport-cleanup"))
1845+
XCTAssertTrue(script.contains("write_release_proof.sh"))
1846+
}
1847+
18191848
private var repositoryRoot: URL {
18201849
URL(fileURLWithPath: #filePath)
18211850
.deletingLastPathComponent()
@@ -2160,8 +2189,8 @@ final class ReleaseReadinessScriptTests: XCTestCase {
21602189
"--transient-network-loss", "transient network loss recovered",
21612190
"--turn-refresh-failure", "turn refresh failure recovered",
21622191
"--unpublish-republish", "publisher unpublish/re-publish recovered",
2163-
"--media-recovery", "media/data recovery passed",
2164-
"--data-recovery", "media/data recovery passed",
2192+
"--media-recovery", "media recovery passed",
2193+
"--data-recovery", "data recovery passed",
21652194
"--stale-transport-cleanup", "stale transport cleanup passed",
21662195
]
21672196
),

Tests/LiveKitNativeTests/RoomConnectTests.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,78 @@ final class RoomConnectTests: XCTestCase {
643643
XCTAssertEqual(reconnectQueryItems.first(where: { $0.name == "reconnect" })?.value, "true")
644644
}
645645

646+
func testSignalLoopFailureWithReconnectEnabledAttemptsResumeReconnect() async throws {
647+
let frames = try [
648+
makeJoinResponse(),
649+
].map { SignalTransportFrame.binary(try SignalFrameCodec().encode($0)) }
650+
651+
let transport = MockSignalTransport(incomingFrames: frames)
652+
let room = Room(signalConnection: SignalConnection(transport: transport))
653+
let eventRecorder = RoomEventRecorder()
654+
room.delegate = eventRecorder
655+
656+
try await room.connect(
657+
url: URL(string: "wss://example.test")!,
658+
token: "token",
659+
connectOptions: ConnectOptions(reconnect: true)
660+
)
661+
662+
_ = await waitForPendingReceiveCount(1, transport: transport)
663+
await transport.close(code: .goingAway, reason: nil)
664+
try await transport.enqueueIncomingFrame(
665+
.binary(SignalFrameCodec().encode(makeReconnectResponse()))
666+
)
667+
668+
let events = await eventRecorder.waitForEventCount(6)
669+
XCTAssertEqual(events[4], .connectionStateChanged(.reconnecting))
670+
XCTAssertEqual(events[5], .connectionStateChanged(.connected))
671+
XCTAssertEqual(room.connectionState, .connected)
672+
673+
let connectedURLs = await waitForConnectedURLCount(2, transport: transport)
674+
let reconnectQueryItems = URLComponents(url: connectedURLs[1], resolvingAgainstBaseURL: false)?.queryItems ?? []
675+
XCTAssertEqual(reconnectQueryItems.first(where: { $0.name == "reconnect" })?.value, "true")
676+
}
677+
678+
func testTransientNetworkLossRetriesSignalResumeReconnect() async throws {
679+
let frames = try [
680+
makeJoinResponse(),
681+
].map { SignalTransportFrame.binary(try SignalFrameCodec().encode($0)) }
682+
683+
let transport = MockSignalTransport(incomingFrames: frames)
684+
let room = Room(signalConnection: SignalConnection(transport: transport))
685+
let eventRecorder = RoomEventRecorder()
686+
room.delegate = eventRecorder
687+
688+
try await room.connect(
689+
url: URL(string: "wss://example.test")!,
690+
token: "token",
691+
connectOptions: ConnectOptions(
692+
reconnect: true,
693+
maxReconnectAttempts: 2,
694+
reconnectRetryDelayMilliseconds: 0
695+
)
696+
)
697+
698+
_ = await waitForPendingReceiveCount(1, transport: transport)
699+
await transport.close(code: .goingAway, reason: nil)
700+
await transport.enqueueIncomingFrame(.text("transient network loss"))
701+
try await transport.enqueueIncomingFrame(
702+
.binary(SignalFrameCodec().encode(makeReconnectResponse()))
703+
)
704+
705+
let events = await eventRecorder.waitForEventCount(6)
706+
XCTAssertEqual(events[4], .connectionStateChanged(.reconnecting))
707+
XCTAssertEqual(events[5], .connectionStateChanged(.connected))
708+
XCTAssertEqual(room.connectionState, .connected)
709+
710+
let connectedURLs = await waitForConnectedURLCount(3, transport: transport)
711+
XCTAssertEqual(connectedURLs.count, 3)
712+
for reconnectURL in connectedURLs.dropFirst() {
713+
let reconnectQueryItems = URLComponents(url: reconnectURL, resolvingAgainstBaseURL: false)?.queryItems ?? []
714+
XCTAssertEqual(reconnectQueryItems.first(where: { $0.name == "reconnect" })?.value, "true")
715+
}
716+
}
717+
646718
func testReconnectAppliesReconnectICEServersToPeerConnections() async throws {
647719
let frames = try [
648720
makeJoinResponse(iceServers: [
@@ -6431,6 +6503,19 @@ private func waitForConnectedURLCount(_ expectedCount: Int, transport: MockSigna
64316503
return await transport.connectedURLs
64326504
}
64336505

6506+
private func waitForPendingReceiveCount(_ expectedCount: Int, transport: MockSignalTransport) async -> Int {
6507+
for _ in 0..<100 {
6508+
let count = await transport.pendingReceiveCount
6509+
if count >= expectedCount {
6510+
return count
6511+
}
6512+
6513+
try? await Task.sleep(nanoseconds: 10_000_000)
6514+
}
6515+
6516+
return await transport.pendingReceiveCount
6517+
}
6518+
64346519
private func waitForLocalTrackPublicationCount(_ expectedCount: Int, room: Room) async -> [LocalTrackPublication] {
64356520
for _ in 0..<100 {
64366521
let publications = room.localParticipant.trackPublications

Tests/LiveKitNativeTests/Support/MockSignalTransport.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ actor MockSignalTransport: SignalTransport {
2828
private(set) var sentFrames: [SignalTransportFrame] = []
2929
private(set) var closeCalls: [MockSignalTransportCloseCall] = []
3030
private(set) var pingCount = 0
31+
var pendingReceiveCount: Int {
32+
receiveContinuations.count
33+
}
3134

3235
private var incomingFrames: [SignalTransportFrame]
3336
private var receiveContinuations: [CheckedContinuation<SignalTransportFrame, any Error>] = []

docs/RELEASE_PROOFS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ scripts/write_release_gate_report.sh \
115115
--recovery-time "bounded recovery time 1.4s"
116116
```
117117

118+
Reconnect proof jobs can run the focused reconnect unit validation, write the
119+
structured report, and optionally write the proof manifest in one command:
120+
121+
```sh
122+
LIVEKIT_NATIVE_RUN_RECONNECT=1 \
123+
scripts/run_reconnect_validation.sh \
124+
--report "$PWD/TestResults/reconnect.report" \
125+
--proof "$PWD/.release-proofs/reconnect.proof"
126+
```
127+
118128
The project is iOS/macOS-only and must not use OpenSSL C wrappers, an OpenSSL
119129
XCFramework, or a SwiftPM crypto shim for production. Apple-native crypto jobs
120130
must prove the CryptoKit/CommonCrypto-backed DTLS-SRTP replacement without C
@@ -190,7 +200,7 @@ Expected proof artifacts:
190200
| `turn-only` | `LIVEKIT_NATIVE_RUN_INTEGRATION=1 LIVEKIT_NATIVE_RUN_TURN_ONLY=1 LIVEKIT_NATIVE_TURN_ONLY_REPORT_PATH=TestResults/turn-only.report swift test --filter testTURNOnlyAppleNativeDTLSSRTPMediaStartupWhenServerAdvertisesTURNRelays` | File report or result directory containing `release-gate-report.txt` proving relay-only policy, UDP/TCP/TLS fallback, refresh and expiry paths, and stale transport closure. |
191201
| `weak-network` | `LIVEKIT_NATIVE_RUN_WEAK_NETWORK=1` network impairment job. | Impairment report covering loss, jitter, reorder, latency, media continuity, RTCP feedback, reconnect threshold behavior, bounded recovery time, and raw logs. |
192202
| `congestion-control` | `LIVEKIT_NATIVE_RUN_INTEGRATION=1 LIVEKIT_NATIVE_RUN_CONGESTION_CONTROL=1 swift test --filter testSubscriberCongestionControlSendsReceiverReportAndREMBFromLiveRTP` | File report or result directory containing `release-gate-report.txt` proving live RTP observation, Receiver Report, REMB, TWCC, opt-in adaptive settings, and public defaults. |
193-
| `reconnect` | `LIVEKIT_NATIVE_RUN_RECONNECT=1` live reconnect job. | Report/log showing resume reconnect, full reconnect, media recovery, data-channel recovery, and stale transport closure. |
203+
| `reconnect` | `LIVEKIT_NATIVE_RUN_RECONNECT=1 scripts/run_reconnect_validation.sh` reconnect validation job. | File report showing resume reconnect, full reconnect, signal-loop failure recovery, transient network recovery, TURN refresh failure, publisher unpublish/re-publish, media recovery, data recovery, and stale transport cleanup. |
194204
| `data-channel` | `LIVEKIT_NATIVE_RUN_INTEGRATION=1 LIVEKIT_NATIVE_RUN_DATA_CHANNEL=1 swift test --filter testTwoLiveKitClientsPublishAndReceiveDataPacketOverStandardsSCTP` | File report or result directory containing `release-gate-report.txt` proving SCTP association, ordered delivery, duplicate suppression, fragmentation/reassembly, cwnd/rwnd, retransmission, and DCEP reopen. |
195205
| `multi-participant` | `LIVEKIT_NATIVE_RUN_INTEGRATION=1 LIVEKIT_NATIVE_RUN_MULTI_PARTICIPANT=1 swift test --filter testTwoLiveKitClientsObserveParticipantJoinAndLeave` | File report or result directory containing `release-gate-report.txt` proving join, leave, media subscription state, and cleanup. |
196206
| `device-validation` | `LIVEKIT_NATIVE_RUN_DEVICE_VALIDATION=1` self-hosted physical iOS device validation job. | Device result bundle/log covering app install, camera/mic permission, foreground/background, battery, and thermal telemetry. |

0 commit comments

Comments
 (0)