PyAgoraRTC 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.github/workflows/release.yml +3 -1
  2. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/PKG-INFO +1 -1
  3. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/decisions.md +19 -1
  4. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/protocol.md +5 -0
  5. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/session/session.py +18 -6
  6. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyproject.toml +1 -1
  7. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_session_join.py +22 -1
  8. pyagorartc-0.2.2/tests/unit/session/test_session_join_race.py +95 -0
  9. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/uv.lock +1 -1
  10. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.claude/agents/code-reviewer.md +0 -0
  11. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.claude/agents/test-reviewer.md +0 -0
  12. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.github/workflows/ci.yml +0 -0
  13. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.gitignore +0 -0
  14. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.pre-commit-config.yaml +0 -0
  15. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/.python-version +0 -0
  16. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/CLAUDE.md +0 -0
  17. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/CONSTITUTION.md +0 -0
  18. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/CONTRIBUTING.md +0 -0
  19. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/LICENSE +0 -0
  20. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/README.md +0 -0
  21. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/analysis/divergence.md +0 -0
  22. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/analysis/legacy/README.md +0 -0
  23. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/analysis/legacy/agora_api.py +0 -0
  24. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/analysis/legacy/agora_sdp.py +0 -0
  25. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/analysis/legacy/agora_websockets.py +0 -0
  26. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/architecture.md +0 -0
  27. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/backlog.md +0 -0
  28. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/code_style.md +0 -0
  29. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/migration.md +0 -0
  30. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/open_questions.md +0 -0
  31. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/docs/testing.md +0 -0
  32. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/__init__.py +0 -0
  33. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/ap/__init__.py +0 -0
  34. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/ap/client.py +0 -0
  35. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/ap/password.py +0 -0
  36. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/ap/response.py +0 -0
  37. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/const.py +0 -0
  38. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/exceptions.py +0 -0
  39. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/models.py +0 -0
  40. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/py.typed +0 -0
  41. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/rtm/__init__.py +0 -0
  42. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/rtm/client.py +0 -0
  43. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/sdp/__init__.py +0 -0
  44. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/sdp/answer.py +0 -0
  45. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/sdp/candidates.py +0 -0
  46. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/sdp/offer.py +0 -0
  47. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/session/__init__.py +0 -0
  48. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/session/messages.py +0 -0
  49. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/session/recovery.py +0 -0
  50. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/pyagorartc/session/transport.py +0 -0
  51. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/__init__.py +0 -0
  52. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/_helpers.py +0 -0
  53. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/conftest.py +0 -0
  54. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/__init__.py +0 -0
  55. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/__main__.py +0 -0
  56. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/_common.py +0 -0
  57. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/ap.py +0 -0
  58. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/gateway.py +0 -0
  59. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/rtm.py +0 -0
  60. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/scheduler.py +0 -0
  61. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/server.py +0 -0
  62. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fakegateway/state.py +0 -0
  63. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/README.md +0 -0
  64. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_all_failed.json +0 -0
  65. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_gateway_failed.json +0 -0
  66. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_irregular.json +0 -0
  67. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_one_failed.json +0 -0
  68. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_request_expected.json +0 -0
  69. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/choose_server_response.json +0 -0
  70. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/ap/update_ticket_response.json +0 -0
  71. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/error.json +0 -0
  72. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_failed.json +0 -0
  73. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_failed_code_only.json +0 -0
  74. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_ok.json +0 -0
  75. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_ok_no_ortc.json +0 -0
  76. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_ok_rtx.json +0 -0
  77. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_v3_expected.json +0 -0
  78. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/join_v3_inputs.json +0 -0
  79. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/leave_expected.json +0 -0
  80. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/non_object_message.json +0 -0
  81. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_add_video_stream.json +0 -0
  82. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_add_video_stream_h265_pt0.json +0 -0
  83. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_add_video_stream_minimal.json +0 -0
  84. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_add_video_stream_no_ssrc.json +0 -0
  85. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_notification_quit.json +0 -0
  86. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_notification_warn.json +0 -0
  87. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_p2p_lost.json +0 -0
  88. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_p2p_lost_top_level.json +0 -0
  89. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_p2p_lost_top_level_only.json +0 -0
  90. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_p2p_ok.json +0 -0
  91. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_rtp_capability_change.json +0 -0
  92. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_rtp_capability_change_irregular.json +0 -0
  93. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_token_privilege_did_expire.json +0 -0
  94. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_token_privilege_will_expire.json +0 -0
  95. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_user_offline.json +0 -0
  96. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_user_offline_no_uid.json +0 -0
  97. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/on_user_online.json +0 -0
  98. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/ping_back.json +0 -0
  99. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/ping_expected.json +0 -0
  100. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/renew_token_expected.json +0 -0
  101. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/set_client_role_expected.json +0 -0
  102. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/subscribe_expected.json +0 -0
  103. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/unknown_event.json +0 -0
  104. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/gateway/unsubscribe_expected.json +0 -0
  105. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_delivered.json +0 -0
  106. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_failed.json +0 -0
  107. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_failed_sent.json +0 -0
  108. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_mixed_case.json +0 -0
  109. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_offline.json +0 -0
  110. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/ack_sent.json +0 -0
  111. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/rtm/peer_message_request_expected.json +0 -0
  112. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/chrome_answer_shipped.sdp +0 -0
  113. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/chrome_offer_ortc_shipped.json +0 -0
  114. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/chrome_recvonly_offer.sdp +0 -0
  115. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/gateway_join_ortc.json +0 -0
  116. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/gateway_ortc_vp8.json +0 -0
  117. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/go2rtc_offer.sdp +0 -0
  118. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/fixtures/sdp/whep_trickle_fragment.sdpfrag +0 -0
  119. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/__init__.py +0 -0
  120. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/_helpers.py +0 -0
  121. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/conftest.py +0 -0
  122. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_ap_client.py +0 -0
  123. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_fakegateway_contract_ap.py +0 -0
  124. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_fakegateway_contract_control.py +0 -0
  125. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_fakegateway_contract_gateway_events.py +0 -0
  126. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_fakegateway_contract_gateway_join.py +0 -0
  127. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_fakegateway_contract_rtm.py +0 -0
  128. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_rtm_client.py +0 -0
  129. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_session_endings.py +0 -0
  130. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_session_secrets.py +0 -0
  131. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_session_streams.py +0 -0
  132. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_session_timers.py +0 -0
  133. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/integration/test_transport.py +0 -0
  134. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/meta/__init__.py +0 -0
  135. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/meta/_helpers.py +0 -0
  136. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/meta/test_conventions.py +0 -0
  137. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/meta/test_guards.py +0 -0
  138. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/regression/__init__.py +0 -0
  139. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/__init__.py +0 -0
  140. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/_fakes.py +0 -0
  141. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/ap/__init__.py +0 -0
  142. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/ap/test_client.py +0 -0
  143. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/ap/test_password.py +0 -0
  144. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/ap/test_response.py +0 -0
  145. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/conftest.py +0 -0
  146. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/rtm/__init__.py +0 -0
  147. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/rtm/test_client.py +0 -0
  148. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/sdp/__init__.py +0 -0
  149. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/sdp/_helpers.py +0 -0
  150. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/sdp/test_answer.py +0 -0
  151. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/sdp/test_candidates.py +0 -0
  152. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/sdp/test_offer.py +0 -0
  153. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/__init__.py +0 -0
  154. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/_helpers.py +0 -0
  155. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_messages.py +0 -0
  156. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_recovery.py +0 -0
  157. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_session.py +0 -0
  158. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_session_recovery.py +0 -0
  159. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_session_streams.py +0 -0
  160. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_session_teardown.py +0 -0
  161. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_session_timers.py +0 -0
  162. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/session/test_transport.py +0 -0
  163. {pyagorartc-0.2.1 → pyagorartc-0.2.2}/tests/unit/test_models.py +0 -0
