Files
groundstation/internal/osd/publisher_test.go
T

184 lines
4.9 KiB
Go

package osd
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestPublisherKeepsOnlyTheLatestSnapshot(t *testing.T) {
publisher := NewPublisher()
if _, ok := publisher.Latest(); ok {
t.Fatal("a publisher without snapshots must report none")
}
first := testSnapshot()
publisher.Publish(first)
got, ok := publisher.Latest()
if !ok {
t.Fatal("a published snapshot must be readable")
}
if got.Sequence != first.Sequence || got.PublishedMS == 0 {
t.Fatalf("latest=%+v must carry the published state with a publish timestamp", got)
}
second := first
second.Sequence = 7
second.Video = false
publisher.Publish(second)
got, _ = publisher.Latest()
if got.Sequence != 7 || got.Video {
t.Fatalf("latest=%+v must be the most recent snapshot", got)
}
}
func TestPublishNeverBlocksOnASlowWriter(t *testing.T) {
release := make(chan struct{})
var writes atomic.Int32
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"), func(string, []byte) error {
writes.Add(1)
<-release
return errors.New("unreachable")
}, Heartbeat, time.Millisecond, nil)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
snapshot := testSnapshot()
snapshot.Sequence = uint16(i)
publisher.Publish(snapshot)
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("publish must never wait for the state file writer")
}
close(release)
}
func TestFilePublisherPublishesSnapshotsToTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "run", "osd-state.bin")
publisher := NewFilePublisher(path, nil)
defer publisher.Close()
want := testSnapshot()
publisher.Publish(want)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got, err := DecodeFile(path); err == nil && got == want {
if info, statErr := os.Stat(path); statErr != nil || info.Mode().Perm() != 0644 {
t.Fatalf("state file mode=%v want 0644 for the unprivileged display user", info.Mode())
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the published snapshot never reached the state file")
}
func TestFilePublisherRewritesUnchangedStateAsHeartbeat(t *testing.T) {
var writes sync.Mutex
count := 0
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"),
func(path string, data []byte) error {
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
writes.Lock()
count++
writes.Unlock()
return nil
}, 10*time.Millisecond, 5*time.Millisecond, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
writes.Lock()
got := count
writes.Unlock()
if got >= 3 {
publisher.Close()
return
}
time.Sleep(time.Millisecond)
}
publisher.Close()
t.Fatal("an unchanged snapshot must keep being republished so it stays fresh")
}
func TestFilePublisherCloseRemovesTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := NewFilePublisher(path, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
break
}
time.Sleep(time.Millisecond)
}
publisher.Close()
publisher.Close() // must be idempotent
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("state file stat=%v want removed after close", err)
}
}
func TestFilePublisherKeepsRetryingAfterAWriteFailure(t *testing.T) {
var attempts atomic.Int32
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := newFilePublisher(path, func(path string, data []byte) error {
if attempts.Add(1) == 1 {
return errors.New("transient write failure")
}
return os.WriteFile(path, data, 0644)
}, time.Second, 5*time.Millisecond, log.New(os.Stderr, "", 0))
defer publisher.Close()
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := DecodeFile(path); err == nil {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("a failed state write must be retried, not dropped")
}
func TestFilePublisherThrottlesPersistentFailureLogs(t *testing.T) {
var logs sync.Mutex
var buffer bytes.Buffer
publisher := newFilePublisher(filepath.Join(t.TempDir(), "missing-dir", "osd-state.bin"),
func(string, []byte) error {
return errors.New("state path is broken")
}, 20*time.Millisecond, 5*time.Millisecond, log.New(&buffer, "", 0))
publisher.Publish(testSnapshot())
time.Sleep(150 * time.Millisecond)
publisher.Close()
logs.Lock()
defer logs.Unlock()
lines := strings.Count(buffer.String(), "\n")
// The write retries on every tick but must log at most once per
// heartbeat: about 150ms/20ms plus the first line, never per attempt.
if lines == 0 || lines > 8 {
t.Fatalf("failure log lines=%d want throttled reporting (1..8)", lines)
}
}