evision-safe playlist timing
This commit is contained in:
@@ -17,8 +17,9 @@ type PlaylistController struct {
|
||||
}
|
||||
|
||||
type PlaylistSnapshot struct {
|
||||
State PlaylistState
|
||||
Entry PlaylistEntry
|
||||
State PlaylistState
|
||||
Entry PlaylistEntry
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -51,7 +52,8 @@ func (c *PlaylistController) Run(
|
||||
commands <-chan PlaylistCommand,
|
||||
) error {
|
||||
state := PlaylistState{}
|
||||
c.publish(state)
|
||||
revision := uint64(0)
|
||||
c.publish(state, revision)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -78,10 +80,11 @@ func (c *PlaylistController) Run(
|
||||
return ctx.Err()
|
||||
case c.sessions <- sessionCommand:
|
||||
}
|
||||
revision++
|
||||
}
|
||||
|
||||
state = next
|
||||
c.publish(state)
|
||||
c.publish(state, revision)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,11 +95,15 @@ func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) {
|
||||
return c.snapshot, c.hasSnapshot
|
||||
}
|
||||
|
||||
func (c *PlaylistController) publish(state PlaylistState) {
|
||||
func (c *PlaylistController) publish(state PlaylistState, revision uint64) {
|
||||
entry, _ := state.Entry(c.playlist)
|
||||
|
||||
c.mu.Lock()
|
||||
c.snapshot = PlaylistSnapshot{State: state, Entry: entry}
|
||||
c.snapshot = PlaylistSnapshot{
|
||||
State: state,
|
||||
Entry: entry,
|
||||
Revision: revision,
|
||||
}
|
||||
c.hasSnapshot = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -257,6 +257,58 @@ func TestPlaylistControllerSnapshotConcurrentReads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerSnapshotRevision(t *testing.T) {
|
||||
controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 8)
|
||||
defer cancel()
|
||||
|
||||
initial := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return !snapshot.State.HasSelection
|
||||
})
|
||||
if initial.Revision != 0 {
|
||||
t.Fatalf("initial revision = %d, want 0", initial.Revision)
|
||||
}
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
|
||||
_ = receivePlaylistSession(t, sessions)
|
||||
selected := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return snapshot.Revision == 1
|
||||
})
|
||||
if selected.State.CurrentIndex != 1 {
|
||||
t.Fatalf("selected state = %#v, want index 1", selected.State)
|
||||
}
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
|
||||
_ = receivePlaylistSession(t, sessions)
|
||||
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return snapshot.Revision == 2
|
||||
})
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2}
|
||||
_ = receivePlaylistSession(t, sessions)
|
||||
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return snapshot.Revision == 3
|
||||
})
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
time.Sleep(time.Millisecond)
|
||||
boundary, ok := controller.Snapshot()
|
||||
if !ok || boundary.Revision != 3 {
|
||||
t.Fatalf("boundary snapshot = %#v, %v; want revision 3", boundary, ok)
|
||||
}
|
||||
|
||||
commands <- PlaylistCommand{}
|
||||
time.Sleep(time.Millisecond)
|
||||
invalid, ok := controller.Snapshot()
|
||||
if !ok || invalid.Revision != 3 {
|
||||
t.Fatalf("invalid-command snapshot = %#v, %v; want revision 3", invalid, ok)
|
||||
}
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startPlaylistController(
|
||||
t *testing.T,
|
||||
playlist Playlist,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package playback
|
||||
|
||||
import "time"
|
||||
|
||||
type PlaylistTimingState struct {
|
||||
Revision uint64
|
||||
Duration time.Duration
|
||||
Started bool
|
||||
Deadline time.Time
|
||||
}
|
||||
|
||||
func NewPlaylistTiming(
|
||||
revision uint64,
|
||||
duration time.Duration,
|
||||
) PlaylistTimingState {
|
||||
return PlaylistTimingState{
|
||||
Revision: revision,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
func StartPlaylistTiming(
|
||||
current PlaylistTimingState,
|
||||
revision uint64,
|
||||
now time.Time,
|
||||
) (PlaylistTimingState, bool) {
|
||||
if revision != current.Revision || current.Duration <= 0 || current.Started {
|
||||
return current, false
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Started = true
|
||||
next.Deadline = now.Add(current.Duration)
|
||||
return next, true
|
||||
}
|
||||
|
||||
func ExpirePlaylistTiming(
|
||||
current PlaylistTimingState,
|
||||
revision uint64,
|
||||
now time.Time,
|
||||
) (PlaylistTimingState, bool) {
|
||||
if revision != current.Revision ||
|
||||
!current.Started ||
|
||||
now.Before(current.Deadline) {
|
||||
return current, false
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Started = false
|
||||
next.Deadline = time.Time{}
|
||||
return next, true
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPlaylistTimingResetsState(t *testing.T) {
|
||||
got := NewPlaylistTiming(7, 10*time.Second)
|
||||
want := PlaylistTimingState{Revision: 7, Duration: 10 * time.Second}
|
||||
if got != want {
|
||||
t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPlaylistTiming(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||
current := NewPlaylistTiming(4, 10*time.Second)
|
||||
|
||||
got, started := StartPlaylistTiming(current, 4, now)
|
||||
if !started {
|
||||
t.Fatal("StartPlaylistTiming() started = false, want true")
|
||||
}
|
||||
want := PlaylistTimingState{
|
||||
Revision: 4,
|
||||
Duration: 10 * time.Second,
|
||||
Started: true,
|
||||
Deadline: now.Add(10 * time.Second),
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||
started, ok := StartPlaylistTiming(NewPlaylistTiming(4, time.Second), 4, now)
|
||||
if !ok {
|
||||
t.Fatal("initial StartPlaylistTiming() did not start")
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
current PlaylistTimingState
|
||||
revision uint64
|
||||
}{
|
||||
{name: "zero duration", current: NewPlaylistTiming(4, 0), revision: 4},
|
||||
{name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3},
|
||||
{name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5},
|
||||
{name: "already started", current: started, revision: 4},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, changed := StartPlaylistTiming(test.current, test.revision, now.Add(time.Second))
|
||||
if changed {
|
||||
t.Fatal("StartPlaylistTiming() changed = true, want false")
|
||||
}
|
||||
if got != test.current {
|
||||
t.Fatalf("StartPlaylistTiming() = %#v, want unchanged %#v", got, test.current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpirePlaylistTiming(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||
current, _ := StartPlaylistTiming(NewPlaylistTiming(9, 5*time.Second), 9, now)
|
||||
|
||||
got, expired := ExpirePlaylistTiming(current, 9, now.Add(5*time.Second))
|
||||
if !expired {
|
||||
t.Fatal("ExpirePlaylistTiming() expired = false, want true")
|
||||
}
|
||||
want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second}
|
||||
if got != want {
|
||||
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
again, expired := ExpirePlaylistTiming(got, 9, now.Add(6*time.Second))
|
||||
if expired || again != got {
|
||||
t.Fatalf("duplicate ExpirePlaylistTiming() = %#v, %v; want unchanged, false", again, expired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpirePlaylistTimingIgnoresInapplicableEvents(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||
current, _ := StartPlaylistTiming(NewPlaylistTiming(4, 10*time.Second), 4, now)
|
||||
tests := []struct {
|
||||
name string
|
||||
revision uint64
|
||||
at time.Time
|
||||
}{
|
||||
{name: "early", revision: 4, at: now.Add(9 * time.Second)},
|
||||
{name: "stale revision", revision: 3, at: now.Add(10 * time.Second)},
|
||||
{name: "future revision", revision: 5, at: now.Add(10 * time.Second)},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, expired := ExpirePlaylistTiming(current, test.revision, test.at)
|
||||
if expired {
|
||||
t.Fatal("ExpirePlaylistTiming() expired = true, want false")
|
||||
}
|
||||
if got != current {
|
||||
t.Fatalf("ExpirePlaylistTiming() = %#v, want unchanged %#v", got, current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||
old, _ := StartPlaylistTiming(NewPlaylistTiming(1, time.Second), 1, now)
|
||||
current := NewPlaylistTiming(2, 2*time.Second)
|
||||
|
||||
got, expired := ExpirePlaylistTiming(current, old.Revision, old.Deadline)
|
||||
if expired || got != current {
|
||||
t.Fatalf("old expiry changed new timing: %#v, %v", got, expired)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user