telegabber/calls/signaling/xmppsig/track_buffer_test.go
2026-05-25 06:27:20 -07:00

175 lines
5.1 KiB
Go

package xmppsig
import (
"sync/atomic"
"testing"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
)
// pion OnTrack fires inside SetRemoteDescription, but setupAudio runs
// later (from OnRemoteDescriptionApplied) - so hookTrack must install
// a permanent forwarder at PC construction and buffer until
// SetTrackHandler. This drives a real pion<->pion roundtrip with the
// handler installed after the track has already landed.
func TestHookTrack_BuffersTrackThatArrivesBeforeSetHandler(t *testing.T) {
if testing.Short() {
t.Skip("e2e ICE handshake")
}
// gateway-side PC, same shape as xmppsig.NewCaller
pcGateway, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
t.Fatalf("gateway PC: %v", err)
}
defer pcGateway.Close()
if _, err := pcGateway.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendrecv,
}); err != nil {
t.Fatalf("gateway AddTransceiver: %v", err)
}
// the forwarder under test
var base xmppBase
base.hookTrack(pcGateway)
// remote pion PC with an audio track to send
pcPeer, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
t.Fatalf("peer PC: %v", err)
}
defer pcPeer.Close()
peerTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2},
"audio", "peer-stream",
)
if err != nil {
t.Fatalf("peer track: %v", err)
}
if _, err := pcPeer.AddTrack(peerTrack); err != nil {
t.Fatalf("peer AddTrack: %v", err)
}
// pion offer/answer + ICE-complete handshake
gatewayConnected := make(chan struct{})
pcGateway.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateConnected {
select {
case <-gatewayConnected:
default:
close(gatewayConnected)
}
}
})
completePionHandshake(t, pcGateway, pcPeer)
select {
case <-gatewayConnected:
case <-time.After(15 * time.Second):
t.Fatalf("gateway PC never reached Connected (state=%s)", pcGateway.ConnectionState())
}
// real RTP so OnTrack fires (pion needs the first packet to dispatch)
stopRTP := make(chan struct{})
go pumpSilenceOpus(peerTrack, stopRTP)
defer close(stopRTP)
// wait for pion to dispatch the first packet through hookTrack
waitUntilTrue(t, "track buffered by hookTrack", 5*time.Second, func() bool {
base.mu.Lock()
defer base.mu.Unlock()
return len(base.pendingTracks) > 0
})
// install handler late, like setupAudio from OnRemoteDescriptionApplied
var delivered atomic.Int64
base.SetTrackHandler(func(t *webrtc.TrackRemote) {
delivered.Add(1)
})
if delivered.Load() != 1 {
t.Errorf("SetTrackHandler did not drain pendingTracks: delivered=%d, want 1", delivered.Load())
}
// subsequent track events should go direct to the handler
base.mu.Lock()
if len(base.pendingTracks) != 0 {
t.Errorf("pendingTracks not cleared after drain: %d entries", len(base.pendingTracks))
}
base.mu.Unlock()
}
// completePionHandshake drives a pion offer/answer + trickle ICE between
// two PeerConnections, with the gateway as the answerer (matching the
// xmpp-originated incoming call shape - Dino's session-initiate carries
// the offer, the gateway sends the answer).
func completePionHandshake(t *testing.T, pcAnswerer, pcOfferer *webrtc.PeerConnection) {
t.Helper()
// trickle ICE: forward each side's candidates to the other
pcOfferer.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
if err := pcAnswerer.AddICECandidate(c.ToJSON()); err != nil {
t.Logf("answerer AddICECandidate: %v", err)
}
})
pcAnswerer.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
if err := pcOfferer.AddICECandidate(c.ToJSON()); err != nil {
t.Logf("offerer AddICECandidate: %v", err)
}
})
offer, err := pcOfferer.CreateOffer(nil)
if err != nil {
t.Fatal(err)
}
if err := pcOfferer.SetLocalDescription(offer); err != nil {
t.Fatal(err)
}
if err := pcAnswerer.SetRemoteDescription(*pcOfferer.LocalDescription()); err != nil {
t.Fatal(err)
}
answer, err := pcAnswerer.CreateAnswer(nil)
if err != nil {
t.Fatal(err)
}
if err := pcAnswerer.SetLocalDescription(answer); err != nil {
t.Fatal(err)
}
if err := pcOfferer.SetRemoteDescription(*pcAnswerer.LocalDescription()); err != nil {
t.Fatal(err)
}
}
// writes 20ms opus silence every 20ms until stop closes
func pumpSilenceOpus(track *webrtc.TrackLocalStaticSample, stop <-chan struct{}) {
// minimal opus silence frame: TOC byte + 1-byte SILK
silence := []byte{0xF8, 0xFF, 0xFE}
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-stop:
return
case <-tick.C:
_ = track.WriteSample(media.Sample{Data: silence, Duration: 20 * time.Millisecond})
}
}
}
// local poller; duplicates the jingle one to avoid test-file ordering deps
func waitUntilTrue(t *testing.T, what string, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("waitUntilTrue(%s): timed out after %v", what, d)
}