fix: sweep kiosk process group after direct child exit (#1)
Build Ground Station / build (push) Successful in 26s

CI caught stopDisplayProcess returning when the direct child died
without proving the group emptied. Escalation tests now ignore SIGINT
from birth (no trap-setup race) and a final group SIGKILL sweep removes
any kiosk surviving an exiting runuser.
This commit was merged in pull request #7.
This commit is contained in:
2026-09-30 23:25:25 +02:00
parent a31bed8aeb
commit 53ab05ce12
2 changed files with 45 additions and 3 deletions
+5 -1
View File
@@ -82,7 +82,10 @@ func (s *displaySupervisor) run(ctx context.Context) {
// 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.
// then kills the group and reaps the direct child. The direct child exiting
// does not prove the group is empty — a kiosk that ignored the interrupt
// would outlive an exiting runuser — so the group receives a final SIGKILL
// sweep; for an already-empty group that is a harmless no-op.
func stopDisplayProcess(cmd *exec.Cmd, done <-chan error, grace time.Duration) {
signalGroup := func(sig syscall.Signal) {
_ = syscall.Kill(-cmd.Process.Pid, sig)
@@ -94,6 +97,7 @@ func stopDisplayProcess(cmd *exec.Cmd, done <-chan error, grace time.Duration) {
signalGroup(syscall.SIGKILL)
<-done
}
signalGroup(syscall.SIGKILL)
}
// startDisplayProcess builds the kiosk launch command. It normally runs as
+40 -2
View File
@@ -9,6 +9,7 @@ import (
"log"
"os"
"os/exec"
"os/signal"
"runtime"
"strconv"
"strings"
@@ -273,9 +274,14 @@ func TestStopDisplayProcessEscalatesToKillWhenInterruptIgnored(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
// Ignored dispositions are inherited by the whole process tree, so the
// group ignores SIGINT from birth without a trap-setup race.
signal.Ignore(syscall.SIGINT)
defer signal.Reset(syscall.SIGINT)
// The whole group ignores SIGINT, forcing the SIGKILL escalation.
cmd := exec.Command("sh", "-c", "echo started; trap '' INT; while :; do :; done")
// The group leader ignores SIGINT and never exits, forcing the
// SIGKILL escalation.
cmd := exec.Command("sh", "-c", "echo started; while :; do :; done")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
@@ -301,3 +307,35 @@ func TestStopDisplayProcessEscalatesToKillWhenInterruptIgnored(t *testing.T) {
waitForEmptyGroup(t, cmd.Process.Pid)
}
func TestStopDisplayProcessKillsGroupSurvivingDirectChildExit(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
// The tree ignores SIGINT via inheritance, so a group member can
// outlive the direct child exactly like a kiosk behind an exiting
// runuser; the stop must sweep it instead of trusting the child exit.
signal.Ignore(syscall.SIGINT)
defer signal.Reset(syscall.SIGINT)
cmd := exec.Command("sh", "-c", "echo started; sleep 60 & exit 0")
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, 200*time.Millisecond)
waitForEmptyGroup(t, cmd.Process.Pid)
}