Files
livekit/pkg/sfu/datachannel/bitrate_test.go
Raja SubramanianandClaude Opus 4.8 cce14a6fec fix: don't undercount data channel bitrate over idle gaps (#4676)
* fix: don't undercount data channel bitrate over idle gaps

BitrateCalculator divided drained bytes by wall-clock time, including
idle gaps between sparse writes. A fast channel fed sparsely (e.g. 1 KB
every second, acked in a few ms) therefore reported its low offered load
(~8 kbps) instead of its drain capacity (~800 kbps). In the unreliable
writer that shrinks targetLatencyLimit and drops bursts on a channel
that is actually keeping up.

Measure the rate over backlogged ("busy") time only, so idle time no
longer dilutes the estimate. Backlog is detected from the buffer
occupancy just before a write (bufferedAmount - bytesWritten): since no
bytes are added between writes the buffer only drains, so a non-zero
pre-write level means it never emptied and the interval was genuinely
busy. Gating on the post-write buffered amount would be wrong -- it
always includes the just-enqueued (not yet SACKed) bytes and is ~never
zero, collapsing the estimate back to wall-clock. When no backlog is
ever observed the calculator reports no estimate (ok=false) instead of a
confidently-low number.

Introduce BitrateMode:
- BusyOnly (writer default): busy denominator, all drained bytes counted.
- ExcludeIdleDrain: also drops bytes drained across non-backlogged
  intervals for a clean drain-capacity estimate free of idle
  contamination.
- WallClock: elapsed-time denominator, all bytes; for the test-client
  reader which has no send buffer to observe.

Call sites keep the conservative behaviour (writers BusyOnly, reader
WallClock). Tests exercise both writer modes and the mixed idle-then-
backlog window where they diverge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Trim comments in data channel bitrate calculator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make BitrateMode.String a method on the enum

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 00:29:23 +05:30

131 lines
3.8 KiB
Go

package datachannel
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
var busyModes = []BitrateMode{BitrateModeBusyOnly, BitrateModeExcludeIdleDrain}
// 1 KB written every second, each fully drained before the next write: never
// backlogged, so there is no estimate.
func TestBitrateCalculatorSparseFastAck(t *testing.T) {
for _, mode := range busyModes {
t.Run(mode.String(), func(t *testing.T) {
c := NewBitrateCalculator(BitrateDuration, BitrateWindow, mode)
require.NotNil(t, c)
t0 := time.Now()
for i := 0; i < 5; i++ {
ts := t0.Add(time.Duration(i) * time.Second)
c.AddBytes(1000, 1000, ts)
_, ok := c.Bitrate(ts)
require.False(t, ok)
}
})
}
}
// Standing backlog of 1000 bytes; every 100ms write 800 and 800 drain, sampled
// buffer 1000+800=1800. 800 B / 100ms = 64000 bps.
func TestBitrateCalculatorSustainedBacklog(t *testing.T) {
for _, mode := range busyModes {
t.Run(mode.String(), func(t *testing.T) {
c := NewBitrateCalculator(BitrateDuration, BitrateWindow, mode)
t0 := time.Now()
c.AddBytes(800, 1800, t0)
for i := 1; i <= 30; i++ {
c.AddBytes(800, 1800, t0.Add(time.Duration(i)*100*time.Millisecond))
}
bitrate, ok := c.Bitrate(t0.Add(3 * time.Second))
require.True(t, ok)
require.InDelta(t, 64000, bitrate, 2000)
})
}
}
// Buffer only climbs and nothing drains (stalled connection): the estimate
// converges to ~0.
func TestBitrateCalculatorStalledConnection(t *testing.T) {
for _, mode := range busyModes {
t.Run(mode.String(), func(t *testing.T) {
c := NewBitrateCalculator(BitrateDuration, BitrateWindow, mode)
t0 := time.Now()
buffered := 0
for i := 0; i < 20; i++ {
buffered += 1000 // wrote 1000, nothing drained
c.AddBytes(1000, buffered, t0.Add(time.Duration(i)*50*time.Millisecond))
}
bitrate, ok := c.Bitrate(t0.Add(time.Second))
require.True(t, ok)
require.Less(t, bitrate, 1000)
})
}
}
// A single write that drains to empty is not backlogged at the next sample, so
// no estimate is produced.
func TestBitrateCalculatorFlushNotCounted(t *testing.T) {
for _, mode := range busyModes {
t.Run(mode.String(), func(t *testing.T) {
c := NewBitrateCalculator(BitrateDuration, BitrateWindow, mode)
t0 := time.Now()
c.AddBytes(100, 100, t0)
ts := t0.Add(100 * time.Millisecond)
c.AddBytes(0, 0, ts)
_, ok := c.Bitrate(ts)
require.False(t, ok)
})
}
}
// An idle drain followed by a backlog: BusyOnly counts the idle-drained bytes
// over busy time only and reports a higher rate than ExcludeIdleDrain.
func TestBitrateCalculatorMixedIdleThenBacklog(t *testing.T) {
off := NewBitrateCalculator(BitrateDuration, BitrateWindow, BitrateModeBusyOnly)
on := NewBitrateCalculator(BitrateDuration, BitrateWindow, BitrateModeExcludeIdleDrain)
t0 := time.Now()
feed := func(written, buffered int, ts time.Time) {
off.AddBytes(written, buffered, ts)
on.AddBytes(written, buffered, ts)
}
// 5000 drains during an idle gap (not backlogged), then a sustained backlog
// of 800 every 100ms over a standing 5000.
feed(5000, 5000, t0)
feed(5000, 5000, t0.Add(100*time.Millisecond))
for i := 1; i <= 5; i++ {
feed(800, 5800, t0.Add(time.Duration(100+i*100)*time.Millisecond))
}
queryTs := t0.Add(700 * time.Millisecond)
rOff, okOff := off.Bitrate(queryTs)
rOn, okOn := on.Bitrate(queryTs)
require.True(t, okOff)
require.True(t, okOn)
require.Greater(t, rOff, rOn)
}
// No observable buffer: throughput over elapsed time. 1000 B every 100ms =
// 80000 bps.
func TestBitrateCalculatorWallClock(t *testing.T) {
c := NewBitrateCalculator(BitrateDuration, BitrateWindow, BitrateModeWallClock)
t0 := time.Now()
for i := 0; i < 20; i++ {
c.AddBytes(1000, 0, t0.Add(time.Duration(i)*100*time.Millisecond))
}
bitrate, ok := c.Bitrate(t0.Add(2 * time.Second))
require.True(t, ok)
require.InDelta(t, 80000, bitrate, 8000)
}