@@ -66,7 +66,9 @@ jobs:
66
66
  - name: Create GitHub Release
67
67
  uses: softprops/action-gh-release@v2
68
68
  with:
69
- files: dist/*
69
+ files:
70
+ dist/*.whl
71
+ dist/*.tar.gz
70
72
  generate_release_notes: true
71
73
  prerelease: ${{ steps.version.outputs.prerelease }}
72
74
  env:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: PyAgoraRTC
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Async Python client for Agora RTC signalling: edge discovery, SDP/ORTC negotiation and the WebSocket gateway session
5
5
  Project-URL: Homepage, https://github.com/mikey0000/PyAgoraRTC
6
6
  Project-URL: Documentation, https://github.com/mikey0000/PyAgoraRTC/tree/main/docs
@@ -191,7 +191,7 @@ socket and tasks and fires `on_closed(JOIN_FAILED)` before raising (a socket
191
191
  that closes after the join result but before `join()` acts on it is a failed
192
192
  join, `GatewayConnectError`, not a silently dead joined session); the
193
193
  gateway's quit, a socket close, the deadline and (with D22) `p2p_lost` fire
194
- their reasons. `close()` is idempotent and safe after a failed join. A
194
+ their reasons, including when they land while `join()` is in flight (D29). `close()` is idempotent and safe after a failed join. A
195
195
  candidate added after `join()` is ignored with a DEBUG line (Q4).
196
196
 
197
197
  ## D24. `parse_offer` restores what `sdp_transform` coerces (amends D3)
@@ -250,3 +250,21 @@ defaults to `True`, preserving the Mammotion rule; PetKit passes `False`,
250
250
  which counts the announced uid as present so `on_user_offline` still tears
251
251
  the subscription down. A held stream logs its uid at DEBUG. Q18 decides the
252
252
  eventual default.
253
+
254
+ ## D29. A quit or `p2p_lost` during `join()` fails the join with its own reason (amends D23)
255
+
256
+ The gateway sends the older session's 2003 quit when a second join on the
257
+ same uid lands, which can be right behind the older session's own join
258
+ result: the reader dispatches both before `join()` resumes. The handlers
259
+ used to act only on a joined session, so the quit was dropped and `join()`
260
+ answered for a session the gateway had already quit (a frozen feed in HA).
261
+ A quit, or `p2p_lost` with `end_on_p2p_lost`, that arrives before `join()`
262
+ has marked the session joined is recorded; `join()` then raises
263
+ `GatewayConnectError` and `on_closed` fires once with `GATEWAY_QUIT` /
264
+ `P2P_LOST`, not `JOIN_FAILED`: the join succeeded at the gateway and the
265
+ reason is what the host acts on. It is checked ahead of the socket-close
266
+ race, since the gateway closes the socket after the quit. One that arrives
267
+ before the result fails the join at once instead of waiting out the
268
+ timeout. `GatewayConnectError`, not `JoinRejectedError`: the join result was
269
+ a success, and `JoinRejectedError` carries a join-result code the gateway
270
+ never sent; this is the same shape as the socket-close race.
@@ -659,6 +659,11 @@ Negative case from the same test:
659
659
 
660
660
  - **Mammotion uid sharing.** Every Mammotion stream token shares one viewer uid. A second camera joining the same
661
661
  uid while the first is established gets the first one quit with code 2003.
662
+ - **Timing against the join result.** The fake gateway, and the HA log that prompted D29, deliver the older
663
+ session's quit right after its own join result, often in the same read. A client must treat a quit that arrives
664
+ before it has acted on its join result as ending that join, not drop it for "not joined yet". The timing is from
665
+ a real HA DEBUG log (quit at 11:52:58.467, answer built 8 ms later, no ending reported); the frames themselves
666
+ were not logged.
662
667
  - **How the SDK handles it** (`handleNotification`, SDK:31150-31198): it maps `code` through the table below.
663
668
  - Code 28 with `detail` is a recover notification.
664
669
  - Code 30 is `K_VOS_FALLBACK`, whose `detail` is `"FALLBACKCN"` or `"fallback_hls"`.
@@ -124,6 +124,7 @@ class AgoraSession:
124
124
  self._reader_stopped = False
125
125
  self._join_started = False
126
126
  self._joined = False
127
+ self._pending_end: CloseReason | None = None
127
128
  self._own_uid = creds.uid
128
129
  self._token = creds.token
129
130
  self._rtx = False
@@ -193,8 +194,9 @@ class AgoraSession:
193
194
  JoinTimeoutError: No join result within ``options.join_timeout_s``.
194
195
  SdpError: The offer or the gateway ORTC could not be translated.
195
196
 
196
- On any failure the socket is closed, owned tasks are cancelled and ``on_closed(JOIN_FAILED)`` fires
197
- before the exception propagates (D9, D23).
197
+ On any failure the socket is closed, owned tasks are cancelled and ``on_closed`` fires before the
198
+ exception propagates: ``JOIN_FAILED``, or ``GATEWAY_QUIT`` / ``P2P_LOST`` when the gateway ended the
199
+ session before ``join`` finished, which raises ``GatewayConnectError`` (D9, D23, D29).
198
200
 
199
201
  """
200
202
  if self._join_started or self._close_reason is not None:
@@ -203,7 +205,7 @@ class AgoraSession:
203
205
  try:
204
206
  return await self._join(offer_sdp, session_id)
205
207
  except BaseException:
206
- await self._end(CloseReason.JOIN_FAILED)
208
+ await self._end(self._pending_end or CloseReason.JOIN_FAILED)
207
209
  raise
208
210
 
209
211
  async def renew_token(self, token: str | None = None) -> None:
@@ -274,6 +276,9 @@ class AgoraSession:
274
276
  self._join_waiter = None
275
277
  timer.cancel()
276
278
  join = parse_join_result(frame)
279
+ if self._pending_end is not None:
280
+ # Checked first: the gateway usually closes the socket right after the quit it announced.
281
+ raise GatewayConnectError(f"gateway ended the session during join ({self._pending_end.value})")
277
282
  if self._reader_stopped:
278
283
  # The result arrived, but the message loop stopped before this task resumed to act on it.
279
284
  raise GatewayConnectError("gateway socket closed before the join completed")
@@ -486,8 +491,7 @@ class AgoraSession:
486
491
  notification.code,
487
492
  notification.detail,
488
493
  )
489
- if self._joined:
490
- await self._end(CloseReason.GATEWAY_QUIT)
494
+ await self._end_by_gateway(CloseReason.GATEWAY_QUIT)
491
495
 
492
496
  async def _on_p2p_lost(self, frame: GatewayFrame) -> None:
493
497
  lost = parse_p2p_lost(frame)
@@ -496,8 +500,16 @@ class AgoraSession:
496
500
  _LOGGER.debug("Gateway reported p2p_lost (code %s, %s); ignored", lost.code, lost.error)
497
501
  return
498
502
  _LOGGER.warning("Gateway reported p2p_lost (code %s, %s); ending the session", lost.code, lost.error)
503
+ await self._end_by_gateway(CloseReason.P2P_LOST)
504
+
505
+ async def _end_by_gateway(self, reason: CloseReason) -> None:
506
+ """End a joined session; while ``join`` is in flight, leave the ending for ``join`` to apply (D29)."""
499
507
  if self._joined:
500
- await self._end(CloseReason.P2P_LOST)
508
+ await self._end(reason)
509
+ return
510
+ self._pending_end = self._pending_end or reason
511
+ if (waiter := self._join_waiter) is not None and not waiter.done():
512
+ waiter.set_exception(GatewayConnectError(f"gateway ended the session during join ({reason.value})"))
501
513
 
502
514
  async def _on_p2p_ok(self, frame: GatewayFrame) -> None:
503
515
  ok = parse_p2p_ok(frame.message)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PyAgoraRTC"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Async Python client for Agora RTC signalling: edge discovery, SDP/ORTC negotiation and the WebSocket gateway session"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
8
8
  import pytest
9
9
 
10
10
  from pyagorartc.const import EDGE_DOMAIN_SUFFIX
11
- from pyagorartc.exceptions import JoinRejectedError, JoinTimeoutError
11
+ from pyagorartc.exceptions import GatewayConnectError, JoinRejectedError, JoinTimeoutError
12
12
  from pyagorartc.models import CloseReason, SessionOptions
13
13
  from pyagorartc.sdp import extract_inline_candidates
14
14
  from tests._helpers import RTC_TOKEN
@@ -165,3 +165,24 @@ class TestJoinFailure:
165
165
  with pytest.raises(JoinTimeoutError):
166
166
  await asyncio.wait_for(join, SESSION_TIMEOUT_S)
167
167
  assert r.closed.calls == [CloseReason.JOIN_FAILED]
168
+
169
+ @pytest.mark.regression
170
+ async def test_a_repeat_join_landing_with_the_first_result_fails_the_first_join_as_a_gateway_quit(
171
+ self, fake_agora: FakeAgora, new_session: Callable[..., SessionRig]
172
+ ) -> None:
173
+ """The 2003 quit reaching the first session with its join result was dropped; its ``join`` then answered."""
174
+ fake_agora.control(join_delay_s=5.0)
175
+ first, second = new_session(), new_session()
176
+ first_join = asyncio.ensure_future(first.session.join(CHROME_OFFER, SESSION_ID))
177
+ second_join = asyncio.ensure_future(second.session.join(CHROME_OFFER, SESSION_ID))
178
+ # Each session's join timer and the fake's two held join results.
179
+ await first.sleepers(4)
180
+
181
+ # Both held results fall due together: the first socket gets its result, then the second join's 2003.
182
+ await first.advance(5.0)
183
+
184
+ with pytest.raises(GatewayConnectError):
185
+ await asyncio.wait_for(first_join, SESSION_TIMEOUT_S)
186
+ await asyncio.wait_for(second_join, SESSION_TIMEOUT_S)
187
+ assert first.closed.calls == [CloseReason.GATEWAY_QUIT]
188
+ assert (second.closed.calls, second.session.is_joined) == ([], True)
@@ -0,0 +1,95 @@
1
+ """An ending the gateway announces between the join result and ``join`` resuming (D23, D29)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING
7
+
8
+ import pytest
9
+
10
+ from pyagorartc.exceptions import GatewayConnectError
11
+ from pyagorartc.models import CloseReason, SessionOptions
12
+ from tests._helpers import load_json_fixture
13
+ from tests.unit.session._helpers import OFFER, SESSION_ID, TIMEOUT, all_done, rig
14
+
15
+ if TYPE_CHECKING:
16
+ from tests.unit.session._helpers import Rig
17
+
18
+
19
+ async def join_then(r: Rig, fixture: str) -> asyncio.Task[str]:
20
+ """Start ``join`` and feed the result and ``fixture`` together, so the reader dispatches both before it resumes."""
21
+ task = asyncio.ensure_future(r.session.join(OFFER, SESSION_ID))
22
+ await r.sent(1)
23
+ r.conn.feed({**load_json_fixture("gateway/join_ok.json"), "_id": r.conn.sent[0]["_id"]})
24
+ r.conn.feed(load_json_fixture(fixture))
25
+ return task
26
+
27
+
28
+ class TestEndingDuringJoin:
29
+ @pytest.mark.regression
30
+ async def test_a_quit_right_after_the_result_fails_the_join_as_a_gateway_quit(self) -> None:
31
+ """A 2003 quit dispatched before ``join`` resumed was dropped, so ``join`` answered for a quit session."""
32
+ r = rig()
33
+ task = await join_then(r, "gateway/on_notification_quit.json")
34
+
35
+ with pytest.raises(GatewayConnectError, match="quit"):
36
+ await asyncio.wait_for(task, TIMEOUT)
37
+ assert r.closed.calls == [CloseReason.GATEWAY_QUIT]
38
+ assert not r.session.is_joined
39
+ assert all_done(r.spawned)
40
+ assert r.conn.sent_of_type("leave") == []
41
+
42
+ async def test_a_quit_then_a_socket_close_during_join_reports_the_quit(self) -> None:
43
+ """The gateway closes the socket after quitting; the quit, not the close, is why the join failed."""
44
+ r = rig()
45
+ task = await join_then(r, "gateway/on_notification_quit.json")
46
+ await r.conn.close()
47
+
48
+ with pytest.raises(GatewayConnectError, match="quit"):
49
+ await asyncio.wait_for(task, TIMEOUT)
50
+ assert r.closed.calls == [CloseReason.GATEWAY_QUIT]
51
+
52
+ async def test_a_quit_during_join_is_reported_once_after_close(self) -> None:
53
+ r = rig()
54
+ task = await join_then(r, "gateway/on_notification_quit.json")
55
+ with pytest.raises(GatewayConnectError):
56
+ await asyncio.wait_for(task, TIMEOUT)
57
+
58
+ await r.session.close()
59
+
60
+ assert r.closed.calls == [CloseReason.GATEWAY_QUIT]
61
+
62
+ @pytest.mark.regression
63
+ async def test_p2p_lost_right_after_the_result_fails_the_join_when_asked(self) -> None:
64
+ """With ``end_on_p2p_lost``, a p2p_lost dispatched before ``join`` resumed was dropped and the join succeeded."""
65
+ r = rig(SessionOptions(end_on_p2p_lost=True))
66
+ task = await join_then(r, "gateway/on_p2p_lost.json")
67
+
68
+ with pytest.raises(GatewayConnectError):
69
+ await asyncio.wait_for(task, TIMEOUT)
70
+ assert r.closed.calls == [CloseReason.P2P_LOST]
71
+ assert not r.session.is_joined
72
+ assert all_done(r.spawned)
73
+ assert r.conn.sent_of_type("leave") == []
74
+
75
+ async def test_p2p_lost_right_after_the_result_is_ignored_by_default(self) -> None:
76
+ r = rig()
77
+ task = await join_then(r, "gateway/on_p2p_lost.json")
78
+
79
+ answer = await asyncio.wait_for(task, TIMEOUT)
80
+
81
+ assert "a=setup:" in answer
82
+ assert (r.closed.calls, r.session.is_joined) == ([], True)
83
+
84
+ async def test_a_quit_before_the_result_fails_the_join_without_waiting_for_the_timeout(self) -> None:
85
+ """The quit fails the pending join at once rather than leaving it to ``join_timeout_s``."""
86
+ r = rig()
87
+ task = asyncio.ensure_future(r.session.join(OFFER, SESSION_ID))
88
+ await r.sent(1)
89
+
90
+ r.conn.feed(load_json_fixture("gateway/on_notification_quit.json"))
91
+
92
+ with pytest.raises(GatewayConnectError, match="quit"):
93
+ await asyncio.wait_for(task, TIMEOUT)
94
+ assert r.closed.calls == [CloseReason.GATEWAY_QUIT]
95
+ assert all_done(r.spawned)
@@ -524,7 +524,7 @@ wheels = [
524
524
 
525
525
  [[package]]
526
526
  name = "pyagorartc"
527
- version = "0.2.1"
527
+ version = "0.2.2"
528
528
  source = { editable = "." }
529
529
  dependencies = [
530
530
  { name = "aiohttp" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes