Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions go/cmd/compass-app/multiwindow_e2e_reap_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//go:build linux && gtk4

package main

import (
"os"
"strconv"
"strings"
)

// liveChildren returns every process still descended from this one, discovered
// by walking /proc for entries whose ppid chains back to os.Getpid(). WebKit's
// GPU/network helpers are our direct children; scanning the whole subtree also
// catches any helper-of-a-helper before it reparents to init.
func liveChildren() []procInfo {
self := os.Getpid()

entries, err := os.ReadDir("/proc")
if err != nil {
// Without procfs there is nothing to reap and no way to prove otherwise;
// treat as clean so the gate never wedges on an unreadable /proc.
return nil
}

ppids := make(map[int]int, len(entries))
comms := make(map[int]string, len(entries))
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue // not a pid directory
}
ppid, comm, ok := readStat(pid)
if !ok {
continue // vanished between readdir and read; not our concern
}
ppids[pid] = ppid
comms[pid] = comm
}

var out []procInfo
for pid := range ppids {
if pid != self && descendsFrom(pid, self, ppids) {
out = append(out, procInfo{pid: pid, comm: comms[pid]})
}
}
return out
}

// readStat reads ppid and comm for one pid, tolerating a process that exits
// mid-scan.
func readStat(pid int) (ppid int, comm string, ok bool) {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return 0, "", false
}
return parseStat(string(raw))
}

// parseStat pulls ppid and comm out of a /proc/<pid>/stat line. comm can hold
// spaces and parens, so key off the final ')': the two fields after it are
// state then ppid.
func parseStat(s string) (ppid int, comm string, ok bool) {
open := strings.IndexByte(s, '(')
shut := strings.LastIndexByte(s, ')')
if open < 0 || shut < 0 || shut < open {
return 0, "", false
}
comm = s[open+1 : shut]
fields := strings.Fields(s[shut+1:])
if len(fields) < 2 {
return 0, "", false
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
return 0, "", false
}
return ppid, comm, true
}

// descendsFrom reports whether pid chains up to root through the ppid map. The
// walk is bounded by the map size so pid reuse mid-scan cannot loop forever.
func descendsFrom(pid, root int, ppids map[int]int) bool {
for hops := 0; hops <= len(ppids); hops++ {
ppid, seen := ppids[pid]
if !seen {
return false
}
if ppid == root {
return true
}
if ppid <= 1 {
return false // reached init/kernel without hitting root
}
pid = ppid
}
return false
}
66 changes: 66 additions & 0 deletions go/cmd/compass-app/multiwindow_e2e_reap_linux_unit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//go:build linux && gtk4

package main

import "testing"

// The chain walk decides which pids get signalled, so a regression here would
// aim SIGKILL at something we do not own. Every non-descendant case must answer
// false: under-signalling leaks a process, over-signalling kills a stranger.
func TestDescendsFrom(t *testing.T) {
const self = 100

tests := []struct {
name string
pid int
ppids map[int]int
want bool
}{
{"direct child", 200, map[int]int{200: self, self: 1}, true},
{"deep grandchild", 400, map[int]int{400: 300, 300: 200, 200: self, self: 1}, true},
{"reparented to init", 200, map[int]int{200: 1, self: 1}, false},
{"sibling under a subreaper", 200, map[int]int{200: 50, 50: 1, self: 1}, false},
{"broken link", 400, map[int]int{400: 300, self: 1}, false},
{"cycle terminates", 200, map[int]int{200: 300, 300: 200, self: 1}, false},
{"self is not its own descendant", self, map[int]int{self: 1}, false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := descendsFrom(tc.pid, self, tc.ppids); got != tc.want {
t.Fatalf("descendsFrom(%d, %d) = %v, want %v", tc.pid, self, got, tc.want)
}
})
}
}

