detached aa250ab

fix(idle): avoid portal signal cleanup crash

commit
aa250abd7a7b191ee5a95a773c1e0fbbd3ec4d59
author
brice · · 2026-06-06 05:43 UTC
committer
brice · · 2026-06-06 05:57 UTC
parent
88f46cc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +113 -6
M .github/workflows/ci.yml +1 -1
@@ -83,7 +83,7 @@ jobs:
8383 - name: Install dependencies
8484 run: |
8585 pacman -Syu --noconfirm
86- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli
86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
8787 - uses: actions/checkout@v6
8888 - name: Cache Go modules
8989 uses: actions/cache@v5
@@ -83,7 +83,7 @@ jobs:
83 - name: Install dependencies83 - name: Install dependencies
84 run: |84 run: |
85 pacman -Syu --noconfirm85 pacman -Syu --noconfirm
86- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
87 - uses: actions/checkout@v687 - uses: actions/checkout@v6
88 - name: Cache Go modules88 - name: Cache Go modules
89 uses: actions/cache@v589 uses: actions/cache@v5
M internal/infrastructure/idle/portal_inhibitor.go +8 -5
@@ -135,7 +135,10 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
135135 func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {
136136 log := logging.FromContext(ctx)
137137
138- if p.conn == nil {
138+ p.mu.Lock()
139+ conn := p.conn
140+ p.mu.Unlock()
141+ if conn == nil {
139142 return
140143 }
141144
@@ -145,18 +148,18 @@ func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.
145148 requestIface, handlePath,
146149 )
147150
148- if err := p.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
151+ if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
149152 log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
150153 return
151154 }
152155
153156 // Create a channel to receive signals
154157 signals := make(chan *dbus.Signal, 1)
155- p.conn.Signal(signals)
158+ conn.Signal(signals)
156159
157160 defer func() {
158- p.conn.RemoveSignal(signals)
159- _ = p.conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
161+ conn.RemoveSignal(signals)
162+ _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
160163 }()
161164
162165 // Wait for either a Response signal or context cancellation
@@ -135,7 +135,10 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
135 func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {135 func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {
136 log := logging.FromContext(ctx)136 log := logging.FromContext(ctx)
137 137
138- if p.conn == nil {138+ p.mu.Lock()
139+ conn := p.conn
140+ p.mu.Unlock()
141+ if conn == nil {
139 return142 return
140 }143 }
141 144
@@ -145,18 +148,18 @@ func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.
145 requestIface, handlePath,148 requestIface, handlePath,
146 )149 )
147 150
148- if err := p.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {151+ if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
149 log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")152 log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
150 return153 return
151 }154 }
152 155
153 // Create a channel to receive signals156 // Create a channel to receive signals
154 signals := make(chan *dbus.Signal, 1)157 signals := make(chan *dbus.Signal, 1)
155- p.conn.Signal(signals)158+ conn.Signal(signals)
156 159
157 defer func() {160 defer func() {
158- p.conn.RemoveSignal(signals)161+ conn.RemoveSignal(signals)
159- _ = p.conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err162+ _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
160 }()163 }()
161 164
162 // Wait for either a Response signal or context cancellation165 // Wait for either a Response signal or context cancellation
A internal/infrastructure/idle/portal_inhibitor_test.go +104 -0
@@ -0,0 +1,104 @@
1+package idle
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "os/exec"
8+ "strings"
9+ "sync"
10+ "testing"
11+
12+ "github.com/godbus/dbus/v5"
13+)
14+
15+func TestWatchForResponseCleanupSurvivesConnFieldCleared(t *testing.T) {
16+ // clearConnOnDoneContext clears inhibitor.conn after watchForResponse has
17+ // installed its cleanup defer; the test fails if cleanup dereferences the
18+ // mutable field instead of the D-Bus connection snapshot.
19+ conn := connectPrivateSessionBus(t)
20+ defer conn.Close()
21+
22+ inhibitor := &PortalInhibitor{conn: conn}
23+ ctx := &clearConnOnDoneContext{
24+ Context: context.Background(),
25+ done: closedDoneChannel(),
26+ clear: func() {
27+ inhibitor.mu.Lock()
28+ inhibitor.conn = nil
29+ inhibitor.mu.Unlock()
30+ },
31+ }
32+
33+ inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")
34+
35+ inhibitor.mu.Lock()
36+ connCleared := inhibitor.conn == nil
37+ inhibitor.mu.Unlock()
38+ if !connCleared {
39+ t.Fatal("watchForResponse returned before observing context cancellation")
40+ }
41+}
42+
43+func connectPrivateSessionBus(t *testing.T) *dbus.Conn {
44+ t.Helper()
45+
46+ address := startPrivateSessionBus(t)
47+ conn, err := dbus.Connect(address)
48+ if err != nil {
49+ t.Fatalf("connect to private D-Bus session bus: %v", err)
50+ }
51+ return conn
52+}
53+
54+func startPrivateSessionBus(t *testing.T) string {
55+ t.Helper()
56+
57+ if _, err := exec.LookPath("dbus-daemon"); err != nil {
58+ t.Skipf("dbus-daemon unavailable: %v", err)
59+ }
60+
61+ var stderr bytes.Buffer
62+ cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")
63+ cmd.Stderr = &stderr
64+
65+ stdout, err := cmd.StdoutPipe()
66+ if err != nil {
67+ t.Fatalf("open dbus-daemon stdout: %v", err)
68+ }
69+ err = cmd.Start()
70+ if err != nil {
71+ t.Fatalf("start dbus-daemon: %v", err)
72+ }
73+
74+ t.Cleanup(func() {
75+ if cmd.Process != nil {
76+ _ = cmd.Process.Kill()
77+ }
78+ _ = cmd.Wait()
79+ })
80+
81+ address, err := bufio.NewReader(stdout).ReadString('\n')
82+ if err != nil {
83+ t.Fatalf("read dbus-daemon address: %v; stderr: %s", err, stderr.String())
84+ }
85+ return strings.TrimSpace(address)
86+}
87+
88+type clearConnOnDoneContext struct {
89+ context.Context
90+ done <-chan struct{}
91+ once sync.Once
92+ clear func()
93+}
94+
95+func (c *clearConnOnDoneContext) Done() <-chan struct{} {
96+ c.once.Do(c.clear)
97+ return c.done
98+}
99+
100+func closedDoneChannel() <-chan struct{} {
101+ done := make(chan struct{})
102+ close(done)
103+ return done
104+}
@@ -0,0 +1,104 @@
1+package idle
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "os/exec"
8+ "strings"
9+ "sync"
10+ "testing"
11+
12+ "github.com/godbus/dbus/v5"
13+)
14+
15+func TestWatchForResponseCleanupSurvivesConnFieldCleared(t *testing.T) {
16+ // clearConnOnDoneContext clears inhibitor.conn after watchForResponse has
17+ // installed its cleanup defer; the test fails if cleanup dereferences the
18+ // mutable field instead of the D-Bus connection snapshot.
19+ conn := connectPrivateSessionBus(t)
20+ defer conn.Close()
21+
22+ inhibitor := &PortalInhibitor{conn: conn}
23+ ctx := &clearConnOnDoneContext{
24+ Context: context.Background(),
25+ done: closedDoneChannel(),
26+ clear: func() {
27+ inhibitor.mu.Lock()
28+ inhibitor.conn = nil
29+ inhibitor.mu.Unlock()
30+ },
31+ }
32+
33+ inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")
34+
35+ inhibitor.mu.Lock()
36+ connCleared := inhibitor.conn == nil
37+ inhibitor.mu.Unlock()
38+ if !connCleared {
39+ t.Fatal("watchForResponse returned before observing context cancellation")
40+ }
41+}
42+
43+func connectPrivateSessionBus(t *testing.T) *dbus.Conn {
44+ t.Helper()
45+
46+ address := startPrivateSessionBus(t)
47+ conn, err := dbus.Connect(address)
48+ if err != nil {
49+ t.Fatalf("connect to private D-Bus session bus: %v", err)
50+ }
51+ return conn
52+}
53+
54+func startPrivateSessionBus(t *testing.T) string {
55+ t.Helper()
56+
57+ if _, err := exec.LookPath("dbus-daemon"); err != nil {
58+ t.Skipf("dbus-daemon unavailable: %v", err)
59+ }
60+
61+ var stderr bytes.Buffer
62+ cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")
63+ cmd.Stderr = &stderr
64+
65+ stdout, err := cmd.StdoutPipe()
66+ if err != nil {
67+ t.Fatalf("open dbus-daemon stdout: %v", err)
68+ }
69+ err = cmd.Start()
70+ if err != nil {
71+ t.Fatalf("start dbus-daemon: %v", err)
72+ }
73+
74+ t.Cleanup(func() {
75+ if cmd.Process != nil {
76+ _ = cmd.Process.Kill()
77+ }
78+ _ = cmd.Wait()
79+ })
80+
81+ address, err := bufio.NewReader(stdout).ReadString('\n')
82+ if err != nil {
83+ t.Fatalf("read dbus-daemon address: %v; stderr: %s", err, stderr.String())
84+ }
85+ return strings.TrimSpace(address)
86+}
87+
88+type clearConnOnDoneContext struct {
89+ context.Context
90+ done <-chan struct{}
91+ once sync.Once
92+ clear func()
93+}
94+
95+func (c *clearConnOnDoneContext) Done() <-chan struct{} {
96+ c.once.Do(c.clear)
97+ return c.done
98+}
99+
100+func closedDoneChannel() <-chan struct{} {
101+ done := make(chan struct{})
102+ close(done)
103+ return done
104+}

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 8

Tags 42

↑↓ moveenter openesc close