Skip to content

Commit 1b0f730

Browse files
committed
obs-webrtc: Add trickle ICE support for WHIP
Implements pre-offer ICE gathering via HTTP OPTIONS request and trickle ICE for faster WHIP connection times. Changes: - Add FetchIceServersViaOptions() to query ICE servers before offer - Parse Link headers for STUN/TURN URIs and credentials - Implement trickle ICE with async candidate sending - Wait up to 150ms for initial candidates before sending offer - Maintain backwards compatibility when OPTIONS/trickle unavailable
1 parent 65afc7a commit 1b0f730

File tree

2 files changed

+299
-1
lines changed

2 files changed

+299
-1
lines changed

plugins/obs-webrtc/whip-output.cpp

Lines changed: 276 additions & 1 deletion
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
/*
@@ -34,6 +35,17 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
3435
endpoint_url(),
3536
bearer_token(),
3637
resource_url(),
38+
ice_gathering_mutex(),
39+
ice_gathering_cv(),
40+
ice_gathering_complete(false),
41+
has_first_candidate(false),
42+
offer_sent(false),
43+
has_ice_servers(false),
44+
ice_ufrag(),
45+
ice_pwd(),
46+
pending_candidates(),
47+
pending_candidates_mutex(),
48+
post_response_gather_started(false),
3749
running(false),
3850
start_stop_mutex(),
3951
start_stop_thread(),
@@ -266,6 +278,67 @@ bool WHIPOutput::Init()
266278
return true;
267279
}
268280

281+
/**
282+
* @brief Fetch ICE servers via OPTIONS request to WHIP endpoint.
283+
*
284+
* Per WHIP spec, the endpoint may provide STUN/TURN servers via Link headers
285+
* in response to an OPTIONS request. This allows ICE gathering to begin
286+
* before the offer is sent, enabling P2P connections behind NAT.
287+
*
288+
* @param iceServers Vector to populate with discovered ICE servers
289+
* @return bool True if request succeeded (even if no ICE servers found)
290+
*/
291+
bool WHIPOutput::FetchIceServersViaOptions(std::vector<rtc::IceServer> &iceServers)
292+
{
293+
struct curl_slist *headers = nullptr;
294+
headers = curl_slist_append(headers, "Accept: application/sdp");
295+
headers = curl_slist_append(headers, user_agent.c_str());
296+
297+
if (!bearer_token.empty()) {
298+
auto bearer_token_header = std::string("Authorization: Bearer ") + bearer_token;
299+
headers = curl_slist_append(headers, bearer_token_header.c_str());
300+
}
301+
302+
std::vector<std::string> http_headers;
303+
304+
CURL *c = curl_easy_init();
305+
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
306+
curl_easy_setopt(c, CURLOPT_URL, endpoint_url.c_str());
307+
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "OPTIONS");
308+
curl_easy_setopt(c, CURLOPT_NOBODY, 1L);
309+
curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L);
310+
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, curl_header_function);
311+
curl_easy_setopt(c, CURLOPT_HEADERDATA, (void *)&http_headers);
312+
313+
CURLcode res = curl_easy_perform(c);
314+
curl_easy_cleanup(c);
315+
curl_slist_free_all(headers);
316+
317+
if (res != CURLE_OK) {
318+
do_log(LOG_DEBUG, "OPTIONS request failed: %s (will proceed without pre-configured ICE servers)",
319+
curl_easy_strerror(res));
320+
return false;
321+
}
322+
323+
for (auto &http_header : http_headers) {
324+
auto value = value_for_header("link", http_header);
325+
if (value.empty())
326+
continue;
327+
328+
for (auto end = value.find(","); end != std::string::npos; end = value.find(",")) {
329+
this->ParseLinkHeader(value.substr(0, end), iceServers);
330+
value = value.substr(end + 1);
331+
}
332+
this->ParseLinkHeader(value, iceServers);
333+
}
334+
335+
if (!iceServers.empty()) {
336+
do_log(LOG_INFO, "Discovered %zu ICE server(s) via OPTIONS request", iceServers.size());
337+
}
338+
339+
return true;
340+
}
341+
269342
/**
270343
* @brief Set up the PeerConnection and media tracks.
271344
*
@@ -275,12 +348,82 @@ bool WHIPOutput::Setup()
275348
{
276349
rtc::Configuration cfg;
277350

351+
// Fetch ICE servers via OPTIONS request (per WHIP spec section 4.4)
352+
std::vector<rtc::IceServer> iceServers;
353+
FetchIceServersViaOptions(iceServers);
354+
has_ice_servers = !iceServers.empty();
355+
if (has_ice_servers) {
356+
cfg.iceServers = iceServers;
357+
}
358+
278359
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
279-
cfg.disableAutoGathering = true;
360+
// Enable auto-gathering if we have ICE servers from OPTIONS
361+
cfg.disableAutoGathering = iceServers.empty();
280362
#endif
281363

364+
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+
282376
peer_connection = std::make_shared<rtc::PeerConnection>(cfg);
283377

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+
410+
// Set up async ICE gathering completion notification
411+
peer_connection->onGatheringStateChange([this](rtc::PeerConnection::GatheringState state) {
412+
if (state == rtc::PeerConnection::GatheringState::Complete) {
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+
}
424+
}
425+
});
426+
284427
peer_connection->onStateChange([this](rtc::PeerConnection::State state) {
285428
switch (state) {
286429
case rtc::PeerConnection::State::New:
@@ -400,8 +543,34 @@ bool WHIPOutput::Connect()
400543
std::string read_buffer;
401544
std::vector<std::string> http_headers;
402545

546+
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
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.
551+
if (has_ice_servers) {
552+
std::unique_lock<std::mutex> lock(ice_gathering_mutex);
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(); });
557+
}
558+
}
559+
#endif
560+
403561
auto offer_sdp = std::string(peer_connection->localDescription().value());
404562

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+
405574
#ifdef DEBUG_SDP
406575
do_log(LOG_DEBUG, "Offer SDP:\n%s", offer_sdp.c_str());
407576
#endif
@@ -531,6 +700,23 @@ bool WHIPOutput::Connect()
531700
do_log(LOG_DEBUG, "WHIP Resource URL is: %s", resource_url.c_str());
532701
curl_url_cleanup(url_builder);
533702

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+
534720
#ifdef DEBUG_SDP
535721
do_log(LOG_DEBUG, "Answer SDP:\n%s", read_buffer.c_str());
536722
#endif
@@ -574,6 +760,11 @@ bool WHIPOutput::Connect()
574760
doCleanup(false);
575761

576762
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
763+
// Always gather with POST response servers to:
764+
// 1. Get host candidates even if no ICE servers provided
765+
// 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;
577768
peer_connection->gatherLocalCandidates(iceServers);
578769
#endif
579770

@@ -718,6 +909,90 @@ void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared
718909
}
719910
}
720911

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+
721996
void register_whip_output()
722997
{
723998
const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_AV;

plugins/obs-webrtc/whip-output.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@
1111
#include <mutex>
1212
#include <thread>
1313
#include <algorithm>
14+
#include <chrono>
15+
#include <condition_variable>
1416

1517
#include <rtc/rtc.hpp>
18+
#include <vector>
19+
#include <rtc/candidate.hpp>
1620

1721
struct videoLayerState {
1822
uint16_t sequenceNumber;
@@ -45,15 +49,34 @@ class WHIPOutput {
4549
void SendDelete();
4650
void StopThread(bool signal);
4751
void ParseLinkHeader(std::string linkHeader, std::vector<rtc::IceServer> &iceServers);
52+
bool FetchIceServersViaOptions(std::vector<rtc::IceServer> &iceServers);
4853
void Send(void *data, uintptr_t size, uint64_t duration, std::shared_ptr<rtc::Track> track,
4954
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);
5058

5159
obs_output_t *output;
5260

5361
std::string endpoint_url;
5462
std::string bearer_token;
5563
std::string resource_url;
5664

65+
std::mutex ice_gathering_mutex;
66+
std::condition_variable ice_gathering_cv;
67+
std::atomic<bool> ice_gathering_complete;
68+
std::atomic<bool> has_first_candidate;
69+
std::atomic<bool> offer_sent;
70+
bool has_ice_servers;
71+
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+
5780
std::atomic<bool> running;
5881

5982
std::mutex start_stop_mutex;

0 commit comments

Comments
 (0)