feat: independent fullscreen kiosk display foundation (#1)

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.
This commit is contained in:
2026-09-30 23:12:06 +02:00
parent 0360f23f0a
commit a31bed8aeb
15 changed files with 1266 additions and 78 deletions
+28 -52
View File
@@ -1,71 +1,47 @@
// Command groundstation-display renders the Ground Station kiosk canvas. It
// owns its display lifecycle independently of WFB, the transmitter and the
// video launcher: it waits for its target to appear, redraws as needed, and
// stops only when it receives SIGINT or SIGTERM.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/xproto"
"git.piksar.eu/piotr/groundstation/internal/display"
)
const (
width = 1920
height = 1080
)
// buildTarget selects the target-specific opener and template. Each target
// owns its own backend and layouts; there is no shared global template.
func buildTarget(name string) (display.Opener, display.Template, error) {
switch name {
case "x11":
return display.X11Opener{}, display.NewStatusTemplate("Brak polaczenia WFB"), nil
default:
return nil, nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
}
}
func main() {
conn, err := xgb.NewConn()
if err != nil {
log.Fatal(err)
}
defer conn.Close()
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
flag.Parse()
screen := xproto.Setup(conn).DefaultScreen(conn)
window, err := xproto.NewWindowId(conn)
opener, template, err := buildTarget(*target)
if err != nil {
log.Fatal(err)
}
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root, 0, 0, width, height, 0,
xproto.WindowClassInputOutput, screen.RootVisual, values,
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
xproto.MapWindow(conn, window)
conn.Sync()
// XFCE owns the actual fullscreen policy; use its existing wmctrl helper.
_ = exec.Command("wmctrl", "-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen").Run()
gc, err := xproto.NewGcontextId(conn)
if err != nil {
log.Fatal(err)
}
xproto.CreateGC(conn, gc, xproto.Drawable(window), xproto.GcForeground|xproto.GcBackground,
[]uint32{screen.WhitePixel, screen.BlackPixel})
font := xproto.Font(window)
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
xproto.ChangeGC(conn, gc, xproto.GcFont, []uint32{uint32(font)})
draw := func() {
xproto.ClearArea(conn, false, window, 0, 0, width, height)
message := "Brak polaczenia WFB"
xproto.ImageText8(conn, byte(len(message)), xproto.Drawable(window), gc, 32, 56, message)
conn.Sync()
}
draw()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
for {
event, err := conn.WaitForEvent()
if err != nil {
if os.IsTimeout(err) {
continue
}
return
}
if _, ok := event.(xproto.ExposeEvent); ok {
draw()
}
// Temporary X11 unavailability never exits this process; display.Run
// keeps retrying internally until the context is cancelled.
if err := display.Run(ctx, opener, template, display.Options{}); err != nil {
log.Fatalf("display: %v", err)
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"log"
"os"
"os/exec"
"syscall"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
)
// displaySupervisor keeps the independent kiosk display process running.
// X11 downtime never escalates here: the kiosk retries its target
// internally, and this loop only restarts the process if the kiosk itself
// dies. The Ground Station supervisor therefore stays up while X11 restarts.
type displaySupervisor struct {
launch func() (*exec.Cmd, error)
logger *log.Logger
// retryDelay is the first wait after a kiosk exit; it grows to maxDelay
// and resets after the kiosk stayed up for stableAfter.
retryDelay time.Duration
maxDelay time.Duration
stableAfter time.Duration
// stopGrace bounds the interrupt-to-kill window on shutdown.
stopGrace time.Duration
}
func newDisplaySupervisor(cfg config.Config, logger *log.Logger) *displaySupervisor {
return &displaySupervisor{
launch: func() (*exec.Cmd, error) { return startDisplayProcess(cfg.Display) },
logger: logger,
retryDelay: time.Second,
maxDelay: 30 * time.Second,
stableAfter: time.Minute,
stopGrace: 5 * time.Second,
}
}
func (s *displaySupervisor) run(ctx context.Context) {
delay := s.retryDelay
for ctx.Err() == nil {
cmd, err := s.launch()
if err == nil {
err = cmd.Start()
}
if err != nil {
s.logger.Printf("display start: %v; retrying", err)
if !sleepFor(ctx, delay) {
return
}
delay = nextBackoff(delay, s.maxDelay)
continue
}
started := time.Now()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if ctx.Err() != nil {
return
}
s.logger.Printf("display exited: %v; restarting", err)
case <-ctx.Done():
stopDisplayProcess(cmd, done, s.stopGrace)
return
}
if time.Since(started) >= s.stableAfter {
delay = s.retryDelay
}
if !sleepFor(ctx, delay) {
return
}
delay = nextBackoff(delay, s.maxDelay)
}
}
// stopDisplayProcess shuts the kiosk down deterministically: the interrupt
// goes to the whole kiosk process group (runuser plus the kiosk itself,
// which runuser does not forward signals to), waits for the grace window,
// then kills the group and reaps the direct child.
func stopDisplayProcess(cmd *exec.Cmd, done <-chan error, grace time.Duration) {
signalGroup := func(sig syscall.Signal) {
_ = syscall.Kill(-cmd.Process.Pid, sig)
}
signalGroup(syscall.SIGINT)
select {
case <-done:
case <-time.After(grace):
signalGroup(syscall.SIGKILL)
<-done
}
}
// startDisplayProcess builds the kiosk launch command. It normally runs as
// the unprivileged desktop user against the session X server, in its own
// process group so shutdown can signal the whole kiosk tree.
func startDisplayProcess(cfg config.DisplayConfig) (*exec.Cmd, error) {
env := []string{
"DISPLAY=" + cfg.Display,
"XAUTHORITY=" + cfg.Xauthority,
}
var cmd *exec.Cmd
if cfg.User == "" {
cmd = exec.Command(cfg.Command)
// Configured values must precede the inherited environment: the
// child resolves the first occurrence of each variable.
cmd.Env = append(env, os.Environ()...)
} else {
args := []string{"-u", cfg.User, "--", "env"}
args = append(args, env...)
args = append(args, cfg.Command)
cmd = exec.Command("runuser", args...)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd, nil
}
func nextBackoff(delay, max time.Duration) time.Duration {
delay *= 2
if delay > max {
return max
}
return delay
}
func sleepFor(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
)
func TestStartDisplayProcessWrapsCommandForDesktopUser(t *testing.T) {
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/usr/local/bin/groundstation-display",
User: "orangepi",
Display: ":0",
Xauthority: "/home/orangepi/.Xauthority",
})
if err != nil {
t.Fatal(err)
}
got := strings.Join(cmd.Args, " ")
want := "runuser -u orangepi -- env DISPLAY=:0 XAUTHORITY=/home/orangepi/.Xauthority /usr/local/bin/groundstation-display"
if got != want {
t.Fatalf("args=%q want %q", got, want)
}
}
func TestStartDisplayProcessRunsDirectlyWithoutUser(t *testing.T) {
t.Setenv("DISPLAY", ":9")
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/bin/kiosk",
Display: ":1",
})
if err != nil {
t.Fatal(err)
}
if strings.Join(cmd.Args, " ") != "/bin/kiosk" {
t.Fatalf("args=%v want [/bin/kiosk]", cmd.Args)
}
// The configured environment must precede the inherited environment so
// the child resolves the configured display, not a pre-existing one.
if len(cmd.Env) < 2 || cmd.Env[0] != "DISPLAY=:1" {
t.Fatalf("env %v must start with DISPLAY=:1 override", cmd.Env)
}
}
func TestStartDisplayProcessRunsInOwnProcessGroup(t *testing.T) {
for _, user := range []string{"", "orangepi"} {
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/usr/local/bin/groundstation-display",
User: user,
Display: ":0",
})
if err != nil {
t.Fatal(err)
}
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
t.Fatalf("user %q: kiosk must start in its own process group", user)
}
}
}
func testSupervisor(launch func() (*exec.Cmd, error)) *displaySupervisor {
return &displaySupervisor{
launch: launch,
logger: log.New(io.Discard, "", 0),
retryDelay: time.Millisecond,
maxDelay: 5 * time.Millisecond,
stableAfter: time.Hour,
stopGrace: time.Second,
}
}
func TestDisplaySupervisorRestartsKioskAfterUnexpectedExit(t *testing.T) {
var starts int32
supervisor := testSupervisor(func() (*exec.Cmd, error) {
atomic.AddInt32(&starts, 1)
return exec.Command("true"), nil
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
waitForCondition(t, func() bool { return atomic.LoadInt32(&starts) >= 3 }, "kiosk restarts")
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
}
func TestDisplaySupervisorKeepsRunningWhenLaunchFails(t *testing.T) {
var attempts int32
supervisor := testSupervisor(func() (*exec.Cmd, error) {
atomic.AddInt32(&attempts, 1)
return nil, errors.New("binary missing")
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
waitForCondition(t, func() bool { return atomic.LoadInt32(&attempts) >= 3 }, "launch retries")
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
}
func waitForCondition(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 TestDisplaySupervisorStopsRunningKioskOnShutdown(t *testing.T) {
var started atomic.Pointer[exec.Cmd]
pipes := make(chan io.ReadCloser, 1)
supervisor := testSupervisor(func() (*exec.Cmd, error) {
cmd := exec.Command("sh", "-c", "echo started; exec sleep 60")
// Mirrors startDisplayProcess: the kiosk runs in its own group.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
started.Store(cmd)
pipes <- pipe
return cmd, nil
})
supervisor.stopGrace = 200 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
// The child signals readiness over its own pipe, which proves Start ran
// without racing on cmd fields written by Start.
pipe := <-pipes
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
// After the supervisor goroutine has returned, its cmd usage is safely
// ordered; the process must be gone.
cmd := started.Load()
if err := cmd.Process.Signal(syscall.Signal(0)); err == nil {
t.Fatal("kiosk process must be gone after shutdown")
}
}
// liveGroupMembers returns the non-zombie members of a process group. It
// relies on procfs and callers must skip on non-Linux platforms.
func liveGroupMembers(t *testing.T, pgid int) []string {
t.Helper()
entries, err := os.ReadDir("/proc")
if err != nil {
t.Fatal(err)
}
var live []string
for _, entry := range entries {
name := entry.Name()
pid, err := strconv.Atoi(name)
if err != nil {
continue
}
data, err := os.ReadFile("/proc/" + name + "/stat")
if err != nil {
continue
}
closing := bytes.LastIndexByte(data, ')')
fields := strings.Fields(string(data[closing+1:]))
if len(fields) < 3 {
continue
}
if fields[0] != "Z" && fields[2] == strconv.Itoa(pgid) {
live = append(live, fmt.Sprintf("%d(%s)", pid, fields[0]))
}
}
return live
}
func waitForEmptyGroup(t *testing.T, pgid int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for {
members := liveGroupMembers(t, pgid)
if len(members) == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("process group %d still has live members: %v", pgid, members)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestStopDisplayProcessTerminatesWholeProcessGroup(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
cmd := exec.Command("sh", "-c", "echo started; sleep 60 & wait")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
stopDisplayProcess(cmd, done, time.Second)
// The interrupt must reach the whole group, including the sleep
// descendant that runuser-style children would otherwise hide behind.
waitForEmptyGroup(t, cmd.Process.Pid)
}
func TestStopDisplayProcessEscalatesToKillWhenInterruptIgnored(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
// The whole group ignores SIGINT, forcing the SIGKILL escalation.
cmd := exec.Command("sh", "-c", "echo started; trap '' INT; while :; do :; done")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
grace := 200 * time.Millisecond
began := time.Now()
stopDisplayProcess(cmd, done, grace)
if elapsed := time.Since(began); elapsed < grace {
t.Fatalf("stop returned after %v, grace was %v", elapsed, grace)
}
waitForEmptyGroup(t, cmd.Process.Pid)
}
+28 -21
View File
@@ -8,9 +8,9 @@ import (
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
@@ -20,30 +20,45 @@ import (
)
func main() {
os.Exit(runMain())
}
func runMain() int {
configPath := flag.String("config", "/etc/groundstation/config.yml", "Ground Station YAML configuration")
stdin := flag.Bool("stdin", false, "read framed data from standard input")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
log.Printf("load config: %v", err)
return 1
}
logger, closeLog, err := setupLogger(cfg)
if err != nil {
log.Fatal(err)
log.Print(err)
return 1
}
defer closeLog()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if display, err := startDisplay(); err != nil {
log.Printf("display start deferred: %v", err)
} else {
defer func() {
_ = display.Process.Signal(os.Interrupt)
_ = display.Wait()
// Shutdown order is deterministic: video stops first, then the kiosk
// display. The defers below run in reverse registration order.
var displayWG sync.WaitGroup
defer displayWG.Wait()
displayCtx, stopDisplay := context.WithCancel(ctx)
defer stopDisplay()
// The kiosk canvas starts before the video launcher so it is already
// mapped on DISPLAY=:0 when video appears.
if cfg.Display.Enabled {
displayWG.Add(1)
go func() {
defer displayWG.Done()
newDisplaySupervisor(cfg, logger).run(displayCtx)
}()
}
@@ -55,9 +70,10 @@ func main() {
if *stdin {
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
logger.Printf("stdin run: %v", err)
return 1
}
return
return 0
}
for ctx.Err() == nil {
@@ -78,16 +94,7 @@ func main() {
}
wait(ctx, time.Second)
}
}
func startDisplay() (*exec.Cmd, error) {
cmd := exec.Command("runuser", "-u", "orangepi", "--", "env",
"DISPLAY=:0", "XAUTHORITY=/home/orangepi/.Xauthority", "/usr/local/bin/groundstation-display")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd, nil
return 0
}
func setupLogger(cfg config.Config) (*log.Logger, func(), error) {