// A comm holding spaces or parens would break a naive field split, and a
// misparsed ppid is a wrong signal target.
func TestParseStat(t *testing.T) {
tests := []struct {
name string
line string
wantPPID int
wantComm string
wantOK bool
}{
{"plain", "42 (bash) S 7 42 42 0 -1 4194304", 7, "bash", true},
{"comm with spaces", "42 (Web Content) S 7 42", 7, "Web Content", true},
{"comm with parens", "42 (odd (name)) S 7 42", 7, "odd (name)", true},
{"comm with a space and a paren", "42 (a b) c) R 9 42", 9, "a b) c", true},
{"truncated after comm", "42 (bash)", 0, "", false},
{"no parens", "42 bash S 7", 0, "", false},
{"non-numeric ppid", "42 (bash) S x 42", 0, "", false},
{"empty", "", 0, "", false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ppid, comm, ok := parseStat(tc.line)
if ok != tc.wantOK || ppid != tc.wantPPID || comm != tc.wantComm {
t.Fatalf("parseStat(%q) = (%d, %q, %v), want (%d, %q, %v)",
tc.line, ppid, comm, ok, tc.wantPPID, tc.wantComm, tc.wantOK)
}
})
}
}
8 changes: 8 additions & 0 deletions go/cmd/compass-app/multiwindow_e2e_reap_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build unix && gtk4 && !linux

package main

// liveChildren has no portable non-linux implementation; the leaked-child reap
// targets WebKitGTK under the linux e2e gate, and darwin does not run it. A
// no-op keeps the non-linux unix build compiling and the reap a clean pass.
func liveChildren() []procInfo { return nil }
72 changes: 71 additions & 1 deletion go/cmd/compass-app/multiwindow_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@
package main

import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"syscall"
"testing"
"time"

"github.com/RigelBuild/compass/go/internal/bridge"
"github.com/wailsapp/wails/v3/pkg/application"
Expand Down Expand Up @@ -69,7 +74,72 @@ func TestMain(m *testing.M) {
os.Stderr.WriteString("compass-app multi-window e2e: app.Run: " + err.Error() + "\n")
os.Exit(1)
}
os.Exit(e2eExitCode)
// WebKitGTK fork+execs its GPU/network helpers in C as our direct children;
// upstream teardown never joins them. Left alive they hold go test's captured
// pipe until its WaitDelay fires (a PASS turns into "Test I/O incomplete"), or
// orphan to init on a reused runner. Reap them here, bounded and loud.
os.Exit(reapChildren(e2eExitCode))
}

// reapWait hard-bounds the join; past it a child is wedged, so the gate reds
// loudly instead of tripping go test's opaque WaitDelay. reapEscalate is the
// SIGTERM grace before SIGKILL — the helpers honor SIGTERM slowly.
const (
reapWait = 10 * time.Second
reapEscalate = 2 * time.Second
)

// procInfo names one surviving child for the timeout diagnostic.
type procInfo struct {
pid int
comm string
}

// reapChildren joins the WebKit helpers WebKitGTK fork+exec'd as our children.
// They block until signaled (the app has quit), so a passive wait never ends:
// SIGTERM, then SIGKILL if they dawdle, until none remain or reapWait elapses.
// A survivor past the bound reds loudly and non-zero, never masking a failure.
func reapChildren(exitCode int) int {
start := time.Now()
signalChildren(liveChildren(), syscall.SIGTERM)

ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
remaining := liveChildren()
if len(remaining) == 0 {
return exitCode
}
if time.Since(start) >= reapWait {
var b strings.Builder
fmt.Fprintf(&b, "compass-app multi-window e2e: %d child process(es) survived the %s reap deadline:\n", len(remaining), reapWait)
for _, p := range remaining {
fmt.Fprintf(&b, " pid=%d comm=%q\n", p.pid, p.comm)
}
os.Stderr.WriteString(b.String())
if exitCode == 0 {
return 1
}
return exitCode
}
// Re-signal each pass rather than latching: a helper first seen after
// the grace would otherwise get neither SIGTERM nor SIGKILL.
if time.Since(start) >= reapEscalate {
signalChildren(remaining, syscall.SIGKILL)
}
<-ticker.C
}
}

// signalChildren sends sig to each named process. ESRCH is expected and ignored
// — the child exited between discovery and the signal; any other error is a real
// fault worth surfacing, never swallowed.
func signalChildren(procs []procInfo, sig syscall.Signal) {
for _, p := range procs {
if err := syscall.Kill(p.pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) {
fmt.Fprintf(os.Stderr, "compass-app multi-window e2e: signal %d pid=%d: %v\n", sig, p.pid, err)
}
}
}

// TestMultiWindowCloseCancelsOnlyClosingWindowE2E is the leak-gate proof through
Expand Down
Loading