Wire and test renomination

This commit is contained in:
Joe Turki
2025-12-14 15:20:38 +02:00
parent 4f7fcbe58f
commit 2b85befb35
4 changed files with 894 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/pion/ice/v4" "github.com/pion/ice/v4"
"github.com/pion/logging" "github.com/pion/logging"
@@ -105,6 +106,7 @@ func (g *ICEGatherer) buildAgentOptions() []ice.AgentOption {
options = append(options, g.natRewriteOptions(nat1To1CandiTyp)...) options = append(options, g.natRewriteOptions(nat1To1CandiTyp)...)
options = append(options, g.timeoutOptions()...) options = append(options, g.timeoutOptions()...)
options = append(options, g.miscOptions()...) options = append(options, g.miscOptions()...)
options = append(options, g.renominationOptions()...)
requestedNetworkTypes := g.api.settingEngine.candidates.ICENetworkTypes requestedNetworkTypes := g.api.settingEngine.candidates.ICENetworkTypes
if len(requestedNetworkTypes) == 0 { if len(requestedNetworkTypes) == 0 {
@@ -247,6 +249,31 @@ func (g *ICEGatherer) miscOptions() []ice.AgentOption {
return opts return opts
} }
func (g *ICEGatherer) renominationOptions() []ice.AgentOption {
renom := g.api.settingEngine.renomination
if !renom.enabled && !renom.automatic {
return nil
}
generator := renom.generator
opts := []ice.AgentOption{
ice.WithRenomination(func() uint32 {
return generator()
}),
}
if renom.automatic {
interval := time.Duration(0)
if renom.automaticInterval != nil {
interval = *renom.automaticInterval
}
opts = append(opts, ice.WithAutomaticRenomination(interval))
}
return opts
}
func legacyNAT1To1AddressRewriteRules(ips []string, candidateType ice.CandidateType) []ice.AddressRewriteRule { func legacyNAT1To1AddressRewriteRules(ips []string, candidateType ice.CandidateType) []ice.AddressRewriteRule {
catchAll := make([]string, 0, len(ips)) catchAll := make([]string, 0, len(ips))
rules := make([]ice.AddressRewriteRule, 0, len(ips)+1) rules := make([]ice.AddressRewriteRule, 0, len(ips)+1)

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"net" "net"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -1061,3 +1062,753 @@ func TestNewICEGathererSetMediaStreamIdentification(t *testing.T) { //nolint:cyc
assert.NoError(t, gatherer.Close()) assert.NoError(t, gatherer.Close())
} }
func TestICEGatherer_RenominationOptions(t *testing.T) {
se := SettingEngine{}
assert.NoError(t, se.SetICERenomination())
assert.True(t, se.renomination.enabled)
assert.True(t, se.renomination.automatic)
assert.Nil(t, se.renomination.automaticInterval)
assert.NotNil(t, se.renomination.generator)
}
func TestICEGatherer_RenominationOptionsDisabled(t *testing.T) {
lim := test.TimeOut(time.Second * 10)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, cleanup := buildRenominationVNetPair(t, false, false, nil)
defer cleanup()
connectAndWaitForICE(t, offerPC, answerPC)
agent := getAgent(t, offerPC)
selectedPair, err := agent.GetSelectedCandidatePair()
assert.NoError(t, err)
assert.NotNil(t, selectedPair)
err = agent.RenominateCandidate(selectedPair.Local, selectedPair.Remote)
assert.Error(t, err)
assert.ErrorIs(t, err, ice.ErrRenominationNotEnabled)
}
func TestICEGatherer_RenominationSendsNomination(t *testing.T) { //nolint:cyclop
lim := test.TimeOut(time.Second * 35)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
nominationCh := make(chan uint32, 2)
handler := func(m *stun.Message, _, _ ice.Candidate, _ *ice.CandidatePair) bool {
var attr ice.NominationAttribute
if err := attr.GetFrom(m); err == nil {
select {
case nominationCh <- attr.Value:
default:
}
}
return false
}
offerPC, answerPC, offerSender, answerSender, cleanup := buildStagedRenominationPair(t, handler)
defer cleanup()
recvCh := make(chan string, 4)
negotiated := true
id := uint16(0)
offerDC, err := offerPC.CreateDataChannel("renomination-dc", &DataChannelInit{
Negotiated: &negotiated,
ID: &id,
})
assert.NoError(t, err)
answerDC, err := answerPC.CreateDataChannel("renomination-dc", &DataChannelInit{
Negotiated: &negotiated,
ID: &id,
})
assert.NoError(t, err)
answerDC.OnMessage(func(msg DataChannelMessage) {
select {
case recvCh <- string(msg.Data):
default:
}
})
connected := make(chan struct{})
var once sync.Once
offerPC.OnICEConnectionStateChange(func(state ICEConnectionState) {
if state == ICEConnectionStateConnected {
once.Do(func() {
close(connected)
})
}
})
startTrickleRenomination(t, offerPC, answerPC, offerSender, answerSender)
assert.NoError(t, offerSender.errValue())
assert.NoError(t, answerSender.errValue())
select {
case <-connected:
case <-time.After(15 * time.Second):
assert.Fail(t, "timed out waiting for ICE to connect")
}
pair := selectedCandidatePair(t, offerPC)
assert.NotNil(t, pair)
if pair.Remote.Type() != ice.CandidateTypeServerReflexive {
t.Logf("initial remote candidate type %s (expected srflx), continuing", pair.Remote.Type())
}
initialStat, initialStatOK := getAgent(t, offerPC).GetSelectedCandidatePairStats()
assert.True(t, initialStatOK)
assert.NoError(t, offerSender.flushHost())
assert.NoError(t, answerSender.flushHost())
waitDataChannelOpen(t, offerDC)
waitDataChannelOpen(t, answerDC)
sendAndExpect(t, offerDC, recvCh, "before-renom")
waitForTwoRemoteCandidates(t, offerPC)
waitForTwoRemoteCandidates(t, answerPC)
var switchLocal ice.Candidate
var switchRemote ice.Candidate
agent := getAgent(t, offerPC)
assert.Eventuallyf(t, func() bool {
switchLocal, switchRemote = findSwitchTarget(t, offerPC, initialStat.RemoteCandidateID)
return switchLocal != nil && switchRemote != nil
}, 10*time.Second, 50*time.Millisecond, "no alternate succeeded pair found; pairs: %s", candidatePairSummary(t, agent))
assert.NoError(t, agent.RenominateCandidate(switchLocal, switchRemote))
sendAndExpect(t, offerDC, recvCh, "after-renom")
select {
case v := <-nominationCh:
assert.Greater(t, v, uint32(0))
case <-time.After(20 * time.Second):
assert.Fail(t, "did not observe nomination attribute on binding request")
}
}
func TestICEGatherer_RenominationSwitchesPair(t *testing.T) { //nolint:cyclop
lim := test.TimeOut(time.Second * 45)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, offerSender, answerSender, cleanup := buildStagedRenominationPair(t, nil)
defer cleanup()
recvCh := make(chan string, 4)
negotiated := true
id := uint16(0)
offerDC, err := offerPC.CreateDataChannel("renomination-dc", &DataChannelInit{
Negotiated: &negotiated,
ID: &id,
})
assert.NoError(t, err)
answerDC, err := answerPC.CreateDataChannel("renomination-dc", &DataChannelInit{
Negotiated: &negotiated,
ID: &id,
})
assert.NoError(t, err)
answerDC.OnMessage(func(msg DataChannelMessage) {
select {
case recvCh <- string(msg.Data):
default:
}
})
connected := make(chan struct{})
offerPC.OnICEConnectionStateChange(func(state ICEConnectionState) {
if state == ICEConnectionStateConnected {
select {
case <-connected:
default:
close(connected)
}
}
})
var flushHostOnce sync.Once
flushHosts := func() {
flushHostOnce.Do(func() {
assert.NoError(t, offerSender.flushHost())
assert.NoError(t, answerSender.flushHost())
})
}
startTrickleRenomination(t, offerPC, answerPC, offerSender, answerSender)
assert.NoError(t, offerSender.errValue())
assert.NoError(t, answerSender.errValue())
// Fallback: release host candidates even if the initial selection check stalls.
go func() {
time.Sleep(time.Second)
flushHosts()
}()
select {
case <-connected:
case <-time.After(15 * time.Second):
agent := getAgent(t, offerPC)
assert.Fail(t, "timed out waiting for initial connection; pairs: %s", candidatePairSummary(t, agent))
}
var initialRemoteType ice.CandidateType
if !assert.Eventuallyf(
t, func() bool {
if pair := selectedCandidatePair(t, offerPC); pair == nil {
return false
} else {
initialRemoteType = pair.Remote.Type()
return initialRemoteType == ice.CandidateTypeServerReflexive ||
initialRemoteType == ice.CandidateTypePeerReflexive
}
},
12*time.Second, 30*time.Millisecond,
"expected to start on a srflx/prflx remote candidate (got %s)", initialRemoteType,
) {
flushHosts()
assert.Fail(t, "expected to start on a srflx/prflx remote candidate")
}
flushHosts()
waitDataChannelOpen(t, offerDC)
waitDataChannelOpen(t, answerDC)
sendAndExpect(t, offerDC, recvCh, "before-switch")
initialPair := selectedCandidatePair(t, offerPC)
initialStat, initialStatOK := getAgent(t, offerPC).GetSelectedCandidatePairStats()
t.Logf("initial selected pair: %s<->%s (%s/%s)",
initialPair.Local.Address(), initialPair.Remote.Address(), initialPair.Local.Type(), initialPair.Remote.Type())
waitForTwoRemoteCandidates(t, offerPC)
waitForTwoRemoteCandidates(t, answerPC)
assert.True(t, initialStatOK, "missing initial selected pair stats")
switchLocal, switchRemote := findSwitchTarget(t, offerPC, initialStat.RemoteCandidateID)
assert.NotNil(t, switchLocal)
assert.NotNil(t, switchRemote)
assert.NotNil(t, switchLocal.Type())
assert.NotNil(t, switchRemote.Type())
assert.False(t, switchLocal.Equal(switchRemote), "switch local and remote candidates should be different")
t.Logf(
"renomination target: %s/%s -> %s/%s",
switchLocal.Address(), switchLocal.Type(), switchRemote.Address(), switchRemote.Type(),
)
agent := getAgent(t, offerPC)
if !assert.Eventually(t, func() bool {
pair := selectedCandidatePair(t, offerPC)
if pair != nil && pair.Local.Equal(switchLocal) && pair.Remote.Equal(switchRemote) {
return true
}
if err := agent.RenominateCandidate(switchLocal, switchRemote); err != nil {
t.Logf("renomination attempt: %v", err)
}
return false
}, 10*time.Second, 50*time.Millisecond, "selected pair should change after renomination") {
assert.Fail(t, "selected pair did not switch; pairs: %s", candidatePairSummary(t, agent))
}
finalStat, ok := agent.GetSelectedCandidatePairStats()
assert.True(t, ok)
assert.NotEqual(
t, initialStat.RemoteCandidateID, finalStat.RemoteCandidateID, "selected pair should change after renomination",
)
finalLocal := findCandidateByID(t, agent, finalStat.LocalCandidateID, true)
finalRemote := findCandidateByID(t, agent, finalStat.RemoteCandidateID, false)
assert.NotNil(t, finalLocal)
assert.NotNil(t, finalRemote)
assert.Equal(t, ice.CandidateTypeHost, finalLocal.Type())
assert.NotEqual(t, ice.CandidateTypeServerReflexive, finalRemote.Type())
finalPair := selectedCandidatePair(t, offerPC)
assert.NotNil(t, finalPair)
sendAndExpect(t, offerDC, recvCh, "after-switch")
assert.False(t, initialPair.Remote.Equal(finalPair.Remote), "expected remote candidate to change after renomination")
}
func buildRenominationVNetPair(
t *testing.T,
enableRenomination bool,
automatic bool,
bindingHandler func(*stun.Message, ice.Candidate, ice.Candidate, *ice.CandidatePair) bool,
) (*PeerConnection, *PeerConnection, func()) {
t.Helper()
router, err := vnet.NewRouter(&vnet.RouterConfig{
CIDR: "1.2.3.0/24",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
assert.NoError(t, err)
netStack, err := vnet.NewNet(&vnet.NetConfig{
StaticIPs: []string{"1.2.3.4"},
})
assert.NoError(t, err)
assert.NoError(t, router.AddNet(netStack))
answerNet, err := vnet.NewNet(&vnet.NetConfig{
StaticIPs: []string{"1.2.3.5"},
})
assert.NoError(t, err)
assert.NoError(t, router.AddNet(answerNet))
assert.NoError(t, router.Start())
offerSE := SettingEngine{}
offerSE.SetNet(netStack)
offerSE.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
offerSE.SetNetworkTypes([]NetworkType{NetworkTypeUDP4})
if enableRenomination {
assert.NoError(t, offerSE.SetICERenomination())
if automatic {
assert.NoError(t, offerSE.SetICERenomination())
}
}
answerSE := SettingEngine{}
answerSE.SetNet(answerNet)
answerSE.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
answerSE.SetNetworkTypes([]NetworkType{NetworkTypeUDP4})
if enableRenomination {
assert.NoError(t, answerSE.SetICERenomination())
if automatic {
assert.NoError(t, answerSE.SetICERenomination())
}
}
if bindingHandler != nil {
answerSE.SetICEBindingRequestHandler(bindingHandler)
}
offerPC, err := NewAPI(WithSettingEngine(offerSE)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
answerPC, err := NewAPI(WithSettingEngine(answerSE)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
cleanup := func() {
closePairNow(t, offerPC, answerPC)
assert.NoError(t, router.Stop())
}
return offerPC, answerPC, cleanup
}
func connectAndWaitForICE(t *testing.T, offerPC, answerPC *PeerConnection) {
t.Helper()
connected := make(chan struct{})
var once sync.Once
offerPC.OnICEConnectionStateChange(func(state ICEConnectionState) {
if state == ICEConnectionStateConnected {
once.Do(func() {
close(connected)
})
}
})
assert.NoError(t, signalPair(offerPC, answerPC))
select {
case <-connected:
case <-time.After(5 * time.Second):
assert.Fail(t, "timed out waiting for ICE to connect")
}
}
func selectedCandidatePair(t *testing.T, pc *PeerConnection) *ice.CandidatePair {
t.Helper()
agent := getAgent(t, pc)
pair, err := agent.GetSelectedCandidatePair()
assert.NoError(t, err)
return pair
}
func waitForTwoRemoteCandidates(t *testing.T, pc *PeerConnection) {
t.Helper()
assert.Eventually(t, func() bool {
agent := getAgent(t, pc)
remotes, err := agent.GetRemoteCandidates()
assert.NoError(t, err)
return len(remotes) >= 2
}, 5*time.Second, 20*time.Millisecond)
}
func findCandidateByID(t *testing.T, agent *ice.Agent, id string, local bool) ice.Candidate {
t.Helper()
var cands []ice.Candidate
var err error
if local {
cands, err = agent.GetLocalCandidates()
} else {
cands, err = agent.GetRemoteCandidates()
}
assert.NoError(t, err)
for _, cand := range cands {
if cand.ID() == id {
return cand
}
}
return nil
}
//nolint:cyclop
func findSwitchTarget(
t *testing.T, pc *PeerConnection, excludeRemoteID string,
) (ice.Candidate, ice.Candidate) {
t.Helper()
agent := getAgent(t, pc)
var targetLocal ice.Candidate
var targetRemote ice.Candidate
for _, stat := range agent.GetCandidatePairsStats() {
if stat.State != ice.CandidatePairStateSucceeded ||
stat.LocalCandidateID == "" || stat.RemoteCandidateID == "" ||
stat.RemoteCandidateID == excludeRemoteID {
continue
}
local := findCandidateByID(t, agent, stat.LocalCandidateID, true)
remote := findCandidateByID(t, agent, stat.RemoteCandidateID, false)
if local == nil || remote == nil {
continue
}
if local.Type() != ice.CandidateTypeHost {
continue
}
if remote.Type() == ice.CandidateTypeHost {
return local, remote
}
if remote.Type() == ice.CandidateTypePeerReflexive {
targetLocal = local
targetRemote = remote
}
}
return targetLocal, targetRemote
}
func getAgent(t *testing.T, pc *PeerConnection) *ice.Agent {
t.Helper()
pc.iceTransport.lock.RLock()
agent := pc.iceTransport.gatherer.getAgent()
pc.iceTransport.lock.RUnlock()
assert.NotNil(t, agent)
return agent
}
func candidatePairSummary(t *testing.T, agent *ice.Agent) string {
t.Helper()
locals, err := agent.GetLocalCandidates()
assert.NoError(t, err)
remotes, err := agent.GetRemoteCandidates()
assert.NoError(t, err)
localMap := map[string]string{}
for _, cand := range locals {
localMap[cand.ID()] = fmt.Sprintf("%s/%s", cand.Address(), cand.Type())
}
remoteMap := map[string]string{}
for _, cand := range remotes {
remoteMap[cand.ID()] = fmt.Sprintf("%s/%s", cand.Address(), cand.Type())
}
stats := agent.GetCandidatePairsStats()
summary := make([]string, 0, len(stats))
for _, stat := range stats {
summary = append(summary, fmt.Sprintf(
"%s<->%s state=%s nominated=%v rtt=%.2fms",
localMap[stat.LocalCandidateID],
remoteMap[stat.RemoteCandidateID],
stat.State,
stat.Nominated,
stat.CurrentRoundTripTime*1000,
))
}
return strings.Join(summary, "; ")
}
func waitDataChannelOpen(t *testing.T, dc *DataChannel) {
t.Helper()
if dc.ReadyState() == DataChannelStateOpen {
return
}
done := make(chan struct{})
dc.OnOpen(func() {
close(done)
})
select {
case <-done:
case <-time.After(5 * time.Second):
assert.Fail(t, "data channel did not open")
}
}
func sendAndExpect(t *testing.T, sender *DataChannel, recvCh chan string, msg string) {
t.Helper()
err := sender.SendText(msg)
assert.NoError(t, err)
select {
case got := <-recvCh:
assert.Equal(t, msg, got)
case <-time.After(5 * time.Second):
assert.Fail(t, "did not receive data channel message")
}
}
type stagedCandidateSender struct {
remote *PeerConnection
mu sync.Mutex
srflx []ICECandidateInit
host []ICECandidateInit
err error
}
func (s *stagedCandidateSender) addCandidate(cand ICECandidateInit, srflx bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return
}
if srflx && s.remote.RemoteDescription() != nil {
if err := s.remote.AddICECandidate(cand); err != nil {
s.err = err
}
return
}
if srflx {
s.srflx = append(s.srflx, cand)
} else {
s.host = append(s.host, cand)
}
}
func (s *stagedCandidateSender) flushSrflx() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return s.err
}
for _, cand := range s.srflx {
if err := s.remote.AddICECandidate(cand); err != nil {
s.err = err
return err
}
}
s.srflx = nil
return s.err
}
func (s *stagedCandidateSender) flushHost() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return s.err
}
for _, cand := range s.host {
if err := s.remote.AddICECandidate(cand); err != nil {
s.err = err
return err
}
}
s.host = nil
return s.err
}
func (s *stagedCandidateSender) errValue() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
func makeSrflxCandidateInit(c ICECandidate) ICECandidateInit {
init := c.ToJSON()
replacement := fmt.Sprintf("typ srflx raddr %s rport %d", c.Address, c.Port)
init.Candidate = strings.Replace(init.Candidate, "typ host", replacement, 1)
return init
}
func buildStagedRenominationPair(
t *testing.T,
bindingHandler func(*stun.Message, ice.Candidate, ice.Candidate, *ice.CandidatePair) bool,
) (*PeerConnection, *PeerConnection, *stagedCandidateSender, *stagedCandidateSender, func()) {
t.Helper()
const (
primaryOfferIP = "10.0.0.2"
secondaryOfferIP = "10.0.0.4"
primaryAnswerIP = "10.0.0.3"
secondaryAnswerIP = "10.0.0.5"
)
router, err := vnet.NewRouter(&vnet.RouterConfig{
CIDR: "10.0.0.0/24",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
assert.NoError(t, err)
offerNet, err := vnet.NewNet(&vnet.NetConfig{
StaticIPs: []string{primaryOfferIP, secondaryOfferIP},
})
assert.NoError(t, err)
assert.NoError(t, router.AddNet(offerNet))
answerNet, err := vnet.NewNet(&vnet.NetConfig{
StaticIPs: []string{primaryAnswerIP, secondaryAnswerIP},
})
assert.NoError(t, err)
assert.NoError(t, router.AddNet(answerNet))
assert.NoError(t, router.Start())
offerSE := SettingEngine{}
offerSE.SetNet(offerNet)
offerSE.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
offerSE.SetNetworkTypes([]NetworkType{NetworkTypeUDP4})
offerSE.SetICETimeouts(5*time.Second, 15*time.Second, 200*time.Millisecond)
// prefer srflx/prflx nomination first so the test reliably observes the switch to host via renomination.
offerSE.SetSrflxAcceptanceMinWait(0)
offerSE.SetHostAcceptanceMinWait(3 * time.Second)
assert.NoError(t, offerSE.SetICERenomination(WithRenominationInterval(200*time.Millisecond)))
answerSE := SettingEngine{}
answerSE.SetNet(answerNet)
answerSE.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
answerSE.SetNetworkTypes([]NetworkType{NetworkTypeUDP4})
answerSE.SetICETimeouts(5*time.Second, 15*time.Second, 200*time.Millisecond)
answerSE.SetSrflxAcceptanceMinWait(0)
answerSE.SetHostAcceptanceMinWait(3 * time.Second)
assert.NoError(t, answerSE.SetICERenomination(WithRenominationInterval(200*time.Millisecond)))
if bindingHandler != nil {
answerSE.SetICEBindingRequestHandler(bindingHandler)
}
offerPC, err := NewAPI(WithSettingEngine(offerSE)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
answerPC, err := NewAPI(WithSettingEngine(answerSE)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
offerSender := &stagedCandidateSender{remote: answerPC}
answerSender := &stagedCandidateSender{remote: offerPC}
offerPC.OnICECandidate(func(c *ICECandidate) {
if c == nil {
return
}
switch c.Address {
case primaryOfferIP:
offerSender.addCandidate(makeSrflxCandidateInit(*c), true)
host := *c
host.Priority = 1
offerSender.addCandidate(host.ToJSON(), false)
case secondaryOfferIP:
host := *c
host.Priority = 1
offerSender.addCandidate(host.ToJSON(), false)
}
})
answerPC.OnICECandidate(func(c *ICECandidate) {
if c == nil {
return
}
switch c.Address {
case primaryAnswerIP:
answerSender.addCandidate(makeSrflxCandidateInit(*c), true)
host := *c
host.Priority = 1
answerSender.addCandidate(host.ToJSON(), false)
case secondaryAnswerIP:
host := *c
host.Priority = 1
answerSender.addCandidate(host.ToJSON(), false)
}
})
cleanup := func() {
closePairNow(t, offerPC, answerPC)
assert.NoError(t, router.Stop())
}
return offerPC, answerPC, offerSender, answerSender, cleanup
}
func startTrickleRenomination(
t *testing.T,
offerPC, answerPC *PeerConnection,
offerSender, answerSender *stagedCandidateSender,
) {
t.Helper()
_, err := offerPC.CreateDataChannel("renomination-data", nil)
assert.NoError(t, err)
offer, err := offerPC.CreateOffer(nil)
assert.NoError(t, err)
assert.NoError(t, offerPC.SetLocalDescription(offer))
assert.NoError(t, answerPC.SetRemoteDescription(offer))
answer, err := answerPC.CreateAnswer(nil)
assert.NoError(t, err)
assert.NoError(t, answerPC.SetLocalDescription(answer))
assert.NoError(t, offerPC.SetRemoteDescription(*answerPC.LocalDescription()))
assert.NoError(t, offerSender.flushSrflx())
assert.NoError(t, answerSender.flushSrflx())
}

View File

@@ -9,6 +9,7 @@ package webrtc
import ( import (
"context" "context"
"crypto/x509" "crypto/x509"
"errors"
"io" "io"
"net" "net"
"time" "time"
@@ -45,7 +46,8 @@ type SettingEngine struct {
ICERelayAcceptanceMinWait *time.Duration ICERelayAcceptanceMinWait *time.Duration
ICESTUNGatherTimeout *time.Duration ICESTUNGatherTimeout *time.Duration
} }
candidates struct { renomination renominationSettings
candidates struct {
ICELite bool ICELite bool
ICENetworkTypes []NetworkType ICENetworkTypes []NetworkType
InterfaceFilter func(string) (keep bool) InterfaceFilter func(string) (keep bool)
@@ -114,6 +116,68 @@ type SettingEngine struct {
ignoreRidPauseForRecv bool ignoreRidPauseForRecv bool
} }
type renominationSettings struct {
enabled bool
generator ice.NominationValueGenerator
automatic bool
automaticInterval *time.Duration
}
// NominationValueGenerator generates nomination values for ICE renomination.
type NominationValueGenerator func() uint32
func (f NominationValueGenerator) toIce() ice.NominationValueGenerator {
return ice.NominationValueGenerator(f)
}
// RenominationOption allows configuring ICE renomination behavior.
type RenominationOption func(*renominationSettings)
// WithRenominationGenerator overrides the default nomination value generator.
func WithRenominationGenerator(generator NominationValueGenerator) RenominationOption {
return func(cfg *renominationSettings) {
cfg.generator = generator.toIce()
}
}
// WithRenominationInterval sets the interval for automatic renomination checks.
// Passing zero or a negative duration returns an error from SetICERenomination.
func WithRenominationInterval(interval time.Duration) RenominationOption {
return func(cfg *renominationSettings) {
i := interval
cfg.automaticInterval = &i
}
}
var errInvalidRenominationInterval = errors.New("renomination interval must be greater than zero")
// SetICERenomination configures ICE renomination using options for generator and scheduling.
// Manual control is not exposed yet. This always enables automatic renomination with the default
// generator unless a custom one is provided.
func (e *SettingEngine) SetICERenomination(options ...RenominationOption) error {
cfg := e.renomination
for _, opt := range options {
if opt != nil {
opt(&cfg)
}
}
if cfg.automaticInterval != nil && *cfg.automaticInterval <= 0 {
return errInvalidRenominationInterval
}
if cfg.generator == nil {
cfg.generator = ice.DefaultNominationValueGenerator()
}
e.renomination.enabled = true
e.renomination.generator = cfg.generator
e.renomination.automatic = true
e.renomination.automaticInterval = cfg.automaticInterval
return nil
}
func (e *SettingEngine) getSCTPMaxMessageSize() uint32 { func (e *SettingEngine) getSCTPMaxMessageSize() uint32 {
if e.sctp.maxMessageSize != 0 { if e.sctp.maxMessageSize != 0 {
return e.sctp.maxMessageSize return e.sctp.maxMessageSize

View File

@@ -55,6 +55,57 @@ func TestSetConnectionTimeout(t *testing.T) {
assert.Equal(t, *s.timeout.ICEKeepaliveInterval, 3*time.Second) assert.Equal(t, *s.timeout.ICEKeepaliveInterval, 3*time.Second)
} }
func TestICERenomination(t *testing.T) {
t.Run("EnableWithDefaultGenerator", func(t *testing.T) {
s := SettingEngine{}
assert.NoError(t, s.SetICERenomination())
assert.True(t, s.renomination.enabled)
assert.NotNil(t, s.renomination.generator)
assert.Equal(t, uint32(1), s.renomination.generator())
assert.Equal(t, uint32(2), s.renomination.generator())
})
t.Run("AutomaticRenominationUsesExistingGenerator", func(t *testing.T) {
var calls uint32
settings := SettingEngine{}
customGen := func() uint32 {
calls++
return 100 + calls
}
interval := 2 * time.Second
assert.NoError(t, settings.SetICERenomination(
WithRenominationGenerator(customGen),
WithRenominationInterval(interval),
))
assert.True(t, settings.renomination.enabled)
assert.True(t, settings.renomination.automatic)
if assert.NotNil(t, settings.renomination.automaticInterval) {
assert.Equal(t, interval, *settings.renomination.automaticInterval)
}
assert.Equal(t, uint32(101), settings.renomination.generator())
})
t.Run("AutomaticRenominationEnablesGenerator", func(t *testing.T) {
s := SettingEngine{}
assert.NoError(t, s.SetICERenomination())
assert.True(t, s.renomination.enabled)
assert.True(t, s.renomination.automatic)
assert.Nil(t, s.renomination.automaticInterval)
assert.NotNil(t, s.renomination.generator)
})
t.Run("InvalidInterval", func(t *testing.T) {
s := SettingEngine{}
assert.ErrorIs(t, s.SetICERenomination(WithRenominationInterval(0)), errInvalidRenominationInterval)
assert.ErrorIs(t, s.SetICERenomination(WithRenominationInterval(-1*time.Second)), errInvalidRenominationInterval)
})
}
func TestDetachDataChannels(t *testing.T) { func TestDetachDataChannels(t *testing.T) {
s := SettingEngine{} s := SettingEngine{}
assert.False(t, s.detach.DataChannels) assert.False(t, s.detach.DataChannels)