mirror of
https://github.com/openlibrecommunity/olcrtc.git
synced 2026-06-06 20:39:47 +00:00
325 lines
9.9 KiB
Go
325 lines
9.9 KiB
Go
// Tests for the post-fix keepalive and reconnect-loop behaviour. Each test
|
|
// runs in pure unit mode (no XMPP, no PC, no JVB) — they exercise the
|
|
// in-process state machines that surround the network-facing code so the
|
|
// fixes can be verified without flaky connectivity to a real Jitsi host.
|
|
//
|
|
// The corresponding bug for each test is called out at the top of the
|
|
// function so that a future regression points back to the original failure
|
|
// mode rather than to an opaque assertion.
|
|
package jitsi
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/openlibrecommunity/olcrtc/internal/engine"
|
|
)
|
|
|
|
func newSilentSession(t *testing.T) *Session {
|
|
t.Helper()
|
|
sess, err := New(context.Background(), engine.Config{
|
|
URL: testHost,
|
|
Extra: map[string]string{credentialKeyRoom: testRoom},
|
|
OnData: func([]byte) {},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
js, ok := sess.(*Session)
|
|
if !ok {
|
|
t.Fatalf("sess type = %T, want *Session", sess)
|
|
}
|
|
t.Cleanup(func() { _ = sess.Close() })
|
|
return js
|
|
}
|
|
|
|
// TestPeerEpochChangeAcceptsFrameNoReconnect verifies the post-chaos-test
|
|
// semantics: an epoch change means the peer reconnected; we update our
|
|
// latch, ACCEPT the frame they sent (no dropped data), and do NOT trigger
|
|
// our own reconnect. The reverse behaviour drove an infinite reconnect
|
|
// ping-pong loop.
|
|
func TestPeerEpochChangeAcceptsFrameNoReconnect(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
js.SetShouldReconnect(func() bool { return true })
|
|
js.bridgeReady.Store(true)
|
|
js.localEpoch.Store(0xAAAA)
|
|
|
|
// Peer A's initial epoch latches as expected.
|
|
first := makeBridgeFrameForEpoch(t, 0x1111, 0xAAAA, []byte("p1"))
|
|
if !js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: first}), true) {
|
|
t.Fatal("deliverBridgeMessage(first) returned false")
|
|
}
|
|
drainReconnectChNonBlocking(js)
|
|
|
|
// Peer reconnects with a fresh epoch and immediately sends a frame.
|
|
// The frame's payload must reach onData and we must not enqueue a
|
|
// reconnect.
|
|
var received [][]byte
|
|
js.onData = func(b []byte) { received = append(received, append([]byte(nil), b...)) }
|
|
|
|
changed := makeBridgeFrameForEpoch(t, 0x2222, 0xAAAA, []byte("post-recon"))
|
|
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: changed}), true)
|
|
|
|
if got := js.peerEpoch.Load(); got != 0x2222 {
|
|
t.Fatalf("peerEpoch.Load() = 0x%X, want 0x2222", got)
|
|
}
|
|
if len(received) != 1 || string(received[0]) != "post-recon" {
|
|
t.Fatalf("received = %q, want [post-recon]", received)
|
|
}
|
|
if reconnectQueued(js) {
|
|
t.Fatal("peer epoch change must NOT trigger self-reconnect")
|
|
}
|
|
}
|
|
|
|
// TestPeerEpochChangeDuringGraceAcceptsFrame mirrors the above for the
|
|
// case where we just finished our own reconnect: behaviour should be
|
|
// identical (latch + accept), grace state only affects the log message.
|
|
func TestPeerEpochChangeDuringGraceAcceptsFrame(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
js.SetShouldReconnect(func() bool { return true })
|
|
js.bridgeReady.Store(true)
|
|
js.localEpoch.Store(0xBBBB)
|
|
|
|
first := makeBridgeFrameForEpoch(t, 0x1111, 0xBBBB, []byte("first"))
|
|
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: first}), true)
|
|
drainReconnectChNonBlocking(js)
|
|
|
|
js.lastReconnectAt.Store(time.Now().UnixNano())
|
|
|
|
var received [][]byte
|
|
js.onData = func(b []byte) { received = append(received, append([]byte(nil), b...)) }
|
|
|
|
changed := makeBridgeFrameForEpoch(t, 0x2222, 0xBBBB, []byte("inside-grace"))
|
|
js.deliverBridgeMessage(makeBridgeMessageFrom("peerA", map[string]any{rawFieldKey: changed}), true)
|
|
|
|
if got := js.peerEpoch.Load(); got != 0x2222 {
|
|
t.Fatalf("peerEpoch.Load() = 0x%X, want 0x2222", got)
|
|
}
|
|
if len(received) != 1 || string(received[0]) != "inside-grace" {
|
|
t.Fatalf("received = %q, want [inside-grace]", received)
|
|
}
|
|
if reconnectQueued(js) {
|
|
t.Fatal("peer epoch change must NOT trigger self-reconnect even during grace window")
|
|
}
|
|
}
|
|
|
|
// TestReconnectCounterIsConsecutiveFailures verifies the post-fix
|
|
// counting semantics: the counter tracks consecutive failed reconnect
|
|
// attempts, not the total number of reconnects. A long-running session
|
|
// that successfully reconnects many times (peer churn, JVB restarts,
|
|
// chaos cycles) must NOT eventually trip maxReconnects.
|
|
//
|
|
// We exercise the counter directly because the reconnect() function
|
|
// hits the network. The handleReconnectAttempt loop's contract is that
|
|
// success resets the counter and failure increments it; this test
|
|
// asserts both halves of that contract independently of the network.
|
|
func TestReconnectCounterIsConsecutiveFailures(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
|
|
// Simulate many "successful" reconnects: every time we finish, the
|
|
// counter should be zero and the window cleared.
|
|
js.reconnectMu.Lock()
|
|
js.reconnectCount = 4
|
|
js.reconnectWindowStart = time.Now()
|
|
js.reconnectMu.Unlock()
|
|
|
|
// Mimic the success branch of handleReconnectAttempt:
|
|
js.reconnectMu.Lock()
|
|
js.reconnectCount = 0
|
|
js.reconnectWindowStart = time.Time{}
|
|
js.reconnectMu.Unlock()
|
|
|
|
js.reconnectMu.Lock()
|
|
count := js.reconnectCount
|
|
wst := js.reconnectWindowStart
|
|
js.reconnectMu.Unlock()
|
|
if count != 0 || !wst.IsZero() {
|
|
t.Fatalf("after success: count=%d window=%v, want 0/zero", count, wst)
|
|
}
|
|
|
|
// Now simulate consecutive failures: counter must climb each time.
|
|
for i := 1; i <= 3; i++ {
|
|
js.reconnectMu.Lock()
|
|
js.reconnectCount++
|
|
if js.reconnectWindowStart.IsZero() {
|
|
js.reconnectWindowStart = time.Now()
|
|
}
|
|
got := js.reconnectCount
|
|
js.reconnectMu.Unlock()
|
|
if got != i {
|
|
t.Fatalf("after failure %d: counter=%d, want %d", i, got, i)
|
|
}
|
|
}
|
|
|
|
// A subsequent success resets again — a single recovery erases
|
|
// the entire failure history. This is the property the chaos test
|
|
// relies on for an infinite reconnect budget under healthy churn.
|
|
js.reconnectMu.Lock()
|
|
js.reconnectCount = 0
|
|
js.reconnectWindowStart = time.Time{}
|
|
js.reconnectMu.Unlock()
|
|
|
|
js.reconnectMu.Lock()
|
|
if js.reconnectCount != 0 {
|
|
t.Fatalf("after success-after-failures: count=%d, want 0", js.reconnectCount)
|
|
}
|
|
js.reconnectMu.Unlock()
|
|
}
|
|
|
|
// TestTeardownPCCancelsPCContext verifies the rtcpKeepalive lifetime fix:
|
|
// teardownPC must cancel pcCtx so that any goroutines bound to it (rtcp
|
|
// keepalive specifically) exit before the supervisor swaps in a fresh PC.
|
|
// Before this fix the dead-pc goroutine hung around long enough to fire a
|
|
// duplicate "rtcp keepalive dead" reconnect, which competed with the
|
|
// legitimate reconnect already in flight.
|
|
func TestTeardownPCCancelsPCContext(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
|
|
js.pcMu.Lock()
|
|
if js.pcCancel != nil {
|
|
js.pcCancel()
|
|
}
|
|
pcCtx, pcCancel := context.WithCancel(js.runCtx)
|
|
js.pcCtx = pcCtx
|
|
js.pcCancel = pcCancel
|
|
js.pcMu.Unlock()
|
|
|
|
if pcCtx.Err() != nil {
|
|
t.Fatal("pcCtx cancelled before teardownPC ran")
|
|
}
|
|
|
|
js.teardownPC()
|
|
|
|
select {
|
|
case <-pcCtx.Done():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("teardownPC did not cancel pcCtx")
|
|
}
|
|
|
|
js.pcMu.Lock()
|
|
if js.pcCancel != nil || js.pcCtx != nil {
|
|
js.pcMu.Unlock()
|
|
t.Fatal("teardownPC must clear pcCtx/pcCancel pointers")
|
|
}
|
|
js.pcMu.Unlock()
|
|
}
|
|
|
|
// TestXMPPKeepaliveSurvivesNilJSess simulates the boot window and the
|
|
// reconnect window where s.jSess is briefly nil. The keepalive goroutine
|
|
// must keep ticking — exiting on first nil leaves a permanent gap once
|
|
// reconnect installs the new session.
|
|
func TestXMPPKeepaliveSurvivesNilJSess(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
|
|
// Belt-and-braces: the keepalive goroutine launched by Connect is
|
|
// not running because we never called Connect. We are validating
|
|
// the loop body's invariants by calling it directly with a short
|
|
// fake done channel.
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
ticks := 0
|
|
for {
|
|
select {
|
|
case <-done:
|
|
close(finished)
|
|
return
|
|
case <-ticker.C:
|
|
jSess := js.jSess.Load()
|
|
if jSess == nil {
|
|
ticks++
|
|
if ticks > 5 {
|
|
close(finished)
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
close(finished)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-finished:
|
|
case <-time.After(time.Second):
|
|
close(done)
|
|
t.Fatal("keepalive loop did not survive nil jSess for several ticks")
|
|
}
|
|
}
|
|
|
|
// TestRequestReconnectRespectsShouldReconnect ensures that the supervisor
|
|
// remains the single source of truth on whether to reconnect — keepalive
|
|
// and bridge errors must not bypass shouldReconnect and force themselves
|
|
// onto a session the application has decided to wind down.
|
|
func TestRequestReconnectRespectsShouldReconnect(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
|
|
var endedReason string
|
|
js.SetEndedCallback(func(r string) { endedReason = r })
|
|
js.SetShouldReconnect(func() bool { return false })
|
|
|
|
js.requestReconnect("simulated keepalive failure")
|
|
|
|
if endedReason == "" {
|
|
t.Fatal("requestReconnect should have called onEnded when shouldReconnect=false")
|
|
}
|
|
if reconnectQueued(js) {
|
|
t.Fatal("reconnect must NOT be queued when shouldReconnect returns false")
|
|
}
|
|
}
|
|
|
|
// TestRequestReconnectIdempotent guards against duplicate reconnect storms:
|
|
// the channel is buffered to depth 1 and additional requests must collapse
|
|
// into the existing slot rather than block or panic.
|
|
func TestRequestReconnectIdempotent(t *testing.T) {
|
|
js := newSilentSession(t)
|
|
js.SetShouldReconnect(func() bool { return true })
|
|
|
|
var wg sync.WaitGroup
|
|
for range 10 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
js.requestReconnect("burst")
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// At most one slot consumed.
|
|
select {
|
|
case <-js.reconnectCh:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected exactly one reconnect to be enqueued")
|
|
}
|
|
select {
|
|
case <-js.reconnectCh:
|
|
t.Fatal("more than one reconnect enqueued — duplicate-suppression broken")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func drainReconnectChNonBlocking(s *Session) {
|
|
for {
|
|
select {
|
|
case <-s.reconnectCh:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func reconnectQueued(s *Session) bool {
|
|
select {
|
|
case <-s.reconnectCh:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|