component conferences: update to manage the new single Jingle session and the ICE restart:

The Jingle Audio/Video Conferences spec has been updated to work with a single Jingle
session. This patch updates the component to handle it, and implements ICE restart
management.

Closing is better handled, and session are replaced in case of duplication (happens
notably in web frontend on tab reload).
This commit is contained in:
Goffi 2026-10-17 17:07:18 +02:00
commit cb570ab3cf
2 changed files with 842 additions and 76 deletions

View file

@ -0,0 +1,318 @@
"""Unit tests for the conferences component (ICE restart paths)."""
from pathlib import Path
from types import SimpleNamespace
from pytest import fixture
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from libervia.backend.plugins.plugin_comp_conferences import Conference
# the SDP line endings are CRLF, as in real offers (RFC 4566)
UPLINK_OFFER_SDP = ("""v=0
o=- 0 0 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111
c=IN IP4 0.0.0.0
a=mid:0
a=sendonly
a=ice-ufrag:uf1
a=ice-pwd:pw1
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=mid:1
a=sendonly
a=ice-ufrag:uf1
a=ice-pwd:pw1
""").replace("\n", "\r\n")
# answer of the renegotiated uplink offer (an ICE restart by the user): new ICE
# credentials, with inline candidates
UPLINK_ANSWER_SDP = ("""v=0
o=- 0 0 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111
c=IN IP4 0.0.0.0
a=mid:0
a=recvonly
a=ice-ufrag:uf3
a=ice-pwd:pw3
a=candidate:1 1 udp 2122260223 192.168.1.1 30000 typ host
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=mid:1
a=recvonly
a=ice-ufrag:uf3
a=ice-pwd:pw3
a=candidate:2 1 udp 2122260223 192.168.1.1 30001 typ host
""").replace("\n", "\r\n")
# new offer for an existing stream (an ICE restart by the SFU): new ICE credentials, with
# inline candidates
DOWNLINK_REOFFER_SDP = ("""v=0
o=- 0 0 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111
c=IN IP4 0.0.0.0
a=mid:0
a=sendonly
a=ice-ufrag:uf9
a=ice-pwd:pw9
a=candidate:1 1 udp 2122260223 192.168.1.1 30000 typ host
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=mid:1
a=sendonly
a=ice-ufrag:uf9
a=ice-pwd:pw9
a=candidate:2 1 udp 2122260223 192.168.1.1 30001 typ host
""").replace("\n", "\r\n")
class MockProtocol:
"""Records the data sent to the SFU."""
client_id = "c0ffee"
def __init__(self):
self.sent = []
def send_data(self, data):
self.sent.append(data)
def sendClose(self):
pass
class MockIceUdp:
"""Records the calls to ice_candidates_add (the transport-info send path)."""
def __init__(self, session: dict):
self.session = session
self.calls = []
def ice_candidates_add(self, client, session_id, content_ice_data):
# like XEP-0176: the session's local ICE data is updated with the new credentials
for name, ice_data in content_ice_data.items():
local_ice_data = (
self.session["contents"]
.get(name, {})
.get("transport_data", {})
.get("local_ice_data")
)
if local_ice_data is not None:
local_ice_data.update(ufrag=ice_data["ufrag"], pwd=ice_data["pwd"])
self.calls.append((client, session_id, content_ice_data))
return defer.succeed(None)
@fixture
def conference():
"""A Conference with mocked parent (SFU protocol and XEP-0176)."""
session = {
"id": "sess123",
"local_jid": jid.JID("conferences.test.example"),
"peer_jid": jid.JID("user@test.example"),
"role": "responder",
"contents": {},
"streams": {},
"uplink_offer_sdp": UPLINK_OFFER_SDP,
}
parent = SimpleNamespace(
client=SimpleNamespace(get_virtual_client=lambda jid_: object()),
_ice_udp=MockIceUdp(session),
)
conf = Conference(
parent,
"testroom",
Path("testroom.json"),
"ws://localhost",
"http://localhost",
session,
)
conf._protocol = MockProtocol()
return conf
def test_restart_uplink_same_credentials(conference):
"""No renegotiation if the uplink credentials are unchanged."""
conference._restart_uplink(
conference.session, {"ufrag": "uf1", "pwd": "pw1", "candidates": []}
)
assert conference._protocol.sent == []
def test_restart_uplink_new_credentials(conference):
"""New uplink credentials trigger a renegotiation offer to the SFU."""
conference._restart_uplink(
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
)
(offer,) = conference._protocol.sent
assert offer["type"] == "offer"
assert offer["id"] == conference.galene_id
assert offer["sdp"].count("a=ice-ufrag:uf2") == 2
assert offer["sdp"].count("a=ice-pwd:pw2") == 2
# the CRLF line endings are preserved (a mangled SDP would not be parsable)
assert offer["sdp"].count("\r\n") == UPLINK_OFFER_SDP.count("\r\n")
# the offer is stored for the next restart
assert conference.session["uplink_offer_sdp"] == offer["sdp"]
assert isinstance(conference.session["answer_sdp_d"], defer.Deferred)
# the renegotiation is pending: the answer will carry new credentials
assert conference.session["uplink_renegotiating"] is True
def test_restart_uplink_no_stored_offer(conference):
"""Without the stored uplink offer, nothing is re-offered."""
del conference.session["uplink_offer_sdp"]
conference._restart_uplink(
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
)
assert conference._protocol.sent == []
def test_restart_uplink_answer_consumed(conference):
"""The answer of the renegotiation offer is consumed by on_answer()."""
conference._restart_uplink(
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
)
conference.on_answer({"sdp": "v=0\n"})
assert conference.session.pop("answer_sdp_d", None) is None
def test_restart_uplink_answer_credentials_forwarded(conference):
"""The answer's ICE credentials and inline candidates are forwarded to the user."""
session = conference.session
for name in ("0", "1"):
session["contents"][name] = {
"transport_data": {
"local_ice_data": {
"ufrag": "uf_old",
"pwd": "pw_old",
"candidates": [],
}
}
}
conference._restart_uplink(session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []})
conference.on_answer({"sdp": UPLINK_ANSWER_SDP})
assert "uplink_renegotiating" not in session
calls = conference.parent._ice_udp.calls
# two transport-info with the new credentials (one per content), then the two inline
# candidates
assert [next(iter(data)) for data in (c[2] for c in calls)] == [
"0",
"1",
"0",
"1",
]
for _, session_id, data in calls:
assert session_id == "sess123"
((name, ice_data),) = data.items()
assert (ice_data["ufrag"], ice_data["pwd"]) == ("uf3", "pw3")
assert calls[2][2]["0"]["candidates"]
assert calls[3][2]["1"]["candidates"]
def test_on_answer_initial_no_credentials_forwarded(conference):
"""Without an ICE restart, the answer is not forwarded to the user."""
session = conference.session
for name in ("0", "1"):
session["contents"][name] = {
"transport_data": {
"local_ice_data": {
"ufrag": "uf3",
"pwd": "pw3",
"candidates": [],
}
}
}
session["answer_sdp_d"] = defer.Deferred()
conference.on_answer({"sdp": UPLINK_ANSWER_SDP})
assert conference.parent._ice_udp.calls == []
async def test_renegotiation_downlink(conference):
"""A re-offered stream: new credentials to the user, stored answer to the SFU."""
session = conference.session
session["streams"] = {"down1": ["2-0", "2-1"]}
session["stream_answer_sdp"] = {"down1": "ANSWER_SDP"}
# the contents of the stream, with the new local ICE data (set in production by the
# ice_candidates_add calls of _handle_renegotiation)
for name, media in (("2-0", "audio"), ("2-1", "video")):
session["contents"][name] = {
"application_data": {"media": media},
"transport_data": {
"local_ice_data": {"ufrag": "uf9", "pwd": "pw9", "candidates": []}
},
}
await conference._handle_renegotiation({"id": "down1", "sdp": DOWNLINK_REOFFER_SDP})
# two transport-info with the new credentials (one per content), then the two inline
# candidates
calls = conference.parent._ice_udp.calls
assert len(calls) == 4
for _, session_id, data in calls:
assert session_id == "sess123"
((name, ice_data),) = data.items()
assert (ice_data["ufrag"], ice_data["pwd"]) == ("uf9", "pw9")
assert [next(iter(data)) for data in (c[2] for c in calls)] == [
"2-0",
"2-1",
"2-0",
"2-1",
]
assert calls[2][2]["2-0"]["candidates"]
assert calls[3][2]["2-1"]["candidates"]
answer = [m for m in conference._protocol.sent if m["type"] == "answer"]
assert len(answer) == 1
assert answer[0]["id"] == "down1"
assert answer[0]["sdp"] == "ANSWER_SDP"
async def test_renegotiation_downlink_no_stored_answer(conference):
"""Without the stored answer, no answer is re-sent to the SFU."""
session = conference.session
session["streams"] = {"down1": ["2-0", "2-1"]}
for name, media in (("2-0", "audio"), ("2-1", "video")):
session["contents"][name] = {
"application_data": {"media": media},
"transport_data": {
"local_ice_data": {"ufrag": "uf9", "pwd": "pw9", "candidates": []}
},
}
await conference._handle_renegotiation({"id": "down1", "sdp": DOWNLINK_REOFFER_SDP})
assert all(m["type"] != "answer" for m in conference._protocol.sent)
async def test_offer_mids_renamed(conference):
"""The mids of a new downlink stream are prefixed with the stream counter."""
session = conference.session
added = []
class MockRtp:
async def call_update(
self, client, session_id, call_data, jingle_child_elts=None
):
added.append(call_data)
return ["1-0", "1-1"]
class MockAvConf:
def build_origin_elt(self, origin):
return None
conference.parent._rtp = MockRtp()
conference.parent._av_conf = MockAvConf()
data = {"id": "down1", "sdp": DOWNLINK_REOFFER_SDP, "username": "user@test.example"}
await conference.on_offer(data)
(call_data,) = added
sdp = call_data["sdp"]
assert "a=mid:1-0" in sdp
assert "a=mid:1-1" in sdp
# the CRLF line endings are preserved (a mangled SDP would not be parsable)
assert sdp.count("\r\n") == DOWNLINK_REOFFER_SDP.count("\r\n")
assert session["streams"] == {"down1": ["1-0", "1-1"]}