From c057de4b6a07b38d574ef8732fa3d69054bc1410 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Tue, 8 Sep 2026 14:06:28 +0200 Subject: [PATCH 1/5] Align the WARP dtls/ice pins with the ones the v4.2.18-warp.1 tag ships It seems that there is a misalignment between branch `warp-4.2.18` and release tag `v4.2.18-warp.1`. This commit simply updates the branch versions of dtls and ice dependencies to the newer tag-defined versions. --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 4ccb67c14c9..3c069e13840 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.4-warp.1 +replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1 -replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.2.7-warp.1 +replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.1 diff --git a/go.sum b/go.sum index fb8ed9b6d33..a2b2799037f 100644 --- a/go.sum +++ b/go.sum @@ -25,10 +25,10 @@ github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/livekit/dtls/v3 v3.1.4-warp.1 h1:oS+wSZDhgIXDLX8cmSeFhfdX28XZxwxLGNOmTP8B+yk= -github.com/livekit/dtls/v3 v3.1.4-warp.1/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= -github.com/livekit/ice/v4 v4.2.7-warp.1 h1:6ieMfxp6ADkM10SR0Fyu2LYe+TJQADxY//1BJAphn4o= -github.com/livekit/ice/v4 v4.2.7-warp.1/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/livekit/dtls/v3 v3.1.5-warp.1 h1:5jo2qQreDnUrgw6Al1F+9xVyXHvWQ2IaQkjF53FHEyU= +github.com/livekit/dtls/v3 v3.1.5-warp.1/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/livekit/ice/v4 v4.4.0-warp.1 h1:P9Y1f02VVx6kkRJxY3jzoS7HnblFbYkgMwiu6iOqCOU= +github.com/livekit/ice/v4 v4.4.0-warp.1/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= From 9603f82b587c20ae9426942eab0e7e81024d8c0e Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Tue, 8 Sep 2026 14:15:28 +0200 Subject: [PATCH 2/5] Clear the ICETransport start cancel when the role switch fails Test failure: TestICETransport_StartContextClearsCancelOnRoleError Run `go test -run '^TestICETransport_StartContextClearsCancelOnRoleError$' ./...` and see failure. StartContext derives a cancellable context and stores its cancel func in t.ctxCancel before dialing. SPED split the blocking Dial/Accept into StartDial/StartAccept plus a later AwaitConnect, which added a second, earlier error return. The subsequent AwaitConnect error path releases the context in the same way as the fix (`ctxCancel(); t.ctxCancel = nil`), but the earlier one returns with the cancel still stored. The result is that the derived context is never released and a subsequent Stop() invokes a cancel func for a start that never happened. The gatherer and its agent are deliberately left running: StartContext did not start them on this path, and the test asserts they survive. --- icetransport.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/icetransport.go b/icetransport.go index a8d799ff7be..c26cc87a929 100644 --- a/icetransport.go +++ b/icetransport.go @@ -179,6 +179,10 @@ func (t *ICETransport) StartContext( if err != nil { t.lock.Lock() + // The derived context is unused past this point + ctxCancel() + t.ctxCancel = nil + return err } From 310ef6d7ed4a8b13a2e307f878093eab9bcf69e8 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Tue, 8 Sep 2026 14:20:20 +0200 Subject: [PATCH 3/5] Advertise goog-sped-v1 in the offer when SPED is enabled Test failure: TestSpedOfferAdvertisesGoogSpedV1 Run `go test -run '^TestSpedOfferAdvertisesGoogSpedV1$' ./...` and see failure. Without this fix the SDP offer carries no a=ice-options line at all, so the assertions for "goog-sped-v1" and "trickle" in test TestSpedOfferAdvertisesGoogSpedV1 both fail. --- peerconnection.go | 2 ++ sdp.go | 9 +++++++++ sdp_test.go | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/peerconnection.go b/peerconnection.go index e59dbc5efce..3727fedbd0f 100644 --- a/peerconnection.go +++ b/peerconnection.go @@ -2959,6 +2959,7 @@ func (pc *PeerConnection) generateUnmatchedSDP( nil, pc.api.settingEngine.getSCTPMaxMessageSize(), false, + pc.api.settingEngine.enableSped, ) } @@ -3147,6 +3148,7 @@ func (pc *PeerConnection) generateMatchedSDP( bundleGroup, pc.api.settingEngine.getSCTPMaxMessageSize(), ignoreRidPauseForRecv, + pc.api.settingEngine.enableSped, ) } diff --git a/sdp.go b/sdp.go index 1525f049415..4dd8b2f2b8f 100644 --- a/sdp.go +++ b/sdp.go @@ -710,6 +710,7 @@ func populateSDP( matchBundleGroup *string, sctpMaxMessageSize uint32, ignoreRidPauseForRecv bool, + enableSped bool, ) (*sdp.SessionDescription, error) { var err error mediaDtlsFingerprints := []DTLSFingerprint{} @@ -791,6 +792,14 @@ func populateSDP( descr = descr.WithValueAttribute(sdp.AttrKeyICELite, "") } + if enableSped { + // Advertise SPED so a libwebrtc peer enables DTLS-in-STUN. + // Without it, it answers with plain DTLS and the fold silently does not happen. + // "trickle" (RFC 8840 S4.1.1) is kept in the list because a peer that sees + // ice-options without it treats us as a non-trickle agent and de-paces ICE. + descr = descr.WithValueAttribute(sdp.AttrKeyICEOptions, "trickle goog-sped-v1") + } + if isExtmapAllowMixed { descr = descr.WithPropertyAttribute(sdp.AttrKeyExtMapAllowMixed) } diff --git a/sdp_test.go b/sdp_test.go index 540ecde7804..fe95480ee97 100644 --- a/sdp_test.go +++ b/sdp_test.go @@ -761,6 +761,7 @@ func TestMediaDescriptionFingerprints(t *testing.T) { nil, 0, false, + false, ) assert.NoError(t, err) @@ -816,6 +817,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), se.ignoreRidPauseForRecv, + false, ) assert.Nil(t, err) @@ -878,6 +880,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), se.ignoreRidPauseForRecv, + false, ) assert.Nil(t, err) @@ -938,6 +941,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -979,6 +983,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1038,6 +1043,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.NoError(t, err) @@ -1072,6 +1078,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1103,6 +1110,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1148,6 +1156,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1189,6 +1198,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx &matchedBundle, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1232,6 +1242,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx &matchedBundle, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) @@ -1266,6 +1277,7 @@ func TestPopulateSDP(t *testing.T) { //nolint:gocyclo,cyclop,maintidx nil, se.getSCTPMaxMessageSize(), false, + false, ) assert.Nil(t, err) From b3d9c9e44cca1d0ef622dddbe87e1001f0923a0b Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Tue, 8 Sep 2026 14:35:34 +0200 Subject: [PATCH 4/5] Put the ICE agent in tentative before the first check so the DTLS fold happens Test failure: TestSpedFoldsDTLSIntoICE Run `go test -run '^TestSpedFoldsDTLSIntoICE$' ./...` and see failure. The agent only piggybacks DTLS once a callback is set. StartContext passes t.dtlsCallback, which is nil in the normal flow, so the agent starts checking with piggybacking off. The real callback comes later from DTLSTransport.start(), which needs the dtls.Conn and so the mux that StartContext creates further down. If the agent finishes its check in that window, the flight goes out as plain DTLS and we pay the round trip SPED is meant to save. This fix installs a simple forwarding shim before the first check leaves, and buffer inbound DTLS until the real callback arrives. Nothing changes when SPED is off. --- icetransport.go | 72 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/icetransport.go b/icetransport.go index c26cc87a929..c026c895044 100644 --- a/icetransport.go +++ b/icetransport.go @@ -19,6 +19,12 @@ import ( "github.com/pion/webrtc/v4/internal/util" ) +// pendingDTLSPacket is an embedded DTLS packet held until DTLSTransport is ready. +type pendingDTLSPacket struct { + packet []byte + rAddr net.Addr +} + // ICETransport allows an application access to information about the ICE // transport over which packets are sent and received. type ICETransport struct { @@ -42,6 +48,10 @@ type ICETransport struct { dtlsCallback func(packet []byte, rAddr net.Addr) + // Inbound DTLS packets the agent delivered before DTLSTransport configured + // its callback (SPED only) + pendingDTLSPackets []pendingDTLSPacket + log logging.LeveledLogger } @@ -123,7 +133,16 @@ func (t *ICETransport) StartContext( if agent == nil { return fmt.Errorf("%w: unable to start ICETransport", errICEAgentNotExist) } - agent.SetDtlsCallback(t.dtlsCallback) + + // The agent only piggybacks DTLS once a callback is set, and DTLSTransport + // cannot set the real one until the mux exists (created below). If ICE + // connects in that gap the handshake falls back to plain DTLS and loses the + // round trip SPED saves. Install a shim now and buffer until it arrives. + if t.gatherer.api.settingEngine.enableSped && t.dtlsCallback == nil { + agent.SetDtlsCallback(t.forwardDTLSPacket) + } else { + agent.SetDtlsCallback(t.dtlsCallback) + } if err := agent.OnConnectionStateChange(func(iceState ice.ConnectionState) { state := newICETransportStateFromICE(iceState) @@ -226,12 +245,53 @@ func (t *ICETransport) StartContext( func (t *ICETransport) SetDtlsCallback(cb func(packet []byte, rAddr net.Addr)) { t.lock.Lock() - defer t.lock.Unlock() - if agent := t.gatherer.getAgent(); agent != nil { - agent.SetDtlsCallback(cb) - } else { - t.dtlsCallback = cb + agent := t.gatherer.getAgent() + + if agent == nil || !t.gatherer.api.settingEngine.enableSped { + if agent != nil { + agent.SetDtlsCallback(cb) + } else { + t.dtlsCallback = cb + } + t.lock.Unlock() + + return } + + // SPED: StartContext put a shim on the agent that forwards to this field, so + // record the callback and hand it whatever arrived before it existed. + t.dtlsCallback = cb + pending := t.pendingDTLSPackets + t.pendingDTLSPackets = nil + t.lock.Unlock() + + if cb == nil { + // Teardown: take the shim off the agent as well. + agent.SetDtlsCallback(nil) + + return + } + + for _, p := range pending { + cb(p.packet, p.rAddr) + } +} + +// forwardDTLSPacket hands an embedded DTLS packet to DTLSTransport, buffering it +// while the DTLS transport is still being built. +func (t *ICETransport) forwardDTLSPacket(packet []byte, rAddr net.Addr) { + t.lock.Lock() + if cb := t.dtlsCallback; cb != nil { + t.lock.Unlock() + cb(packet, rAddr) + + return + } + + buffered := make([]byte, len(packet)) + copy(buffered, packet) + t.pendingDTLSPackets = append(t.pendingDTLSPackets, pendingDTLSPacket{packet: buffered, rAddr: rAddr}) + t.lock.Unlock() } // restart is not exposed currently because ORTC has users create a whole new ICETransport From 5320bf0b5ae71577297075a1176c163f82bc0adb Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Tue, 8 Sep 2026 14:55:28 +0200 Subject: [PATCH 5/5] Fix event loop starvation when injecting embedded DTLS during SPED Test failure: TestWarp Run `go test -run '^TestWarp$' ./...` and see test hanging and timeout. TestWarp intermittently never finishes. Both peers park in handshakeFSM.wait and stay there wit this error: goroutine 37 [chan send]: dtls.(*Conn).InjectInboundPacket conn.go:964 webrtc.(*DTLSTransport).start.func1 dtlstransport.go:351 ice.(*Agent).ReportPiggybacking piggyback.go:241 ice.(*Agent).handleInbound agent.go:1749 ice/internal/taskloop.(*Loop).runLoop taskloop.go:61 InjectInboundPacket sends on an unbuffered channel that only Conn.nextPacket reads, so it blocks until DTLS asks for its next packet. We called it straight from the SPED callback, which runs on the ICE agent's task loop (the one goroutine handling all inbound STUN). Block that and the agent stops sending checks, so the packet both peers are waiting for never arrives. Whether the packet lands while DTLS happens to be reading is down to scheduling, hence the intermittency. This fix queues the packets and lets a dedicated goroutine make the blocking call. Order is preserved, which the handshake needs. The queue is bounded and drops when full (one retransmission is cheaper than stalling the agent). A defer in start() stops the injector, so the error paths leave nothing behind. --- dtlstransport.go | 64 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/dtlstransport.go b/dtlstransport.go index 8d9b4f29c73..9a1bb7f2f08 100644 --- a/dtlstransport.go +++ b/dtlstransport.go @@ -326,6 +326,64 @@ func (t *DTLSTransport) StartContext(ctx context.Context, remoteParameters DTLSP }) } +// dtlsPacketInjector feeds embedded DTLS packets into a dtls.Conn from its own +// goroutine, in arrival order. +// +// Conn.InjectInboundPacket sends on an unbuffered channel that only +// Conn.nextPacket reads, so it blocks until the DTLS stack asks for its next +// packet. The SPED callback runs on the ICE agent's task loop, the one +// goroutine handling all inbound STUN, and blocking that stalls the agent so +// the handshake never completes. Queue instead and let this goroutine block. +type dtlsPacketInjector struct { + packets chan pendingDTLSPacket + done chan struct{} + closeOnce sync.Once +} + +// dtlsInjectorQueueLen is deliberately generous: a DTLS handshake flight is a +// handful of packets, so reaching this bound means the DTLS stack has stopped +// consuming altogether and dropping is the right answer. +const dtlsInjectorQueueLen = 128 + +func newDTLSPacketInjector(conn *dtls.Conn) *dtlsPacketInjector { + injector := &dtlsPacketInjector{ + packets: make(chan pendingDTLSPacket, dtlsInjectorQueueLen), + done: make(chan struct{}), + } + + go func() { + for { + select { + case <-injector.done: + return + case packet := <-injector.packets: + conn.InjectInboundPacket(packet.packet, packet.rAddr) + } + } + }() + + return injector +} + +// enqueue is the SPED callback handed to ICETransport. It never blocks. +func (i *dtlsPacketInjector) enqueue(packet []byte, rAddr net.Addr) { + buffered := make([]byte, len(packet)) + copy(buffered, packet) + + select { + case i.packets <- pendingDTLSPacket{packet: buffered, rAddr: rAddr}: + case <-i.done: + default: + // Queue full: drop rather than block the caller. DTLS retransmits its + // flights, so a drop costs a retransmission. Blocking would cost the + // whole ICE agent. + } +} + +func (i *dtlsPacketInjector) stop() { + i.closeOnce.Do(func() { close(i.done) }) +} + func (t *DTLSTransport) start(remoteParameters DTLSParameters, handshake func(*dtls.Conn) error) error { role, certificate, err := t.prepareStart(remoteParameters) if err != nil { @@ -347,9 +405,9 @@ func (t *DTLSTransport) start(remoteParameters DTLSParameters, handshake func(*d // Configure ICE for SPED after we created the DTLS transport. if t.api.settingEngine.enableSped { - t.iceTransport.SetDtlsCallback(func(packet []byte, rAddr net.Addr) { - dtlsConn.InjectInboundPacket(packet, rAddr) - }) + injector := newDTLSPacketInjector(dtlsConn) + defer injector.stop() + t.iceTransport.SetDtlsCallback(injector.enqueue) } // This awaits the DTLS handshake.