thread-safe controller snapshot for GUI, CLI

This commit is contained in:
Dmitry Sergeev
2026-09-01 00:20:17 +03:00
parent ea8be34282
commit 03ebe5bbd0
2 changed files with 184 additions and 0 deletions
+41
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
) )
type VideoSlotRunner interface { type VideoSlotRunner interface {
@@ -40,11 +41,21 @@ var (
ErrSyncSlotRequired = errors.New("sync slot is required") ErrSyncSlotRequired = errors.New("sync slot is required")
) )
type SessionSnapshot struct {
Desired SessionConfig
Plan SessionPlan
Generation uint64
}
type SessionController struct { type SessionController struct {
videoSlot VideoSlotRunner videoSlot VideoSlotRunner
audioSlot AudioSlotRunner audioSlot AudioSlotRunner
syncSlot SyncSlotRunner syncSlot SyncSlotRunner
canSync SyncPredicate canSync SyncPredicate
mu sync.RWMutex
snapshot SessionSnapshot
hasSnapshot bool
} }
func NewSessionController( func NewSessionController(
@@ -100,6 +111,13 @@ func (c *SessionController) Run(
desired := initial desired := initial
runtime := c.startSessionRuntime(ctx, plan) runtime := c.startSessionRuntime(ctx, plan)
generation := uint64(1)
c.publish(SessionSnapshot{
Desired: initial,
Plan: plan,
Generation: generation,
})
for { for {
var runtimeDone <-chan struct{} var runtimeDone <-chan struct{}
if runtime != nil { if runtime != nil {
@@ -144,9 +162,17 @@ func (c *SessionController) Run(
return err return err
} }
if plan.Topology != nextPlan.Topology {
generation++
}
desired = nextDesired desired = nextDesired
plan = nextPlan plan = nextPlan
runtime = nextRuntime runtime = nextRuntime
c.publish(SessionSnapshot{
Desired: desired,
Plan: plan,
Generation: generation,
})
} }
} }
} }
@@ -301,3 +327,18 @@ func sessionRuntimeSendError(ctx context.Context, runtime *sessionRuntime) error
<-runtime.done <-runtime.done
return unexpectedSessionRuntimeError(runtime.result) return unexpectedSessionRuntimeError(runtime.result)
} }
func (c *SessionController) Snapshot() (SessionSnapshot, bool) {
c.mu.RLock()
snapshot := c.snapshot
ok := c.hasSnapshot
c.mu.RUnlock()
return snapshot, ok
}
func (c *SessionController) publish(snapshot SessionSnapshot) {
c.mu.Lock()
c.snapshot = snapshot
c.hasSnapshot = true
c.mu.Unlock()
}
@@ -3,6 +3,7 @@ package playback
import ( import (
"context" "context"
"errors" "errors"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -187,6 +188,25 @@ func receiveIndependentStarts(t *testing.T, events <-chan controllerEvent) {
} }
} }
func waitControllerSnapshot(
t *testing.T,
controller *SessionController,
match func(SessionSnapshot) bool,
) SessionSnapshot {
t.Helper()
deadline := time.Now().Add(time.Second)
for {
if snapshot, ok := controller.Snapshot(); ok && match(snapshot) {
return snapshot
}
if time.Now().After(deadline) {
snapshot, ok := controller.Snapshot()
t.Fatalf("snapshot timed out: %#v, available=%t", snapshot, ok)
}
time.Sleep(time.Millisecond)
}
}
func TestSessionControllerUpdatesOnlyChangedIndependentSlot(t *testing.T) { func TestSessionControllerUpdatesOnlyChangedIndependentSlot(t *testing.T) {
events := make(chan controllerEvent, 32) events := make(chan controllerEvent, 32)
controller := newRecordingController(t, events) controller := newRecordingController(t, events)
@@ -277,3 +297,126 @@ func TestSessionControllerCancellationStopsAndJoinsRuntime(t *testing.T) {
t.Fatal("Run() did not stop after cancellation") t.Fatal("Run() did not stop after cancellation")
} }
} }
func TestSessionControllerSnapshotTracksDesiredPlanAndGeneration(t *testing.T) {
events := make(chan controllerEvent, 64)
controller := newRecordingController(t, events)
if snapshot, ok := controller.Snapshot(); ok {
t.Fatalf("Snapshot() before Run = %#v, true; want unavailable", snapshot)
}
initial := validCommandSession()
initial.SyncRequested = false
commands := make(chan SessionCommand)
done := make(chan error, 1)
go func() { done <- controller.Run(context.Background(), initial, commands) }()
receiveIndependentStarts(t, events)
snapshot := waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Desired == initial
})
if snapshot.Generation != 1 || snapshot.Plan.Topology != TopologyIndependent {
t.Fatalf("initial snapshot = %#v", snapshot)
}
newVideo := FeedConfig{Domain: "/new-video", UUID: "new-video", Active: true}
commands <- SessionCommand{Kind: CommandSetVideo, Config: newVideo}
receiveControllerEvent(t, events)
snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Desired.Video == newVideo
})
if snapshot.Generation != 1 || snapshot.Plan.Topology != TopologyIndependent {
t.Fatalf("same-topology snapshot = %#v", snapshot)
}
commands <- SessionCommand{Kind: CommandEnableSync}
for {
if event := receiveControllerEvent(t, events); event.unit == UnitSync && event.action == "start" {
break
}
}
snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Plan.Topology == TopologySynchronized
})
if snapshot.Generation != 2 || !snapshot.Desired.SyncRequested {
t.Fatalf("sync snapshot = %#v", snapshot)
}
commands <- SessionCommand{Kind: CommandDisableSync}
for {
event := receiveControllerEvent(t, events)
if event.action == "start" && (event.unit == UnitVideo || event.unit == UnitAudio) {
break
}
}
snapshot = waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Plan.Topology == TopologyIndependent && !snapshot.Desired.SyncRequested
})
if snapshot.Generation != 3 {
t.Fatalf("independent snapshot generation = %d, want 3", snapshot.Generation)
}
close(commands)
if err := <-done; err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestSessionControllerSnapshotRetainsUnavailableSyncRequest(t *testing.T) {
events := make(chan controllerEvent, 32)
controller, err := NewSessionController(
recordingVideoSlot{events},
recordingAudioSlot{events},
recordingSyncSlot{events},
nil,
)
if err != nil {
t.Fatal(err)
}
initial := validCommandSession()
initial.SyncRequested = false
commands := make(chan SessionCommand)
done := make(chan error, 1)
go func() { done <- controller.Run(context.Background(), initial, commands) }()
receiveIndependentStarts(t, events)
commands <- SessionCommand{Kind: CommandEnableSync}
snapshot := waitControllerSnapshot(t, controller, func(snapshot SessionSnapshot) bool {
return snapshot.Desired.SyncRequested
})
if snapshot.Plan.Topology != TopologyIndependent || snapshot.Generation != 1 {
t.Fatalf("unsupported-sync snapshot = %#v", snapshot)
}
close(commands)
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestSessionControllerSnapshotConcurrentReads(t *testing.T) {
events := make(chan controllerEvent, 32)
controller := newRecordingController(t, events)
initial := validCommandSession()
initial.SyncRequested = false
commands := make(chan SessionCommand)
done := make(chan error, 1)
go func() { done <- controller.Run(context.Background(), initial, commands) }()
receiveIndependentStarts(t, events)
waitControllerSnapshot(t, controller, func(SessionSnapshot) bool { return true })
var readers sync.WaitGroup
for range 8 {
readers.Add(1)
go func() {
defer readers.Done()
for range 1_000 {
controller.Snapshot()
}
}()
}
readers.Wait()
close(commands)
if err := <-done; err != nil {
t.Fatal(err)
}
}