detached 6558c1f

fix(app): stop the watchdog probes from leaking and outliving the session

Each output probe registered a context.AfterFunc on the output context
and dropped its stop function: two leaked registrations per second for
the life of every output. Probes now use the output context directly.

The watchdog waits for its probes in flight on exit, and the display
probe honours the session context, so a stuck display loop cannot hold
the shutdown. Probes run every second with a 3 s timeout, which halves
the idle wakeups they cost. The test runs on synctest.
commit
6558c1fb1132dbeba3fd6e989a5a424f65162beb
author
brice · · 2026-10-10 14:44 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
bfea45c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +87 -65
M internal/adapters/watchdog/watchdog.go +6 -2
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "runtime/pprof"
12+ "sync"
1213 "time"
1314
1415 "github.com/bnema/zerowrap"
@@ -31,8 +32,11 @@ type Options struct {
3132
3233 // Run probes each loop probes returns until ctx ends; the set may change
3334 // between ticks (outputs come and go). On a stall it writes one dump per
34-// stall: a loop must answer again before it is dumped again.
35+// stall: a loop must answer again before it is dumped again. Every Ping
36+// must return once ctx ends: Run waits for the probes in flight.
3537 func Run(ctx context.Context, probes func() []Probe, o Options) {
38+ var inflight sync.WaitGroup
39+ defer inflight.Wait()
3640 type state struct {
3741 answer chan struct{} // the probe in flight, nil when none
3842 since time.Time
@@ -75,7 +79,7 @@ func Run(ctx context.Context, probes func() []Probe, o Options) {
7579 }
7680 answer := make(chan struct{})
7781 s.answer, s.since = answer, now
78- go func() { p.Ping(); close(answer) }()
82+ inflight.Go(func() { p.Ping(); close(answer) })
7983 }
8084 // A loop that left (an unplugged output) is forgotten; its
8185 // probe goroutine ends with the loop's context.
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "runtime/pprof"11 "runtime/pprof"
12+ "sync"
12 "time"13 "time"
13 14
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -31,8 +32,11 @@ type Options struct {
31 32
32 // Run probes each loop probes returns until ctx ends; the set may change33 // Run probes each loop probes returns until ctx ends; the set may change
33 // between ticks (outputs come and go). On a stall it writes one dump per34 // between ticks (outputs come and go). On a stall it writes one dump per
34-// stall: a loop must answer again before it is dumped again.35+// stall: a loop must answer again before it is dumped again. Every Ping
36+// must return once ctx ends: Run waits for the probes in flight.
35 func Run(ctx context.Context, probes func() []Probe, o Options) {37 func Run(ctx context.Context, probes func() []Probe, o Options) {
38+ var inflight sync.WaitGroup
39+ defer inflight.Wait()
36 type state struct {40 type state struct {
37 answer chan struct{} // the probe in flight, nil when none41 answer chan struct{} // the probe in flight, nil when none
38 since time.Time42 since time.Time
@@ -75,7 +79,7 @@ func Run(ctx context.Context, probes func() []Probe, o Options) {
75 }79 }
76 answer := make(chan struct{})80 answer := make(chan struct{})
77 s.answer, s.since = answer, now81 s.answer, s.since = answer, now
78- go func() { p.Ping(); close(answer) }()82+ inflight.Go(func() { p.Ping(); close(answer) })
79 }83 }
80 // A loop that left (an unplugged output) is forgotten; its84 // A loop that left (an unplugged output) is forgotten; its
81 // probe goroutine ends with the loop's context.85 // probe goroutine ends with the loop's context.
M internal/adapters/watchdog/watchdog_test.go +57 -46
@@ -5,8 +5,9 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8- "sync"
8+ "sync/atomic"
99 "testing"
10+ "testing/synctest"
1011 "time"
1112
1213 "github.com/bnema/zerowrap"
@@ -14,53 +15,63 @@ import (
1415
1516 // stuckOwnerLoop blocks like an owner goroutine that never reaches its
1617 // select again; the dump must name it.
17-func stuckOwnerLoop(release <-chan struct{}) { <-release }
18+func stuckOwnerLoop(ctx context.Context, release <-chan struct{}) {
19+ select {
20+ case <-release:
21+ case <-ctx.Done():
22+ }
23+}
1824
1925 func TestStalledLoopIsDumpedOnce(t *testing.T) {
2026 dir := t.TempDir()
21- release := make(chan struct{})
22- var mu sync.Mutex
23- answered := 0
24- probes := func() []Probe {
25- return []Probe{
26- {Name: "stuck", Ping: func() { stuckOwnerLoop(release) }},
27- {Name: "fine", Ping: func() { mu.Lock(); answered++; mu.Unlock() }},
27+ synctest.Test(t, func(t *testing.T) {
28+ ctx, cancel := context.WithCancel(context.Background())
29+ release := make(chan struct{})
30+ var answered atomic.Int32
31+ probes := func() []Probe {
32+ return []Probe{
33+ {Name: "stuck", Ping: func() { stuckOwnerLoop(ctx, release) }},
34+ {Name: "fine", Ping: func() { answered.Add(1) }},
35+ }
2836 }
29- }
30- ctx, cancel := context.WithCancel(context.Background())
31- done := make(chan struct{})
32- go func() {
33- defer close(done)
34- Run(ctx, probes, Options{Dir: dir, Every: 5 * time.Millisecond, Timeout: 30 * time.Millisecond, Log: zerowrap.Default()})
35- }()
36- deadline := time.Now().Add(2 * time.Second)
37- var dumps []string
38- for len(dumps) == 0 && time.Now().Before(deadline) {
39- time.Sleep(5 * time.Millisecond)
40- dumps, _ = filepath.Glob(filepath.Join(dir, "stall-*-stuck.txt"))
41- }
42- if len(dumps) != 1 {
43- t.Fatalf("dumps %v", dumps)
44- }
45- // Still stuck: no second dump.
46- time.Sleep(100 * time.Millisecond)
47- if again, _ := filepath.Glob(filepath.Join(dir, "stall-*.txt")); len(again) != 1 {
48- t.Fatalf("dumps after the first %v", again)
49- }
50- data, err := os.ReadFile(dumps[0])
51- if err != nil {
52- t.Fatal(err)
53- }
54- if !strings.Contains(string(data), "stuckOwnerLoop") {
55- t.Fatalf("dump does not name the stuck function:\n%s", data)
56- }
57- mu.Lock()
58- n := answered
59- mu.Unlock()
60- if n < 2 {
61- t.Fatalf("a healthy loop was probed %d times", n)
62- }
63- close(release)
64- cancel()
65- <-done
37+ done := make(chan struct{})
38+ go func() {
39+ defer close(done)
40+ Run(ctx, probes, Options{Dir: dir, Every: 10 * time.Millisecond, Timeout: 50 * time.Millisecond, Log: zerowrap.Default()})
41+ }()
42+ dumps := func() []string {
43+ m, _ := filepath.Glob(filepath.Join(dir, "stall-*.txt"))
44+ return m
45+ }
46+ time.Sleep(40 * time.Millisecond)
47+ synctest.Wait()
48+ if d := dumps(); len(d) != 0 {
49+ t.Fatalf("dumped before the timeout: %v", d)
50+ }
51+ time.Sleep(30 * time.Millisecond)
52+ synctest.Wait()
53+ d := dumps()
54+ if len(d) != 1 || !strings.HasSuffix(d[0], "-stuck.txt") {
55+ t.Fatalf("dumps %v", d)
56+ }
57+ // Still stuck: no second dump.
58+ time.Sleep(time.Second)
59+ synctest.Wait()
60+ if again := dumps(); len(again) != 1 {
61+ t.Fatalf("dumps after the first %v", again)
62+ }
63+ data, err := os.ReadFile(d[0])
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ if !strings.Contains(string(data), "stuckOwnerLoop") {
68+ t.Fatalf("dump does not name the stuck function:\n%s", data)
69+ }
70+ if n := answered.Load(); n < 50 {
71+ t.Fatalf("a healthy loop was probed %d times in 1 s", n)
72+ }
73+ // Run waits for its probes: a stuck one ends with ctx.
74+ cancel()
75+ <-done
76+ })
6677 }
@@ -5,8 +5,9 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8- "sync"8+ "sync/atomic"
9 "testing"9 "testing"
10+ "testing/synctest"
10 "time"11 "time"
11 12
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
@@ -14,53 +15,63 @@ import (
14 15
15 // stuckOwnerLoop blocks like an owner goroutine that never reaches its16 // stuckOwnerLoop blocks like an owner goroutine that never reaches its
16 // select again; the dump must name it.17 // select again; the dump must name it.
17-func stuckOwnerLoop(release <-chan struct{}) { <-release }18+func stuckOwnerLoop(ctx context.Context, release <-chan struct{}) {
19+ select {
20+ case <-release:
21+ case <-ctx.Done():
22+ }
23+}
18 24
19 func TestStalledLoopIsDumpedOnce(t *testing.T) {25 func TestStalledLoopIsDumpedOnce(t *testing.T) {
20 dir := t.TempDir()26 dir := t.TempDir()
21- release := make(chan struct{})27+ synctest.Test(t, func(t *testing.T) {
22- var mu sync.Mutex28+ ctx, cancel := context.WithCancel(context.Background())
23- answered := 029+ release := make(chan struct{})
24- probes := func() []Probe {30+ var answered atomic.Int32
25- return []Probe{31+ probes := func() []Probe {
26- {Name: "stuck", Ping: func() { stuckOwnerLoop(release) }},32+ return []Probe{
27- {Name: "fine", Ping: func() { mu.Lock(); answered++; mu.Unlock() }},33+ {Name: "stuck", Ping: func() { stuckOwnerLoop(ctx, release) }},
34+ {Name: "fine", Ping: func() { answered.Add(1) }},
35+ }
28 }36 }
29- }37+ done := make(chan struct{})
30- ctx, cancel := context.WithCancel(context.Background())38+ go func() {
31- done := make(chan struct{})39+ defer close(done)
32- go func() {40+ Run(ctx, probes, Options{Dir: dir, Every: 10 * time.Millisecond, Timeout: 50 * time.Millisecond, Log: zerowrap.Default()})
33- defer close(done)41+ }()
34- Run(ctx, probes, Options{Dir: dir, Every: 5 * time.Millisecond, Timeout: 30 * time.Millisecond, Log: zerowrap.Default()})42+ dumps := func() []string {
35- }()43+ m, _ := filepath.Glob(filepath.Join(dir, "stall-*.txt"))
36- deadline := time.Now().Add(2 * time.Second)44+ return m
37- var dumps []string45+ }
38- for len(dumps) == 0 && time.Now().Before(deadline) {46+ time.Sleep(40 * time.Millisecond)
39- time.Sleep(5 * time.Millisecond)47+ synctest.Wait()
40- dumps, _ = filepath.Glob(filepath.Join(dir, "stall-*-stuck.txt"))48+ if d := dumps(); len(d) != 0 {
41- }49+ t.Fatalf("dumped before the timeout: %v", d)
42- if len(dumps) != 1 {50+ }
43- t.Fatalf("dumps %v", dumps)51+ time.Sleep(30 * time.Millisecond)
44- }52+ synctest.Wait()
45- // Still stuck: no second dump.53+ d := dumps()
46- time.Sleep(100 * time.Millisecond)54+ if len(d) != 1 || !strings.HasSuffix(d[0], "-stuck.txt") {
47- if again, _ := filepath.Glob(filepath.Join(dir, "stall-*.txt")); len(again) != 1 {55+ t.Fatalf("dumps %v", d)
48- t.Fatalf("dumps after the first %v", again)56+ }
49- }57+ // Still stuck: no second dump.
50- data, err := os.ReadFile(dumps[0])58+ time.Sleep(time.Second)
51- if err != nil {59+ synctest.Wait()
52- t.Fatal(err)60+ if again := dumps(); len(again) != 1 {
53- }61+ t.Fatalf("dumps after the first %v", again)
54- if !strings.Contains(string(data), "stuckOwnerLoop") {62+ }
55- t.Fatalf("dump does not name the stuck function:\n%s", data)63+ data, err := os.ReadFile(d[0])
56- }64+ if err != nil {
57- mu.Lock()65+ t.Fatal(err)
58- n := answered66+ }
59- mu.Unlock()67+ if !strings.Contains(string(data), "stuckOwnerLoop") {
60- if n < 2 {68+ t.Fatalf("dump does not name the stuck function:\n%s", data)
61- t.Fatalf("a healthy loop was probed %d times", n)69+ }
62- }70+ if n := answered.Load(); n < 50 {
63- close(release)71+ t.Fatalf("a healthy loop was probed %d times in 1 s", n)
64- cancel()72+ }
65- <-done73+ // Run waits for its probes: a stuck one ends with ctx.
74+ cancel()
75+ <-done
76+ })
66 }77 }
M internal/adapters/wayland/probe.go +13 -3
@@ -1,5 +1,15 @@
11 package wayland
22
3-// Ping returns once the display loop ran an empty task, or once the display
4-// stopped. The watchdog uses it to detect a stuck display loop.
5-func (s *Server) Ping() { s.display.Do(func() {}) }
3+import "context"
4+
5+// Ping returns once the display loop ran an empty task, the display
6+// stopped or ctx ended. The watchdog uses it to detect a stuck display
7+// loop; a stuck loop leaves only the inner Do goroutine behind.
8+func (s *Server) Ping(ctx context.Context) {
9+ done := make(chan struct{})
10+ go func() { s.display.Do(func() {}); close(done) }()
11+ select {
12+ case <-done:
13+ case <-ctx.Done():
14+ }
15+}
@@ -1,5 +1,15 @@
1 package wayland1 package wayland
2 2
3-// Ping returns once the display loop ran an empty task, or once the display3+import "context"
4-// stopped. The watchdog uses it to detect a stuck display loop.4+
5-func (s *Server) Ping() { s.display.Do(func() {}) }5+// Ping returns once the display loop ran an empty task, the display
6+// stopped or ctx ended. The watchdog uses it to detect a stuck display
7+// loop; a stuck loop leaves only the inner Do goroutine behind.
8+func (s *Server) Ping(ctx context.Context) {
9+ done := make(chan struct{})
10+ go func() { s.display.Do(func() {}); close(done) }()
11+ select {
12+ case <-done:
13+ case <-ctx.Done():
14+ }
15+}
M internal/app/drm.go +8 -11
@@ -122,14 +122,14 @@ type drmBackend struct {
122122 preparers map[seatDisablePreparer]struct{}
123123 // probes are the running output loops the watchdog pings, by
124124 // connector, guarded by mu.
125- probes map[string]func(context.Context)
125+ probes map[string]func()
126126 }
127127
128-func (b *drmBackend) addProbe(name string, ping func(context.Context)) {
128+func (b *drmBackend) addProbe(name string, ping func()) {
129129 b.mu.Lock()
130130 defer b.mu.Unlock()
131131 if b.probes == nil {
132- b.probes = map[string]func(context.Context){}
132+ b.probes = map[string]func(){}
133133 }
134134 b.probes[name] = ping
135135 }
@@ -141,12 +141,12 @@ func (b *drmBackend) removeProbe(name string) {
141141 }
142142
143143 // watchdogProbes lists the running output loops for the watchdog.
144-func (b *drmBackend) watchdogProbes(ctx context.Context) []watchdog.Probe {
144+func (b *drmBackend) watchdogProbes() []watchdog.Probe {
145145 b.mu.Lock()
146146 defer b.mu.Unlock()
147147 out := make([]watchdog.Probe, 0, len(b.probes))
148148 for name, ping := range b.probes {
149- out = append(out, watchdog.Probe{Name: "output-" + name, Ping: func() { ping(ctx) }})
149+ out = append(out, watchdog.Probe{Name: "output-" + name, Ping: ping})
150150 }
151151 return out
152152 }
@@ -520,12 +520,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
520520 defer o.Close()
521521 b.addPreparer(o)
522522 defer b.removePreparer(o) // before Close: it handles no request any more
523- b.addProbe(name, func(pctx context.Context) {
524- pctx, stop := context.WithCancel(pctx)
525- defer stop()
526- context.AfterFunc(octx, stop) // the probe ends with this output
527- o.Ping(pctx)
528- })
523+ // octx derives from the session: the probe ends with this
524+ // output or the session.
525+ b.addProbe(name, func() { o.Ping(octx) })
529526 defer b.removeProbe(name)
530527 if realtime {
531528 runtime.LockOSThread()
@@ -122,14 +122,14 @@ type drmBackend struct {
122 preparers map[seatDisablePreparer]struct{}122 preparers map[seatDisablePreparer]struct{}
123 // probes are the running output loops the watchdog pings, by123 // probes are the running output loops the watchdog pings, by
124 // connector, guarded by mu.124 // connector, guarded by mu.
125- probes map[string]func(context.Context)125+ probes map[string]func()
126 }126 }
127 127
128-func (b *drmBackend) addProbe(name string, ping func(context.Context)) {128+func (b *drmBackend) addProbe(name string, ping func()) {
129 b.mu.Lock()129 b.mu.Lock()
130 defer b.mu.Unlock()130 defer b.mu.Unlock()
131 if b.probes == nil {131 if b.probes == nil {
132- b.probes = map[string]func(context.Context){}132+ b.probes = map[string]func(){}
133 }133 }
134 b.probes[name] = ping134 b.probes[name] = ping
135 }135 }
@@ -141,12 +141,12 @@ func (b *drmBackend) removeProbe(name string) {
141 }141 }
142 142
143 // watchdogProbes lists the running output loops for the watchdog.143 // watchdogProbes lists the running output loops for the watchdog.
144-func (b *drmBackend) watchdogProbes(ctx context.Context) []watchdog.Probe {144+func (b *drmBackend) watchdogProbes() []watchdog.Probe {
145 b.mu.Lock()145 b.mu.Lock()
146 defer b.mu.Unlock()146 defer b.mu.Unlock()
147 out := make([]watchdog.Probe, 0, len(b.probes))147 out := make([]watchdog.Probe, 0, len(b.probes))
148 for name, ping := range b.probes {148 for name, ping := range b.probes {
149- out = append(out, watchdog.Probe{Name: "output-" + name, Ping: func() { ping(ctx) }})149+ out = append(out, watchdog.Probe{Name: "output-" + name, Ping: ping})
150 }150 }
151 return out151 return out
152 }152 }
@@ -520,12 +520,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
520 defer o.Close()520 defer o.Close()
521 b.addPreparer(o)521 b.addPreparer(o)
522 defer b.removePreparer(o) // before Close: it handles no request any more522 defer b.removePreparer(o) // before Close: it handles no request any more
523- b.addProbe(name, func(pctx context.Context) {523+ // octx derives from the session: the probe ends with this
524- pctx, stop := context.WithCancel(pctx)524+ // output or the session.
525- defer stop()525+ b.addProbe(name, func() { o.Ping(octx) })
526- context.AfterFunc(octx, stop) // the probe ends with this output
527- o.Ping(pctx)
528- })
529 defer b.removeProbe(name)526 defer b.removeProbe(name)
530 if realtime {527 if realtime {
531 runtime.LockOSThread()528 runtime.LockOSThread()
M internal/app/session.go +3 -3
@@ -309,14 +309,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
309309 defer workers.Done()
310310 watchdog.Run(ctx, func() []watchdog.Probe {
311311 probes := []watchdog.Probe{
312- {Name: "wayland", Ping: server.Ping},
312+ {Name: "wayland", Ping: func() { server.Ping(ctx) }},
313313 {Name: "core", Ping: func() { c.Ping(ctx) }},
314314 }
315315 if hw != nil {
316- probes = append(probes, hw.watchdogProbes(ctx)...)
316+ probes = append(probes, hw.watchdogProbes()...)
317317 }
318318 return probes
319- }, watchdog.Options{Dir: dir, Every: 500 * time.Millisecond, Timeout: 2 * time.Second, Log: logging.For(ctx, "app")})
319+ }, watchdog.Options{Dir: dir, Every: time.Second, Timeout: 3 * time.Second, Log: logging.For(ctx, "app")})
320320 }()
321321 }
322322 go func() {
@@ -309,14 +309,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
309 defer workers.Done()309 defer workers.Done()
310 watchdog.Run(ctx, func() []watchdog.Probe {310 watchdog.Run(ctx, func() []watchdog.Probe {
311 probes := []watchdog.Probe{311 probes := []watchdog.Probe{
312- {Name: "wayland", Ping: server.Ping},312+ {Name: "wayland", Ping: func() { server.Ping(ctx) }},
313 {Name: "core", Ping: func() { c.Ping(ctx) }},313 {Name: "core", Ping: func() { c.Ping(ctx) }},
314 }314 }
315 if hw != nil {315 if hw != nil {
316- probes = append(probes, hw.watchdogProbes(ctx)...)316+ probes = append(probes, hw.watchdogProbes()...)
317 }317 }
318 return probes318 return probes
319- }, watchdog.Options{Dir: dir, Every: 500 * time.Millisecond, Timeout: 2 * time.Second, Log: logging.For(ctx, "app")})319+ }, watchdog.Options{Dir: dir, Every: time.Second, Timeout: 3 * time.Second, Log: logging.For(ctx, "app")})
320 }()320 }()
321 }321 }
322 go func() {322 go func() {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close