Kiosk process owns its display lifecycle: waits for X11, keeps the fullscreen black canvas below video, stops deterministically on signal. Supervisor starts it before video, restarts only on process death, and shuts the whole process group down; X11 downtime never restarts the supervisor. Per-target opener/template abstractions for future MIPI.
262 lines
6.0 KiB
Go
262 lines
6.0 KiB
Go
package display
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeCanvas struct {
|
|
mu sync.Mutex
|
|
width, height int
|
|
clears int
|
|
texts []string
|
|
flushes int
|
|
}
|
|
|
|
func (c *fakeCanvas) Size() (int, int) { return c.width, c.height }
|
|
func (c *fakeCanvas) Clear() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.clears++
|
|
}
|
|
func (c *fakeCanvas) Text(_, _ int, message string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.texts = append(c.texts, message)
|
|
}
|
|
func (c *fakeCanvas) Flush() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.flushes++
|
|
}
|
|
|
|
func (c *fakeCanvas) lineCount() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.texts)
|
|
}
|
|
|
|
func (c *fakeCanvas) lines() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.texts...)
|
|
}
|
|
|
|
func (c *fakeCanvas) drawCounts() (clears, flushes int) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.clears, c.flushes
|
|
}
|
|
|
|
type eventResult struct {
|
|
redraw bool
|
|
err error
|
|
}
|
|
|
|
type fakeSession struct {
|
|
canvas *fakeCanvas
|
|
events chan eventResult
|
|
|
|
mu sync.Mutex
|
|
closed int
|
|
}
|
|
|
|
func newFakeSession() *fakeSession {
|
|
return &fakeSession{
|
|
canvas: &fakeCanvas{width: 800, height: 600},
|
|
events: make(chan eventResult, 8),
|
|
}
|
|
}
|
|
|
|
func (s *fakeSession) Canvas() Canvas { return s.canvas }
|
|
|
|
func (s *fakeSession) WaitEvent(ctx context.Context) (bool, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return false, ctx.Err()
|
|
case result := <-s.events:
|
|
return result.redraw, result.err
|
|
}
|
|
}
|
|
|
|
func (s *fakeSession) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.closed++
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeSession) closeCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.closed
|
|
}
|
|
|
|
type fakeOpener struct {
|
|
open func(attempt int) (Session, error)
|
|
}
|
|
|
|
func (o fakeOpener) Open(context.Context) (Session, error) {
|
|
return o.open(0)
|
|
}
|
|
|
|
func waitFor(t *testing.T, condition func() bool, description string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if condition() {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s", description)
|
|
}
|
|
|
|
func tinyOptions() Options {
|
|
return Options{RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}
|
|
}
|
|
|
|
func TestRunDrawsTemplateWhenTargetConnects(t *testing.T) {
|
|
session := newFakeSession()
|
|
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
|
}()
|
|
|
|
waitFor(t, func() bool { return session.canvas.lineCount() > 0 }, "initial template draw")
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("run did not stop after cancel")
|
|
}
|
|
|
|
if clears, flushes := session.canvas.drawCounts(); clears == 0 || flushes == 0 {
|
|
t.Fatalf("template must clear and flush the canvas (clears=%d flushes=%d)", clears, flushes)
|
|
}
|
|
if session.closeCount() != 1 {
|
|
t.Fatalf("session closed %d times, want 1", session.closeCount())
|
|
}
|
|
}
|
|
|
|
func TestRunKeepsRetryingWhileTargetUnavailable(t *testing.T) {
|
|
session := newFakeSession()
|
|
var opens int32
|
|
opener := fakeOpener{open: func(int) (Session, error) {
|
|
if atomic.AddInt32(&opens, 1) <= 2 {
|
|
return nil, errors.New("x11 down")
|
|
}
|
|
return session, nil
|
|
}}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
|
}()
|
|
|
|
waitFor(t, func() bool {
|
|
return atomic.LoadInt32(&opens) >= 3 && session.canvas.lineCount() > 0
|
|
}, "connect after retries")
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("run did not stop after cancel")
|
|
}
|
|
}
|
|
|
|
func TestRunRedrawsTemplateOnEvent(t *testing.T) {
|
|
session := newFakeSession()
|
|
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
|
}()
|
|
|
|
waitFor(t, func() bool { return session.canvas.lineCount() == 1 }, "initial template draw")
|
|
session.events <- eventResult{redraw: true}
|
|
waitFor(t, func() bool { return session.canvas.lineCount() == 2 }, "redraw on event")
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("run did not stop after cancel")
|
|
}
|
|
}
|
|
|
|
func TestRunReconnectsAfterSessionFailure(t *testing.T) {
|
|
first := newFakeSession()
|
|
second := newFakeSession()
|
|
var opens int32
|
|
opener := fakeOpener{open: func(int) (Session, error) {
|
|
if atomic.AddInt32(&opens, 1) == 1 {
|
|
return first, nil
|
|
}
|
|
return second, nil
|
|
}}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
|
}()
|
|
|
|
waitFor(t, func() bool { return first.canvas.lineCount() == 1 }, "draw on first session")
|
|
first.events <- eventResult{err: errors.New("connection lost")}
|
|
waitFor(t, func() bool { return second.canvas.lineCount() == 1 }, "draw on reconnected session")
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("run did not stop after cancel")
|
|
}
|
|
|
|
if first.closeCount() != 1 {
|
|
t.Fatalf("failed session closed %d times, want 1", first.closeCount())
|
|
}
|
|
}
|
|
|
|
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
|
|
var opens int32
|
|
opener := fakeOpener{open: func(int) (Session, error) {
|
|
atomic.AddInt32(&opens, 1)
|
|
return nil, errors.New("must not be called")
|
|
}}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
if err := Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions()); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&opens) != 0 {
|
|
t.Fatalf("opener called %d times after cancellation", opens)
|
|
}
|
|
}
|