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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user