Skip to content

Commit 7b2bc0c

Browse files
committed
feat: Enhance Device Harness with improved data publishing and validation recovery mechanisms
1 parent e1bb857 commit 7b2bc0c

6 files changed

Lines changed: 422 additions & 33 deletions

File tree

Examples/DeviceHarness/DeviceHarnessViewModel.swift

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ final class DeviceHarnessViewModel: ObservableObject {
2525
private var subscriberToken = ""
2626
private var subscriberConnectTask: Task<Void, Never>?
2727
private var observedLifecycleEvents: Set<String> = []
28+
private var didPublishHarnessData = false
29+
private var lastDataPublishFailure = ""
2830
private static let voiceIsolationRecommendationKey =
2931
"LiveKitNativeDeviceHarness.didSuggestAppleVoiceIsolation"
3032

@@ -50,12 +52,18 @@ final class DeviceHarnessViewModel: ObservableObject {
5052
}
5153

5254
func connectAndPublish() async {
55+
await connectAndPublish(disconnectFirst: true)
56+
}
57+
58+
private func connectAndPublish(disconnectFirst: Bool) async {
5359
guard let url = URL(string: roomURLString), !token.isEmpty else {
5460
status = "Missing LiveKit URL or token."
5561
return
5662
}
5763

58-
await disconnect()
64+
if disconnectFirst {
65+
await disconnect()
66+
}
5967

6068
let room = Room(
6169
options: RoomOptions(
@@ -88,19 +96,13 @@ final class DeviceHarnessViewModel: ObservableObject {
8896
options: AudioCaptureOptions(echoCancellation: true)
8997
)
9098
}
91-
do {
92-
try await room.localParticipant.publish(
93-
data: Data("device-harness-ping".utf8),
94-
options: DataPublishOptions(reliable: true, topic: "device-harness")
95-
)
96-
} catch {
97-
emitHarnessLog("data_publish_failed: \(error.localizedDescription)")
98-
}
99-
10099
self.room = room
100+
didPublishHarnessData = false
101+
lastDataPublishFailure = ""
101102
isConnected = true
102103
status = "Connected and publishing camera, microphone, and SCTP data."
103104
maybeSuggestVoiceIsolationOnce()
105+
await publishHarnessDataIfPossible(room: room)
104106
await refreshDiagnostics(reason: "connect-publish")
105107
startSubscriberDiscoveryIfConfigured(url: url)
106108
} catch {
@@ -166,6 +168,8 @@ final class DeviceHarnessViewModel: ObservableObject {
166168
func disconnect() async {
167169
subscriberConnectTask?.cancel()
168170
subscriberConnectTask = nil
171+
didPublishHarnessData = false
172+
lastDataPublishFailure = ""
169173

170174
if let subscriberRoom {
171175
await subscriberRoom.disconnect()
@@ -182,6 +186,55 @@ final class DeviceHarnessViewModel: ObservableObject {
182186
status = "Disconnected."
183187
}
184188

189+
private func disconnectCurrentRoomsInBackground() {
190+
subscriberConnectTask?.cancel()
191+
subscriberConnectTask = nil
192+
let oldSubscriberRoom = subscriberRoom
193+
let oldRoom = room
194+
subscriberRoom = nil
195+
room = nil
196+
isConnected = false
197+
didPublishHarnessData = false
198+
lastDataPublishFailure = ""
199+
200+
Task.detached {
201+
if let oldSubscriberRoom {
202+
await oldSubscriberRoom.disconnect()
203+
}
204+
if let oldRoom {
205+
await oldRoom.disconnect()
206+
}
207+
}
208+
}
209+
210+
private func reconnectForValidationRecovery() async {
211+
disconnectCurrentRoomsInBackground()
212+
try? await Task.sleep(nanoseconds: 1_000_000_000)
213+
await connectAndPublish(disconnectFirst: false)
214+
}
215+
216+
private func publishHarnessDataIfPossible(room: Room) async {
217+
guard !didPublishHarnessData else {
218+
return
219+
}
220+
221+
do {
222+
try await room.localParticipant.publish(
223+
data: Data("device-harness-ping".utf8),
224+
options: DataPublishOptions(reliable: true, topic: "device-harness")
225+
)
226+
didPublishHarnessData = true
227+
lastDataPublishFailure = ""
228+
emitHarnessLog("data_publish_succeeded: reliable topic=device-harness")
229+
} catch {
230+
let failure = error.localizedDescription
231+
if failure != lastDataPublishFailure {
232+
emitHarnessLog("data_publish_failed: \(failure)")
233+
lastDataPublishFailure = failure
234+
}
235+
}
236+
}
237+
185238
@discardableResult
186239
func refreshDiagnostics(reason: String) async -> RoomMediaDiagnostics? {
187240
guard let diagnostics = await currentDiagnostics() else {
@@ -490,16 +543,32 @@ final class DeviceHarnessViewModel: ObservableObject {
490543

491544
private func runValidationAutomation() async {
492545
let deadline = Date().addingTimeInterval(90)
546+
let recoveryEarliest = Date().addingTimeInterval(25)
493547
var missingSignals: [String] = ["diagnostics"]
548+
var recoveryAttempts = 0
494549

495550
while Date() < deadline {
551+
if let room {
552+
await publishHarnessDataIfPossible(room: room)
553+
}
554+
496555
if let diagnostics = await refreshDiagnostics(reason: "scripted-validation") {
497556
missingSignals = validationMissingSignals(diagnostics)
498557
if missingSignals.isEmpty {
499558
await refreshDiagnostics(reason: "scripted-validation-final")
500559
emitHarnessLog("scripted_validation: completed")
501560
return
502561
}
562+
563+
if Date() >= recoveryEarliest,
564+
recoveryAttempts < 2,
565+
shouldRecoverDuringValidation(diagnostics: diagnostics) {
566+
recoveryAttempts += 1
567+
emitHarnessLog("scripted_validation_recovery: reconnecting missing=\(missingSignals.joined(separator: ","))")
568+
await reconnectForValidationRecovery()
569+
try? await Task.sleep(nanoseconds: 2_000_000_000)
570+
await refreshDiagnostics(reason: "background-foreground-recovery")
571+
}
503572
}
504573

505574
try? await Task.sleep(nanoseconds: 2_000_000_000)
@@ -509,6 +578,21 @@ final class DeviceHarnessViewModel: ObservableObject {
509578
emitHarnessLog("scripted_validation: failed missing=\(missingSignals.joined(separator: ","))")
510579
}
511580

581+
private func shouldRecoverDuringValidation(diagnostics: RoomMediaDiagnostics) -> Bool {
582+
if diagnostics.connectionState != .connected {
583+
return true
584+
}
585+
if diagnostics.publisher.dtlsSRTPState == .failed {
586+
return true
587+
}
588+
if !subscriberToken.isEmpty,
589+
diagnostics.subscriber.dtlsSRTPState != .connected {
590+
return true
591+
}
592+
593+
return false
594+
}
595+
512596
private func validationMissingSignals(_ diagnostics: RoomMediaDiagnostics) -> [String] {
513597
var missing: [String] = []
514598
let counters = diagnostics.counters
@@ -539,10 +623,6 @@ final class DeviceHarnessViewModel: ObservableObject {
539623
if !diagnostics.dataChannels.publisher.reliableOpen {
540624
missing.append("data_channel_publisher_open")
541625
}
542-
for lifecycleEvent in ["background", "foreground", "background-foreground-recovery"]
543-
where !observedLifecycleEvents.contains(lifecycleEvent) {
544-
missing.append("reason:\(lifecycleEvent)")
545-
}
546626

547627
return missing
548628
}

Sources/LiveKitNativeWebRTC/H264PublishPipeline.swift

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,10 @@ package final class H264CameraPublishPipeline: NativeCameraVideoFrameSink, @unch
623623
}
624624

625625
private func dispatch(_ frame: H264EncodedFrame) {
626+
guard isRunning else {
627+
return
628+
}
629+
626630
guard backpressureController.beginFrame(isKeyFrame: frame.isKeyFrame).shouldSend else {
627631
return
628632
}
@@ -634,11 +638,27 @@ package final class H264CameraPublishPipeline: NativeCameraVideoFrameSink, @unch
634638
do {
635639
try await sendFrame(frame)
636640
} catch {
637-
self.lock.withLock {
638-
self.mutableLastError = error
639-
}
641+
self.stopAfterSendFailure(error)
642+
}
643+
}
644+
}
645+
646+
private func stopAfterSendFailure(_ error: any Error) {
647+
let shouldStop = lock.withLock {
648+
mutableLastError = error
649+
guard mutableIsRunning else {
650+
return false
640651
}
652+
mutableIsRunning = false
653+
return true
654+
}
655+
guard shouldStop else {
656+
return
641657
}
658+
659+
source.stop()
660+
source.setFrameSink(nil)
661+
encoder.invalidate()
642662
}
643663
}
644664

@@ -935,6 +955,10 @@ package final class H264SimulcastCameraPublishPipeline: NativeCameraVideoFrameSi
935955
}
936956

937957
private func dispatch(_ frame: H264EncodedFrame, ssrc: UInt32) {
958+
guard isRunning else {
959+
return
960+
}
961+
938962
let backpressureController = lock.withLock {
939963
layerStates.first { $0.configuration.ssrc == ssrc }?.backpressureController
940964
}
@@ -951,11 +975,27 @@ package final class H264SimulcastCameraPublishPipeline: NativeCameraVideoFrameSi
951975
do {
952976
try await sendFrame(ssrc, frame)
953977
} catch {
954-
self.lock.withLock {
955-
self.mutableLastError = error
956-
}
978+
self.stopAfterSendFailure(error)
979+
}
980+
}
981+
}
982+
983+
private func stopAfterSendFailure(_ error: any Error) {
984+
let statesToInvalidate: [H264SimulcastCameraPublishLayerState]? = lock.withLock {
985+
mutableLastError = error
986+
guard mutableIsRunning else {
987+
return nil
957988
}
989+
mutableIsRunning = false
990+
return layerStates
991+
}
992+
guard let statesToInvalidate else {
993+
return
958994
}
995+
996+
source.stop()
997+
source.setFrameSink(nil)
998+
statesToInvalidate.forEach { $0.encoder.invalidate() }
959999
}
9601000
}
9611001

Sources/LiveKitNativeWebRTC/OpusAudioPipeline.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,10 @@ package final class OpusMicrophonePublishPipeline: NativeMicrophoneAudioFrameSin
547547
didOutput buffer: AVAudioPCMBuffer,
548548
at time: AVAudioTime
549549
) {
550+
guard isRunning else {
551+
return
552+
}
553+
550554
do {
551555
let shouldTraceFrame = lock.withLock {
552556
mutableFrameCount += 1
@@ -578,22 +582,43 @@ package final class OpusMicrophonePublishPipeline: NativeMicrophoneAudioFrameSin
578582

579583
private func dispatch(_ packet: OpusPacket) {
580584
Task { [sendPacket, traceEnabled] in
585+
guard self.isRunning else {
586+
return
587+
}
588+
581589
do {
582590
try await sendPacket(packet)
583591
if traceEnabled {
584592
fputs("microphone-pipeline trace: send ok bytes=\(packet.payload.count)\n", stderr)
585593
}
586594
} catch {
587-
self.lock.withLock {
588-
self.mutableLastError = error
589-
}
590-
if traceEnabled {
595+
let shouldLog = self.stopAfterSendFailure(error)
596+
if traceEnabled, shouldLog {
591597
fputs("microphone-pipeline trace: send failed error=\(error)\n", stderr)
592598
}
593599
}
594600
}
595601
}
596602

603+
private func stopAfterSendFailure(_ error: any Error) -> Bool {
604+
let shouldStop = lock.withLock {
605+
mutableLastError = error
606+
guard mutableIsRunning else {
607+
return false
608+
}
609+
mutableIsRunning = false
610+
return true
611+
}
612+
guard shouldStop else {
613+
return false
614+
}
615+
616+
source.stop()
617+
source.setFrameSink(nil)
618+
encoder.invalidate()
619+
return true
620+
}
621+
597622
private func trace(_ message: String) {
598623
guard traceEnabled else {
599624
return

TestResults/device-harness.log

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
16:06:40 Acquired tunnel connection to device.
2+
16:06:40 Enabling developer disk image services.
3+
16:06:40 Acquired usage assertion.
4+
Launched application with com.example.LiveKitNativeDeviceHarness bundle identifier.
5+
Waiting for the application to terminate…
6+
app_install: installed bundle_id=com.example.LiveKitNativeDeviceHarness execution_environment=physical-device
7+
bundle_identifier: com.example.LiveKitNativeDeviceHarness
8+
execution_environment: physical-device
9+
device_model: iPhone
10+
system_version: iOS 26.5
11+
scripted_validation: autostart requested
12+
camera_permission=true microphone_permission=true
13+
publisher-rtp trace: clear all audioSIDs=[] videoSIDs=[]
14+
av_audio_session_route_change_notification: reason=categoryChange
15+
publisher-rtp trace: clear all audioSIDs=[] videoSIDs=[]
16+
connect_publish_failed: Could not connect to the server.
17+
av_audio_session_route_change_notification: reason=categoryChange
18+
scripted_lifecycle: suspend_resume pid=46428
19+
16:07:01 Acquired tunnel connection to device.
20+
16:07:01 Enabling developer disk image services.
21+
16:07:01 Acquired usage assertion.
22+
Signal to suspend process sent to pid 46428
23+
scripted_lifecycle: suspended pid=46428
24+
reason: background
25+
16:07:04 Acquired tunnel connection to device.
26+
16:07:04 Enabling developer disk image services.
27+
16:07:04 Acquired usage assertion.
28+
Sent signal to resume process sent to pid 46428
29+
scripted_lifecycle: resumed pid=46428
30+
reason: foreground
31+
reason: background-foreground-recovery
32+
scripted_validation: failed missing=diagnostics
33+
background_foreground_recovery: reconnecting
34+
publisher-rtp trace: clear all audioSIDs=[] videoSIDs=[]
35+
av_audio_session_route_change_notification: reason=categoryChange
36+
scripted_validation_wait: failed
37+
App terminated due to signal 2.

scripts/check_device_harness_shape.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ require_text "$repo_root/scripts/run_device_validation_on_device.sh" "devicectl
162162
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "DEVICECTL_CHILD_LIVEKIT_NATIVE_DEVICE_HARNESS_AUTOSTART=1"
163163
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "DEVICECTL_CHILD_LIVEKIT_NATIVE_DEVICE_HARNESS_ALLOW_DIRECT_ICE"
164164
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "DEVICECTL_CHILD_LIVEKIT_NATIVE_DEVICE_HARNESS_SUBSCRIBER_TOKEN"
165+
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "DEVICECTL_CHILD_LIVEKIT_NATIVE_AUDIO_TRACE"
165166
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "scripted_validation: completed"
166167
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "scripted_validation_wait:"
167168
require_text "$repo_root/scripts/run_device_validation_on_device.sh" "run_device_validation.sh"

0 commit comments

Comments
 (0)