Skip to content

Commit 03fcd10

Browse files
committed
obs-webrtc: Add trickle ICE support for WHIP
- Reduces connection latency by sending offer after first ICE candidate - (150ms) instead of waiting for full gather (5s). Late candidates are - trickled via PATCH per RFC 8840.
1 parent 395db23 commit 03fcd10

File tree

2 files changed

+199
-12
lines changed

2 files changed

+199
-12
lines changed

plugins/obs-webrtc/whip-output.cpp

Lines changed: 184 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "whip-output.h"
22
#include "whip-utils.h"
33

4+
#include <regex>
45
#include <obs.hpp>
56

67
/*
@@ -37,7 +38,14 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
3738
ice_gathering_mutex(),
3839
ice_gathering_cv(),
3940
ice_gathering_complete(false),
41+
has_first_candidate(false),
42+
offer_sent(false),
4043
has_ice_servers(false),
44+
ice_ufrag(),
45+
ice_pwd(),
46+
pending_candidates(),
47+
pending_candidates_mutex(),
48+
post_response_gather_started(false),
4149
running(false),
4250
start_stop_mutex(),
4351
start_stop_thread(),
@@ -354,14 +362,65 @@ bool WHIPOutput::Setup()
354362
#endif
355363

356364
ice_gathering_complete = false;
365+
has_first_candidate = false;
366+
offer_sent = false;
367+
post_response_gather_started = false;
368+
ice_ufrag.clear();
369+
ice_pwd.clear();
370+
first_mid.clear();
371+
{
372+
std::lock_guard<std::mutex> lock(pending_candidates_mutex);
373+
pending_candidates.clear();
374+
}
375+
357376
peer_connection = std::make_shared<rtc::PeerConnection>(cfg);
358377

378+
// Track when we receive our first ICE candidate
379+
peer_connection->onLocalCandidate([this](rtc::Candidate candidate) {
380+
{
381+
std::lock_guard<std::mutex> lock(ice_gathering_mutex);
382+
if (!has_first_candidate) {
383+
has_first_candidate = true;
384+
first_mid = candidate.mid(); // Saved for end-of-candidates signal
385+
ice_gathering_cv.notify_one();
386+
}
387+
}
388+
// If offer already sent, trickle this candidate immediately
389+
// Otherwise queue it - it will be sent after POST completes
390+
// Only trickle if we have ICE servers (trickle ICE not needed for host-only)
391+
if (has_ice_servers) {
392+
bool should_send = false;
393+
// Lock to synchronize with the flush in Connect()
394+
// This prevents candidates from being lost between setting
395+
// offer_sent and flushing the queue
396+
{
397+
std::lock_guard<std::mutex> lock(pending_candidates_mutex);
398+
if (offer_sent) {
399+
should_send = true;
400+
} else {
401+
pending_candidates.push_back(candidate);
402+
}
403+
}
404+
if (should_send) {
405+
SendTrickleCandidate(candidate);
406+
}
407+
}
408+
});
409+
359410
// Set up async ICE gathering completion notification
360411
peer_connection->onGatheringStateChange([this](rtc::PeerConnection::GatheringState state) {
361412
if (state == rtc::PeerConnection::GatheringState::Complete) {
362-
std::lock_guard<std::mutex> lock(ice_gathering_mutex);
363-
ice_gathering_complete = true;
364-
ice_gathering_cv.notify_one();
413+
{
414+
std::lock_guard<std::mutex> lock(ice_gathering_mutex);
415+
ice_gathering_complete = true;
416+
ice_gathering_cv.notify_one();
417+
}
418+
// Only send end-of-candidates after the final (post-response) gather
419+
// completes, not after the pre-offer OPTIONS gather. This ensures
420+
// candidates from POST response ICE servers aren't ignored (RFC 8840).
421+
if (has_ice_servers && post_response_gather_started) {
422+
SendEndOfCandidates();
423+
}
365424
}
366425
});
367426

@@ -485,23 +544,33 @@ bool WHIPOutput::Connect()
485544
std::vector<std::string> http_headers;
486545

487546
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
488-
// Wait for ICE gathering to complete (with timeout) so candidates are
489-
// bundled in the offer. This is required for P2P WHIP endpoints that
490-
// may not support trickle ICE. Only wait if we have ICE servers.
547+
// Smart waiting: if we have ICE servers, wait for first candidate OR 150ms.
548+
// This gets us at least host candidates quickly, and likely some STUN
549+
// candidates too. Any candidates gathered after offer is sent will be
550+
// trickled via PATCH.
491551
if (has_ice_servers) {
492552
std::unique_lock<std::mutex> lock(ice_gathering_mutex);
493-
if (!ice_gathering_complete) {
494-
auto timeout = std::chrono::milliseconds(5000);
495-
if (!ice_gathering_cv.wait_for(lock, timeout,
496-
[this] { return ice_gathering_complete.load(); })) {
497-
do_log(LOG_WARNING, "ICE gathering timed out; sending offer with partial candidates");
498-
}
553+
if (!has_first_candidate) {
554+
// 150ms balances latency vs. candidate coverage; typically enough for host + STUN
555+
auto timeout = std::chrono::milliseconds(150);
556+
ice_gathering_cv.wait_for(lock, timeout, [this] { return has_first_candidate.load(); });
499557
}
500558
}
501559
#endif
502560

503561
auto offer_sdp = std::string(peer_connection->localDescription().value());
504562

563+
// Extract ICE credentials for trickle PATCH requests
564+
std::regex re_ufrag("a=ice-ufrag:([^\\r\\n]+)");
565+
std::regex re_pwd("a=ice-pwd:([^\\r\\n]+)");
566+
std::smatch match;
567+
if (std::regex_search(offer_sdp, match, re_ufrag)) {
568+
ice_ufrag = match[1];
569+
}
570+
if (std::regex_search(offer_sdp, match, re_pwd)) {
571+
ice_pwd = match[1];
572+
}
573+
505574
#ifdef DEBUG_SDP
506575
do_log(LOG_DEBUG, "Offer SDP:\n%s", offer_sdp.c_str());
507576
#endif
@@ -631,6 +700,23 @@ bool WHIPOutput::Connect()
631700
do_log(LOG_DEBUG, "WHIP Resource URL is: %s", resource_url.c_str());
632701
curl_url_cleanup(url_builder);
633702

703+
// Flush any candidates that arrived during the POST request (trickle ICE only)
704+
// Set offer_sent inside the lock to prevent race with onLocalCandidate callback
705+
if (has_ice_servers) {
706+
std::vector<rtc::Candidate> candidates_to_send;
707+
{
708+
std::lock_guard<std::mutex> lock(pending_candidates_mutex);
709+
offer_sent = true;
710+
candidates_to_send = std::move(pending_candidates);
711+
pending_candidates.clear();
712+
}
713+
for (const auto &candidate : candidates_to_send) {
714+
SendTrickleCandidate(candidate);
715+
}
716+
} else {
717+
offer_sent = true;
718+
}
719+
634720
#ifdef DEBUG_SDP
635721
do_log(LOG_DEBUG, "Answer SDP:\n%s", read_buffer.c_str());
636722
#endif
@@ -677,6 +763,8 @@ bool WHIPOutput::Connect()
677763
// Always gather with POST response servers to:
678764
// 1. Get host candidates even if no ICE servers provided
679765
// 2. Incorporate any TURN servers/credentials from the POST response
766+
// Mark that this is the final gather - end-of-candidates will be sent when complete
767+
post_response_gather_started = true;
680768
peer_connection->gatherLocalCandidates(iceServers);
681769
#endif
682770

@@ -821,6 +909,90 @@ void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared
821909
}
822910
}
823911

912+
void WHIPOutput::SendTrickleIcePatch(const std::string &sdp_frag)
913+
{
914+
struct curl_slist *headers = NULL;
915+
headers = curl_slist_append(headers, "Content-Type: application/trickle-ice-sdpfrag");
916+
if (!bearer_token.empty()) {
917+
auto bearer_token_header = std::string("Authorization: Bearer ") + bearer_token;
918+
headers = curl_slist_append(headers, bearer_token_header.c_str());
919+
}
920+
headers = curl_slist_append(headers, user_agent.c_str());
921+
922+
char error_buffer[CURL_ERROR_SIZE] = {};
923+
924+
CURL *c = curl_easy_init();
925+
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
926+
curl_easy_setopt(c, CURLOPT_URL, resource_url.c_str());
927+
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH");
928+
curl_easy_setopt(c, CURLOPT_COPYPOSTFIELDS, sdp_frag.c_str());
929+
curl_easy_setopt(c, CURLOPT_TIMEOUT, 8L);
930+
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
931+
curl_easy_setopt(c, CURLOPT_UNRESTRICTED_AUTH, 1L);
932+
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, error_buffer);
933+
934+
CURLcode res = curl_easy_perform(c);
935+
if (res != CURLE_OK) {
936+
do_log(LOG_WARNING, "Trickle ICE PATCH failed: %s",
937+
error_buffer[0] ? error_buffer : curl_easy_strerror(res));
938+
} else {
939+
long response_code = 0;
940+
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &response_code);
941+
if (response_code < 200 || response_code >= 300) {
942+
do_log(LOG_WARNING, "Trickle ICE PATCH returned HTTP %ld", response_code);
943+
}
944+
}
945+
946+
curl_easy_cleanup(c);
947+
curl_slist_free_all(headers);
948+
}
949+
950+
void WHIPOutput::SendTrickleCandidate(const rtc::Candidate &candidate)
951+
{
952+
// Guard: credentials not yet extracted from offer SDP
953+
if (resource_url.empty() || ice_ufrag.empty() || ice_pwd.empty()) {
954+
return;
955+
}
956+
957+
// Build SDP fragment with single candidate (RFC 8840 compliant)
958+
std::string sdp_frag;
959+
sdp_frag.append("a=ice-ufrag:" + ice_ufrag + "\r\n");
960+
sdp_frag.append("a=ice-pwd:" + ice_pwd + "\r\n");
961+
std::string mid = candidate.mid();
962+
if (!mid.empty()) {
963+
sdp_frag.append("a=mid:" + mid + "\r\n");
964+
}
965+
sdp_frag.append("a=" + candidate.candidate() + "\r\n");
966+
967+
do_log(LOG_DEBUG, "Trickle ICE candidate (mid=%s): %s", mid.c_str(), candidate.candidate().c_str());
968+
SendTrickleIcePatch(sdp_frag);
969+
}
970+
971+
void WHIPOutput::SendEndOfCandidates()
972+
{
973+
// Guard: credentials not yet extracted from offer SDP
974+
if (resource_url.empty() || ice_ufrag.empty() || ice_pwd.empty()) {
975+
return;
976+
}
977+
978+
// Build SDP fragment with end-of-candidates marker (RFC 8840 compliant)
979+
std::string sdp_frag;
980+
sdp_frag.append("a=ice-ufrag:" + ice_ufrag + "\r\n");
981+
sdp_frag.append("a=ice-pwd:" + ice_pwd + "\r\n");
982+
std::string mid;
983+
{
984+
std::lock_guard<std::mutex> lock(ice_gathering_mutex);
985+
mid = first_mid;
986+
}
987+
if (!mid.empty()) {
988+
sdp_frag.append("a=mid:" + mid + "\r\n");
989+
}
990+
sdp_frag.append("a=end-of-candidates\r\n");
991+
992+
do_log(LOG_DEBUG, "Sending end-of-candidates");
993+
SendTrickleIcePatch(sdp_frag);
994+
}
995+
824996
void register_whip_output()
825997
{
826998
const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_AV;

plugins/obs-webrtc/whip-output.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
#include <condition_variable>
1616

1717
#include <rtc/rtc.hpp>
18+
#include <vector>
19+
#include <rtc/candidate.hpp>
1820

1921
struct videoLayerState {
2022
uint16_t sequenceNumber;
@@ -50,6 +52,9 @@ class WHIPOutput {
5052
bool FetchIceServersViaOptions(std::vector<rtc::IceServer> &iceServers);
5153
void Send(void *data, uintptr_t size, uint64_t duration, std::shared_ptr<rtc::Track> track,
5254
std::shared_ptr<rtc::RtcpSrReporter> rtcp_sr_reporter);
55+
void SendTrickleCandidate(const rtc::Candidate &candidate);
56+
void SendEndOfCandidates();
57+
void SendTrickleIcePatch(const std::string &sdp_frag);
5358

5459
obs_output_t *output;
5560

@@ -60,8 +65,18 @@ class WHIPOutput {
6065
std::mutex ice_gathering_mutex;
6166
std::condition_variable ice_gathering_cv;
6267
std::atomic<bool> ice_gathering_complete;
68+
std::atomic<bool> has_first_candidate;
69+
std::atomic<bool> offer_sent;
6370
bool has_ice_servers;
6471

72+
// Trickle ICE support (RFC 8840)
73+
std::string ice_ufrag;
74+
std::string ice_pwd;
75+
std::string first_mid;
76+
std::vector<rtc::Candidate> pending_candidates; // Queued until POST completes
77+
std::mutex pending_candidates_mutex;
78+
std::atomic<bool> post_response_gather_started; // Distinguishes pre-offer vs final gather
79+
6580
std::atomic<bool> running;
6681

6782
std::mutex start_stop_mutex;

0 commit comments

Comments
 (0)