|
17 | 17 | package io.livekit.android.room |
18 | 18 |
|
19 | 19 | import com.google.protobuf.ByteString |
| 20 | +import io.livekit.android.room.track.TrackException |
20 | 21 | import io.livekit.android.test.MockE2ETest |
21 | 22 | import io.livekit.android.test.events.FlowCollector |
22 | 23 | import io.livekit.android.test.mock.MockDataChannel |
23 | 24 | import io.livekit.android.test.mock.MockPeerConnection |
24 | 25 | import io.livekit.android.test.mock.SignalRequestHandler |
25 | 26 | import io.livekit.android.test.mock.TestData |
26 | 27 | import io.livekit.android.test.util.toPBByteString |
| 28 | +import io.livekit.android.util.TimeoutException |
27 | 29 | import io.livekit.android.util.flow |
28 | 30 | import io.livekit.android.util.toOkioByteString |
| 31 | +import kotlinx.coroutines.CoroutineScope |
29 | 32 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 33 | +import kotlinx.coroutines.SupervisorJob |
| 34 | +import kotlinx.coroutines.async |
| 35 | +import kotlinx.coroutines.cancel |
30 | 36 | import kotlinx.coroutines.test.advanceUntilIdle |
| 37 | +import kotlinx.coroutines.test.runCurrent |
31 | 38 | import livekit.LivekitModels |
32 | 39 | import livekit.LivekitModels.DataPacket |
33 | 40 | import livekit.LivekitRtc |
34 | 41 | import livekit.org.webrtc.PeerConnection |
35 | 42 | import org.junit.Assert |
36 | 43 | import org.junit.Assert.assertEquals |
| 44 | +import org.junit.Assert.assertFalse |
37 | 45 | import org.junit.Assert.assertTrue |
38 | 46 | import org.junit.Before |
39 | 47 | import org.junit.Test |
@@ -370,6 +378,121 @@ class RTCEngineMockE2ETest : MockE2ETest() { |
370 | 378 | assertEquals(TestData.JOIN.join.participant.sid, sid) |
371 | 379 | } |
372 | 380 |
|
| 381 | + /** |
| 382 | + * Regression: an AddTrack timeout used to leave the cid in pendingTrackResolvers, |
| 383 | + * poisoning every subsequent publish of the same track with DuplicateTrackException |
| 384 | + * until the connection was torn down (or the server eventually responded. |
| 385 | + */ |
| 386 | + @Test |
| 387 | + fun addTrackTimeoutDoesNotPoisonRetry() = runTest { |
| 388 | + connect() |
| 389 | + // The default mock handler auto-replies to ADD_TRACK; remove it so we can |
| 390 | + // simulate the "server never responds" case that triggers the timeout. |
| 391 | + wsFactory.unregisterSignalRequestHandler(wsFactory.defaultSignalRequestHandler) |
| 392 | + |
| 393 | + // Use a SupervisorJob so timeout/cancellation in addTrack does not cancel the test scope. |
| 394 | + val supervisor = CoroutineScope(coroutineRule.dispatcher + SupervisorJob()) |
| 395 | + try { |
| 396 | + val cid = TestData.LOCAL_TRACK_PUBLISHED.trackPublished.cid |
| 397 | + |
| 398 | + val firstPublish = supervisor.async { |
| 399 | + rtcEngine.addTrack(cid, "audio", LivekitModels.TrackType.AUDIO, stream = null) |
| 400 | + } |
| 401 | + runCurrent() |
| 402 | + |
| 403 | + // Push past the 20s AddTrack deadline without the server replying. |
| 404 | + testScheduler.advanceTimeBy(21_000) |
| 405 | + runCurrent() |
| 406 | + |
| 407 | + assertTrue("firstPublish should be completed", firstPublish.isCompleted) |
| 408 | + val firstFailure = firstPublish.getCompletionExceptionOrNull() |
| 409 | + assertTrue( |
| 410 | + "Expected TimeoutException, got $firstFailure", |
| 411 | + firstFailure is TimeoutException, |
| 412 | + ) |
| 413 | + |
| 414 | + // Retry with the same cid — must not be rejected by the duplicate guard. |
| 415 | + val secondPublish = supervisor.async { |
| 416 | + rtcEngine.addTrack(cid, "audio", LivekitModels.TrackType.AUDIO, stream = null) |
| 417 | + } |
| 418 | + // runCurrent (not advanceUntilIdle): otherwise the new 20s deadline fires |
| 419 | + // before the simulated server response is delivered. |
| 420 | + runCurrent() |
| 421 | + |
| 422 | + if (secondPublish.isCompleted) { |
| 423 | + val syncFailure = secondPublish.getCompletionExceptionOrNull() |
| 424 | + assertFalse( |
| 425 | + "Retry must not fail synchronously with DuplicateTrackException, got $syncFailure", |
| 426 | + syncFailure is TrackException.DuplicateTrackException, |
| 427 | + ) |
| 428 | + } |
| 429 | + |
| 430 | + // Server now replies for the retry; the second publish should resolve cleanly. |
| 431 | + simulateMessageFromServer(TestData.LOCAL_TRACK_PUBLISHED) |
| 432 | + runCurrent() |
| 433 | + |
| 434 | + assertTrue("secondPublish should be completed", secondPublish.isCompleted) |
| 435 | + val secondFailure = secondPublish.getCompletionExceptionOrNull() |
| 436 | + assertTrue("Retry should have succeeded, got $secondFailure", secondFailure == null) |
| 437 | + assertEquals( |
| 438 | + TestData.LOCAL_TRACK_PUBLISHED.trackPublished.track.sid, |
| 439 | + secondPublish.getCompleted().sid, |
| 440 | + ) |
| 441 | + } finally { |
| 442 | + supervisor.cancel() |
| 443 | + } |
| 444 | + } |
| 445 | + |
| 446 | + /** |
| 447 | + * Regression: caller cancellation of an in-flight addTrack must also clean up |
| 448 | + * the pendingTrackResolvers entry so the same cid can be retried. |
| 449 | + */ |
| 450 | + @Test |
| 451 | + fun addTrackCallerCancellationDoesNotPoisonRetry() = runTest { |
| 452 | + connect() |
| 453 | + wsFactory.unregisterSignalRequestHandler(wsFactory.defaultSignalRequestHandler) |
| 454 | + |
| 455 | + val supervisor = CoroutineScope(coroutineRule.dispatcher + SupervisorJob()) |
| 456 | + try { |
| 457 | + val cid = TestData.LOCAL_TRACK_PUBLISHED.trackPublished.cid |
| 458 | + |
| 459 | + val firstPublish = supervisor.async { |
| 460 | + rtcEngine.addTrack(cid, "audio", LivekitModels.TrackType.AUDIO, stream = null) |
| 461 | + } |
| 462 | + runCurrent() |
| 463 | + assertFalse("firstPublish should still be in-flight", firstPublish.isCompleted) |
| 464 | + |
| 465 | + firstPublish.cancel() |
| 466 | + runCurrent() |
| 467 | + assertTrue("firstPublish should be cancelled", firstPublish.isCancelled) |
| 468 | + |
| 469 | + val secondPublish = supervisor.async { |
| 470 | + rtcEngine.addTrack(cid, "audio", LivekitModels.TrackType.AUDIO, stream = null) |
| 471 | + } |
| 472 | + runCurrent() |
| 473 | + |
| 474 | + if (secondPublish.isCompleted) { |
| 475 | + val syncFailure = secondPublish.getCompletionExceptionOrNull() |
| 476 | + assertFalse( |
| 477 | + "Retry must not fail synchronously with DuplicateTrackException, got $syncFailure", |
| 478 | + syncFailure is TrackException.DuplicateTrackException, |
| 479 | + ) |
| 480 | + } |
| 481 | + |
| 482 | + simulateMessageFromServer(TestData.LOCAL_TRACK_PUBLISHED) |
| 483 | + runCurrent() |
| 484 | + |
| 485 | + assertTrue("secondPublish should be completed", secondPublish.isCompleted) |
| 486 | + val secondFailure = secondPublish.getCompletionExceptionOrNull() |
| 487 | + assertTrue( |
| 488 | + "Retry after cancellation should have succeeded, got $secondFailure", |
| 489 | + secondFailure == null, |
| 490 | + ) |
| 491 | + } finally { |
| 492 | + supervisor.cancel() |
| 493 | + } |
| 494 | + } |
| 495 | + |
373 | 496 | /** |
374 | 497 | * After a soft reconnect, the server reports [LivekitRtc.ReconnectResponse.lastMessageSeq]. The engine |
375 | 498 | * drops buffered reliable payloads up to that sequence (inclusive) and re-sends the remainder on the |
|
0 commit comments