detached 39fad31

refactor(idle): use zerobus for the portal inhibitor instead of godbus

Make every portal call synchronously under the inhibitor mutex, since a
zerobus connection is not safe for concurrent use. This drops the
Response-signal watcher goroutine: closing a request the portal already
ended only returns an ignored error.

Tests run against a fake portal on a private dbus-daemon that cannot
auto-start host services.
commit
39fad31509a7add8f9993eabcdf4b9edc98f8074
author
brice · · 2026-10-14 09:38 UTC
parent
998b312
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +315 -181
M go.mod +1 -1
@@ -12,11 +12,11 @@ require (
1212 github.com/bnema/purego-sqlite v0.1.5
1313 github.com/bnema/purego-webp v0.2.1
1414 github.com/bnema/puregotk v0.7.6
15+ github.com/bnema/zerobus v0.1.1
1516 github.com/charmbracelet/bubbles v1.0.0
1617 github.com/charmbracelet/bubbletea v1.3.10
1718 github.com/charmbracelet/lipgloss v1.1.0
1819 github.com/fsnotify/fsnotify v1.10.1
19- github.com/godbus/dbus/v5 v5.2.4
2020 github.com/pelletier/go-toml/v2 v2.4.3
2121 github.com/pressly/goose/v3 v3.29.0
2222 github.com/rs/zerolog v1.35.1
@@ -12,11 +12,11 @@ require (
12 github.com/bnema/purego-sqlite v0.1.512 github.com/bnema/purego-sqlite v0.1.5
13 github.com/bnema/purego-webp v0.2.113 github.com/bnema/purego-webp v0.2.1
14 github.com/bnema/puregotk v0.7.614 github.com/bnema/puregotk v0.7.6
15+ github.com/bnema/zerobus v0.1.1
15 github.com/charmbracelet/bubbles v1.0.016 github.com/charmbracelet/bubbles v1.0.0
16 github.com/charmbracelet/bubbletea v1.3.1017 github.com/charmbracelet/bubbletea v1.3.10
17 github.com/charmbracelet/lipgloss v1.1.018 github.com/charmbracelet/lipgloss v1.1.0
18 github.com/fsnotify/fsnotify v1.10.119 github.com/fsnotify/fsnotify v1.10.1
19- github.com/godbus/dbus/v5 v5.2.4
20 github.com/pelletier/go-toml/v2 v2.4.320 github.com/pelletier/go-toml/v2 v2.4.3
21 github.com/pressly/goose/v3 v3.29.021 github.com/pressly/goose/v3 v3.29.0
22 github.com/rs/zerolog v1.35.122 github.com/rs/zerolog v1.35.1
M go.sum +2 -2
@@ -24,6 +24,8 @@ github.com/bnema/purego-webp v0.2.1 h1:/4TU7++xyc6WnHzqRPg5bYYiIoKnxYaf/7haau0q5
2424 github.com/bnema/purego-webp v0.2.1/go.mod h1:TYOQvozzcPup3zJQ58YmiCB4MkroeiyvvTP/MGWm+zI=
2525 github.com/bnema/puregotk v0.7.6 h1:++v/8UxwkfvbljswDU4rbxr+Nyix0lmXCvWpQt8srjo=
2626 github.com/bnema/puregotk v0.7.6/go.mod h1:BLcNL9jse2WqX3bRZCVhx4scE9xzWT26OazdHFzL5mo=
27+github.com/bnema/zerobus v0.1.1 h1:N17A0XtvvVfneXHC1NHwSOL0fHbn7xDOP+rw3LZw8NE=
28+github.com/bnema/zerobus v0.1.1/go.mod h1:QWo6NcbIyic3+gcqwB3IzZh8dIEdXFfw7WdhjNPITYM=
2729 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
2830 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
2931 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -63,8 +65,6 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
6365 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
6466 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
6567 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
66-github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
67-github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
6868 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6969 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7070 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -24,6 +24,8 @@ github.com/bnema/purego-webp v0.2.1 h1:/4TU7++xyc6WnHzqRPg5bYYiIoKnxYaf/7haau0q5
24 github.com/bnema/purego-webp v0.2.1/go.mod h1:TYOQvozzcPup3zJQ58YmiCB4MkroeiyvvTP/MGWm+zI=24 github.com/bnema/purego-webp v0.2.1/go.mod h1:TYOQvozzcPup3zJQ58YmiCB4MkroeiyvvTP/MGWm+zI=
25 github.com/bnema/puregotk v0.7.6 h1:++v/8UxwkfvbljswDU4rbxr+Nyix0lmXCvWpQt8srjo=25 github.com/bnema/puregotk v0.7.6 h1:++v/8UxwkfvbljswDU4rbxr+Nyix0lmXCvWpQt8srjo=
26 github.com/bnema/puregotk v0.7.6/go.mod h1:BLcNL9jse2WqX3bRZCVhx4scE9xzWT26OazdHFzL5mo=26 github.com/bnema/puregotk v0.7.6/go.mod h1:BLcNL9jse2WqX3bRZCVhx4scE9xzWT26OazdHFzL5mo=
27+github.com/bnema/zerobus v0.1.1 h1:N17A0XtvvVfneXHC1NHwSOL0fHbn7xDOP+rw3LZw8NE=
28+github.com/bnema/zerobus v0.1.1/go.mod h1:QWo6NcbIyic3+gcqwB3IzZh8dIEdXFfw7WdhjNPITYM=
27 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=29 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
28 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=30 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
29 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=31 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -63,8 +65,6 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
63 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=65 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
64 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=66 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
65 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=67 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
66-github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
67-github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
68 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=68 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
69 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=69 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
70 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=70 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
M internal/infrastructure/idle/portal_inhibitor.go +83 -134
@@ -4,11 +4,12 @@ package idle
44 import (
55 "context"
66 "fmt"
7+ "strings"
78 "sync"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
11- "github.com/godbus/dbus/v5"
12+ "github.com/bnema/zerobus"
1213 )
1314
1415 const (
@@ -16,6 +17,7 @@ const (
1617 portalPath = "/org/freedesktop/portal/desktop"
1718 portalInterface = "org.freedesktop.portal.Inhibit"
1819 requestIface = "org.freedesktop.portal.Request"
20+ propertiesIface = "org.freedesktop.DBus.Properties"
1921
2022 // Inhibit flags from portal spec
2123 flagLogout = 1
@@ -29,46 +31,58 @@ var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
2931
3032 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
3133 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
34+//
35+// A zerobus connection is not safe for concurrent use, so every bus call
36+// runs under mu. Calls drop the signals that arrive meanwhile.
3237 type PortalInhibitor struct {
33- conn *dbus.Conn
34- requestPath dbus.ObjectPath // Active inhibit request handle
35- refcount int
36- supported bool
37- requestComplete bool // True if the portal sent a Response signal (request no longer exists)
38- mu sync.Mutex
38+ mu sync.Mutex
39+ conn *zerobus.Conn // nil when the portal is unavailable or after Close
40+ requestPath string // active inhibit request handle
41+ refcount int
3942 }
4043
4144 // NewPortalInhibitor creates a new portal-based idle inhibitor.
4245 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
4346 func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
44- log := logging.FromContext(ctx)
45-
46- inhibitor := &PortalInhibitor{
47- supported: false,
48- }
49-
50- // Connect to session bus
51- conn, err := dbus.ConnectSessionBus()
47+ conn, err := zerobus.SessionBus()
5248 if err != nil {
53- log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
54- return inhibitor
49+ logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
50+ return &PortalInhibitor{}
5551 }
56- inhibitor.conn = conn
52+ return newPortalInhibitor(ctx, conn)
53+}
5754
58- // Check if portal is available
59- obj := conn.Object(portalDest, portalPath)
60- var version uint32
61- err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
62- portalInterface, "version").Store(&version)
55+// newPortalInhibitor takes ownership of conn and keeps it only if the portal
56+// answers on it.
57+func newPortalInhibitor(ctx context.Context, conn *zerobus.Conn) *PortalInhibitor {
58+ log := logging.FromContext(ctx)
59+
60+ version, err := portalVersion(conn)
6361 if err != nil {
6462 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
65- return inhibitor
63+ _ = conn.Close()
64+ return &PortalInhibitor{}
6665 }
6766
68- inhibitor.supported = true
6967 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68+ return &PortalInhibitor{conn: conn}
69+}
7070
71- return inhibitor
71+// portalVersion reads the version property of the Inhibit portal.
72+func portalVersion(conn *zerobus.Conn) (uint32, error) {
73+ e := conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
74+ e.Str(portalInterface)
75+ e.Str("version")
76+ m, err := conn.Call()
77+ if err != nil {
78+ return 0, err
79+ }
80+ body := m.Body()
81+ if sig := body.Variant(); sig != "u" {
82+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
83+ }
84+ version := body.Uint32()
85+ return version, body.Err()
7286 }
7387
7488 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -86,102 +100,51 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
86100 return nil
87101 }
88102
89- // First inhibit request
90- if !p.supported || p.conn == nil {
103+ if p.conn == nil {
91104 log.Debug().Msg("idle inhibitor: not supported, skipping")
92105 return nil
93106 }
94107
95- // Call portal Inhibit method
96- // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
97- obj := p.conn.Object(portalDest, portalPath)
98-
99- options := map[string]dbus.Variant{
100- "reason": dbus.MakeVariant(reason),
101- }
102-
103- var handlePath dbus.ObjectPath
104- err := obj.Call(portalInterface+".Inhibit", 0,
105- "", // window identifier (empty for non-sandboxed)
106- uint32(flagIdle|flagSuspend), // inhibit idle and suspend
107- options,
108- ).Store(&handlePath)
109-
108+ handle, err := p.callInhibit(reason)
110109 if err != nil {
111110 p.refcount--
112111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
113112 return fmt.Errorf("portal inhibit: %w", err)
114113 }
115-
116- p.requestPath = handlePath
117- p.requestComplete = false
118-
119- // Subscribe to the Response signal to know when the inhibition ends
120- // Some portals complete the request immediately with a Response signal
121- go p.watchForResponse(ctx, handlePath)
114+ p.requestPath = handle
122115
123116 log.Info().
124- Str("handle", string(handlePath)).
117+ Str("handle", handle).
125118 Str("reason", reason).
126119 Msg("idle inhibitor: activated")
127120
128121 return nil
129122 }
130123
131-// watchForResponse monitors for the Response signal on the request object.
132-// Some portals (particularly GNOME) complete the Inhibit request immediately,
133-// which removes the Request object. We need to track this to avoid calling
134-// Close on a non-existent object.
135-func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {
136- log := logging.FromContext(ctx)
137-
138- p.mu.Lock()
139- conn := p.conn
140- p.mu.Unlock()
141- if conn == nil {
142- return
143- }
144-
145- // Add match rule for the Response signal on this specific request
146- matchRule := fmt.Sprintf(
147- "type='signal',interface='%s',member='Response',path='%s'",
148- requestIface, handlePath,
149- )
150-
151- if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
152- log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
153- return
124+// callInhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
125+// and returns the request handle.
126+func (p *PortalInhibitor) callInhibit(reason string) (string, error) {
127+ e := p.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
128+ e.Str("") // window identifier (empty for non-sandboxed)
129+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
130+ options := e.BeginArray('{')
131+ e.Struct()
132+ e.Str("reason")
133+ e.Variant("s")
134+ e.Str(reason)
135+ e.EndArray(options)
136+
137+ m, err := p.conn.Call()
138+ if err != nil {
139+ return "", err
154140 }
155-
156- // Create a channel to receive signals
157- signals := make(chan *dbus.Signal, 1)
158- conn.Signal(signals)
159-
160- defer func() {
161- conn.RemoveSignal(signals)
162- _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
163- }()
164-
165- // Wait for either a Response signal or context cancellation
166- for {
167- select {
168- case sig := <-signals:
169- if sig == nil {
170- return
171- }
172- if sig.Path == handlePath && sig.Name == requestIface+".Response" {
173- p.mu.Lock()
174- p.requestComplete = true
175- p.mu.Unlock()
176- log.Debug().
177- Str("handle", string(handlePath)).
178- Msg("idle inhibitor: request completed by portal")
179- return
180- }
181- case <-ctx.Done():
182- return
183- }
141+ body := m.Body()
142+ // The reply points into the receive buffer: copy the handle we keep.
143+ handle := strings.Clone(body.ObjectPath())
144+ if err := body.Err(); err != nil {
145+ return "", err
184146 }
147+ return handle, nil
185148 }
186149
187150 // Uninhibit decrements the refcount. When zero, releases inhibition.
@@ -199,30 +162,23 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
199162 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
200163
201164 // Still have active inhibitors
202- if p.refcount > 0 {
165+ if p.refcount > 0 || p.requestPath == "" {
203166 return nil
204167 }
205168
206- // Release the inhibition
207- if !p.supported || p.conn == nil || p.requestPath == "" {
208- return nil
209- }
169+ p.closeRequest()
170+ log.Info().Msg("idle inhibitor: deactivated")
171+ return nil
172+}
210173
211- // If the portal already completed the request with a Response signal,
212- // the Request object no longer exists - don't try to close it
213- if p.requestComplete {
214- log.Info().Msg("idle inhibitor: deactivated (completed by portal)")
215- p.requestPath = ""
216- return nil
174+// closeRequest releases the active inhibit request. A portal that already
175+// ended the request answers with an error, which is ignored.
176+func (p *PortalInhibitor) closeRequest() {
177+ if p.conn != nil && p.requestPath != "" {
178+ p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")
179+ _, _ = p.conn.Call()
217180 }
218-
219- // Close the request to release inhibition
220- obj := p.conn.Object(portalDest, p.requestPath)
221- _ = obj.Call(requestIface+".Close", 0).Err
222-
223- log.Info().Msg("idle inhibitor: deactivated")
224181 p.requestPath = ""
225- return nil
226182 }
227183
228184 // IsInhibited returns true if currently inhibiting idle.
@@ -237,20 +193,13 @@ func (p *PortalInhibitor) Close() error {
237193 p.mu.Lock()
238194 defer p.mu.Unlock()
239195
240- // Release any active inhibition (only if not already completed by portal)
241- if p.conn != nil && p.requestPath != "" && !p.requestComplete {
242- obj := p.conn.Object(portalDest, p.requestPath)
243- _ = obj.Call(requestIface+".Close", 0).Err
244- }
245- p.requestPath = ""
246- p.requestComplete = false
196+ p.closeRequest()
247197 p.refcount = 0
248198
249- if p.conn != nil {
250- err := p.conn.Close()
251- p.conn = nil
252- return err
199+ if p.conn == nil {
200+ return nil
253201 }
254-
255- return nil
202+ err := p.conn.Close()
203+ p.conn = nil
204+ return err
256205 }
@@ -4,11 +4,12 @@ package idle
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ "strings"
7 "sync"8 "sync"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
11- "github.com/godbus/dbus/v5"12+ "github.com/bnema/zerobus"
12 )13 )
13 14
14 const (15 const (
@@ -16,6 +17,7 @@ const (
16 portalPath = "/org/freedesktop/portal/desktop"17 portalPath = "/org/freedesktop/portal/desktop"
17 portalInterface = "org.freedesktop.portal.Inhibit"18 portalInterface = "org.freedesktop.portal.Inhibit"
18 requestIface = "org.freedesktop.portal.Request"19 requestIface = "org.freedesktop.portal.Request"
20+ propertiesIface = "org.freedesktop.DBus.Properties"
19 21
20 // Inhibit flags from portal spec22 // Inhibit flags from portal spec
21 flagLogout = 123 flagLogout = 1
@@ -29,46 +31,58 @@ var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29 31
30 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.32 // PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).33 // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
34+//
35+// A zerobus connection is not safe for concurrent use, so every bus call
36+// runs under mu. Calls drop the signals that arrive meanwhile.
32 type PortalInhibitor struct {37 type PortalInhibitor struct {
33- conn *dbus.Conn38+ mu sync.Mutex
34- requestPath dbus.ObjectPath // Active inhibit request handle39+ conn *zerobus.Conn // nil when the portal is unavailable or after Close
35- refcount int40+ requestPath string // active inhibit request handle
36- supported bool41+ refcount int
37- requestComplete bool // True if the portal sent a Response signal (request no longer exists)
38- mu sync.Mutex
39 }42 }
40 43
41 // NewPortalInhibitor creates a new portal-based idle inhibitor.44 // NewPortalInhibitor creates a new portal-based idle inhibitor.
42 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).45 // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
43 func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {46 func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
44- log := logging.FromContext(ctx)47+ conn, err := zerobus.SessionBus()
45-
46- inhibitor := &PortalInhibitor{
47- supported: false,
48- }
49-
50- // Connect to session bus
51- conn, err := dbus.ConnectSessionBus()
52 if err != nil {48 if err != nil {
53- log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")49+ logging.FromContext(ctx).Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
54- return inhibitor50+ return &PortalInhibitor{}
55 }51 }
56- inhibitor.conn = conn52+ return newPortalInhibitor(ctx, conn)
53+}
57 54
58- // Check if portal is available55+// newPortalInhibitor takes ownership of conn and keeps it only if the portal
59- obj := conn.Object(portalDest, portalPath)56+// answers on it.
60- var version uint3257+func newPortalInhibitor(ctx context.Context, conn *zerobus.Conn) *PortalInhibitor {
61- err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,58+ log := logging.FromContext(ctx)
62- portalInterface, "version").Store(&version)59+
60+ version, err := portalVersion(conn)
63 if err != nil {61 if err != nil {
64 log.Debug().Err(err).Msg("idle inhibitor: portal not available")62 log.Debug().Err(err).Msg("idle inhibitor: portal not available")
65- return inhibitor63+ _ = conn.Close()
64+ return &PortalInhibitor{}
66 }65 }
67 66
68- inhibitor.supported = true
69 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")67 log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
68+ return &PortalInhibitor{conn: conn}
69+}
70 70
71- return inhibitor71+// portalVersion reads the version property of the Inhibit portal.
72+func portalVersion(conn *zerobus.Conn) (uint32, error) {
73+ e := conn.NewCall(portalDest, portalPath, propertiesIface, "Get", "ss")
74+ e.Str(portalInterface)
75+ e.Str("version")
76+ m, err := conn.Call()
77+ if err != nil {
78+ return 0, err
79+ }
80+ body := m.Body()
81+ if sig := body.Variant(); sig != "u" {
82+ return 0, fmt.Errorf("portal version has type %q, want \"u\"", sig)
83+ }
84+ version := body.Uint32()
85+ return version, body.Err()
72 }86 }
73 87
74 // Inhibit increments the inhibit refcount. First call activates inhibition.88 // Inhibit increments the inhibit refcount. First call activates inhibition.
@@ -86,102 +100,51 @@ func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
86 return nil100 return nil
87 }101 }
88 102
89- // First inhibit request103+ if p.conn == nil {
90- if !p.supported || p.conn == nil {
91 log.Debug().Msg("idle inhibitor: not supported, skipping")104 log.Debug().Msg("idle inhibitor: not supported, skipping")
92 return nil105 return nil
93 }106 }
94 107
95- // Call portal Inhibit method108+ handle, err := p.callInhibit(reason)
96- // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
97- obj := p.conn.Object(portalDest, portalPath)
98-
99- options := map[string]dbus.Variant{
100- "reason": dbus.MakeVariant(reason),
101- }
102-
103- var handlePath dbus.ObjectPath
104- err := obj.Call(portalInterface+".Inhibit", 0,
105- "", // window identifier (empty for non-sandboxed)
106- uint32(flagIdle|flagSuspend), // inhibit idle and suspend
107- options,
108- ).Store(&handlePath)
109-
110 if err != nil {109 if err != nil {
111 p.refcount--110 p.refcount--
112 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")111 log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
113 return fmt.Errorf("portal inhibit: %w", err)112 return fmt.Errorf("portal inhibit: %w", err)
114 }113 }
115-114+ p.requestPath = handle
116- p.requestPath = handlePath
117- p.requestComplete = false
118-
119- // Subscribe to the Response signal to know when the inhibition ends
120- // Some portals complete the request immediately with a Response signal
121- go p.watchForResponse(ctx, handlePath)
122 115
123 log.Info().116 log.Info().
124- Str("handle", string(handlePath)).117+ Str("handle", handle).
125 Str("reason", reason).118 Str("reason", reason).
126 Msg("idle inhibitor: activated")119 Msg("idle inhibitor: activated")
127 120
128 return nil121 return nil
129 }122 }
130 123
131-// watchForResponse monitors for the Response signal on the request object.124+// callInhibit calls Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
132-// Some portals (particularly GNOME) complete the Inhibit request immediately,125+// and returns the request handle.
133-// which removes the Request object. We need to track this to avoid calling126+func (p *PortalInhibitor) callInhibit(reason string) (string, error) {
134-// Close on a non-existent object.127+ e := p.conn.NewCall(portalDest, portalPath, portalInterface, "Inhibit", "sua{sv}")
135-func (p *PortalInhibitor) watchForResponse(ctx context.Context, handlePath dbus.ObjectPath) {128+ e.Str("") // window identifier (empty for non-sandboxed)
136- log := logging.FromContext(ctx)129+ e.Uint32(flagIdle | flagSuspend) // inhibit idle and suspend
137-130+ options := e.BeginArray('{')
138- p.mu.Lock()131+ e.Struct()
139- conn := p.conn132+ e.Str("reason")
140- p.mu.Unlock()133+ e.Variant("s")
141- if conn == nil {134+ e.Str(reason)
142- return135+ e.EndArray(options)
143- }136+
144-137+ m, err := p.conn.Call()
145- // Add match rule for the Response signal on this specific request138+ if err != nil {
146- matchRule := fmt.Sprintf(139+ return "", err
147- "type='signal',interface='%s',member='Response',path='%s'",
148- requestIface, handlePath,
149- )
150-
151- if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
152- log.Debug().Err(err).Msg("idle inhibitor: failed to add signal match")
153- return
154 }140 }
155-141+ body := m.Body()
156- // Create a channel to receive signals142+ // The reply points into the receive buffer: copy the handle we keep.
157- signals := make(chan *dbus.Signal, 1)143+ handle := strings.Clone(body.ObjectPath())
158- conn.Signal(signals)144+ if err := body.Err(); err != nil {
159-145+ return "", err
160- defer func() {
161- conn.RemoveSignal(signals)
162- _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule).Err
163- }()
164-
165- // Wait for either a Response signal or context cancellation
166- for {
167- select {
168- case sig := <-signals:
169- if sig == nil {
170- return
171- }
172- if sig.Path == handlePath && sig.Name == requestIface+".Response" {
173- p.mu.Lock()
174- p.requestComplete = true
175- p.mu.Unlock()
176- log.Debug().
177- Str("handle", string(handlePath)).
178- Msg("idle inhibitor: request completed by portal")
179- return
180- }
181- case <-ctx.Done():
182- return
183- }
184 }146 }
147+ return handle, nil
185 }148 }
186 149
187 // Uninhibit decrements the refcount. When zero, releases inhibition.150 // Uninhibit decrements the refcount. When zero, releases inhibition.
@@ -199,30 +162,23 @@ func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
199 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")162 log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
200 163
201 // Still have active inhibitors164 // Still have active inhibitors
202- if p.refcount > 0 {165+ if p.refcount > 0 || p.requestPath == "" {
203 return nil166 return nil
204 }167 }
205 168
206- // Release the inhibition169+ p.closeRequest()
207- if !p.supported || p.conn == nil || p.requestPath == "" {170+ log.Info().Msg("idle inhibitor: deactivated")
208- return nil171+ return nil
209- }172+}
210 173
211- // If the portal already completed the request with a Response signal,174+// closeRequest releases the active inhibit request. A portal that already
212- // the Request object no longer exists - don't try to close it175+// ended the request answers with an error, which is ignored.
213- if p.requestComplete {176+func (p *PortalInhibitor) closeRequest() {
214- log.Info().Msg("idle inhibitor: deactivated (completed by portal)")177+ if p.conn != nil && p.requestPath != "" {
215- p.requestPath = ""178+ p.conn.NewCall(portalDest, p.requestPath, requestIface, "Close", "")
216- return nil179+ _, _ = p.conn.Call()
217 }180 }
218-
219- // Close the request to release inhibition
220- obj := p.conn.Object(portalDest, p.requestPath)
221- _ = obj.Call(requestIface+".Close", 0).Err
222-
223- log.Info().Msg("idle inhibitor: deactivated")
224 p.requestPath = ""181 p.requestPath = ""
225- return nil
226 }182 }
227 183
228 // IsInhibited returns true if currently inhibiting idle.184 // IsInhibited returns true if currently inhibiting idle.
@@ -237,20 +193,13 @@ func (p *PortalInhibitor) Close() error {
237 p.mu.Lock()193 p.mu.Lock()
238 defer p.mu.Unlock()194 defer p.mu.Unlock()
239 195
240- // Release any active inhibition (only if not already completed by portal)196+ p.closeRequest()
241- if p.conn != nil && p.requestPath != "" && !p.requestComplete {
242- obj := p.conn.Object(portalDest, p.requestPath)
243- _ = obj.Call(requestIface+".Close", 0).Err
244- }
245- p.requestPath = ""
246- p.requestComplete = false
247 p.refcount = 0197 p.refcount = 0
248 198
249- if p.conn != nil {199+ if p.conn == nil {
250- err := p.conn.Close()200+ return nil
251- p.conn = nil
252- return err
253 }201 }
254-202+ err := p.conn.Close()
255- return nil203+ p.conn = nil
204+ return err
256 }205 }
M internal/infrastructure/idle/portal_inhibitor_test.go +229 -44
@@ -4,53 +4,251 @@ import (
44 "bufio"
55 "bytes"
66 "context"
7+ "os"
78 "os/exec"
9+ "path/filepath"
810 "strings"
9- "sync"
1011 "testing"
1112
12- "github.com/godbus/dbus/v5"
13+ "github.com/bnema/zerobus"
1314 )
1415
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()
16+const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"
2117
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- },
18+// portalCall is a method call received by the fake portal.
19+type portalCall struct {
20+ member string
21+ path string
22+ flags uint32
23+ reason string
24+}
25+
26+// fakePortal serves the Inhibit portal on a private bus and reports every
27+// call it answers on calls, before replying.
28+type fakePortal struct {
29+ calls chan portalCall
30+ // endRequest makes the portal answer Close with an error, like a
31+ // portal that already ended the request with a Response signal.
32+ endRequest bool
33+}
34+
35+func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {
36+ t.Helper()
37+ code, err := conn.RequestName(portalDest, zerobus.NameFlagDoNotQueue)
38+ if err != nil || code != zerobus.NamePrimaryOwner {
39+ t.Fatalf("RequestName = %d, %v", code, err)
3140 }
41+ go func() {
42+ for {
43+ m, err := conn.ReadMessage()
44+ if err != nil {
45+ return // connection closed at cleanup
46+ }
47+ if m.Type != zerobus.TypeMethodCall {
48+ continue
49+ }
50+ f.answer(conn, m)
51+ }
52+ }()
53+}
3254
33- inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")
55+func (f *fakePortal) answer(conn *zerobus.Conn, m *zerobus.Message) {
56+ call := portalCall{member: m.Member, path: strings.Clone(m.Path)}
57+ switch m.Member {
58+ case "Get":
59+ e := conn.NewReply(m, "v")
60+ e.Variant("u")
61+ e.Uint32(3)
62+ case "Inhibit":
63+ body := m.Body()
64+ body.Str() // window
65+ call.flags = body.Uint32()
66+ end := body.Array('{')
67+ for body.More(end) {
68+ body.Struct()
69+ key := strings.Clone(body.Str())
70+ sig := body.Variant()
71+ if key == "reason" && sig == "s" {
72+ call.reason = strings.Clone(body.Str())
73+ continue
74+ }
75+ body.Skip(sig)
76+ }
77+ conn.NewReply(m, "o").ObjectPath(testRequestPath)
78+ case "Close":
79+ if f.endRequest {
80+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("request ended")
81+ } else {
82+ conn.NewReply(m, "")
83+ }
84+ default:
85+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "")
86+ }
87+ // Record before replying so the call is visible once the caller returns.
88+ f.calls <- call
89+ _, _ = conn.Send()
90+}
3491
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")
92+func (f *fakePortal) next(t *testing.T) portalCall {
93+ t.Helper()
94+ select {
95+ case c := <-f.calls:
96+ return c
97+ default:
98+ t.Fatal("expected a portal call")
99+ return portalCall{}
40100 }
41101 }
42102
43-func connectPrivateSessionBus(t *testing.T) *dbus.Conn {
103+func (f *fakePortal) none(t *testing.T) {
44104 t.Helper()
105+ select {
106+ case c := <-f.calls:
107+ t.Fatalf("unexpected portal call %s on %s", c.member, c.path)
108+ default:
109+ }
110+}
45111
112+// newTestInhibitor starts a private bus with a fake portal and returns an
113+// inhibitor connected to it.
114+func newTestInhibitor(t *testing.T, portal *fakePortal) *PortalInhibitor {
115+ t.Helper()
46116 address := startPrivateSessionBus(t)
47- conn, err := dbus.Connect(address)
117+ portal.calls = make(chan portalCall, 8)
118+ portal.serve(t, dial(t, address))
119+
120+ p := newPortalInhibitor(context.Background(), dial(t, address))
121+ t.Cleanup(func() { _ = p.Close() })
122+ if c := portal.next(t); c.member != "Get" {
123+ t.Fatalf("first call = %s, want Get", c.member)
124+ }
125+ return p
126+}
127+
128+func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
129+ portal := &fakePortal{}
130+ p := newTestInhibitor(t, portal)
131+ ctx := context.Background()
132+
133+ if err := p.Inhibit(ctx, "video playing"); err != nil {
134+ t.Fatal(err)
135+ }
136+ c := portal.next(t)
137+ if c.member != "Inhibit" || c.flags != flagIdle|flagSuspend || c.reason != "video playing" {
138+ t.Fatalf("Inhibit call = %+v", c)
139+ }
140+
141+ if err := p.Inhibit(ctx, "second video"); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := p.Uninhibit(ctx); err != nil {
145+ t.Fatal(err)
146+ }
147+ portal.none(t)
148+ if !p.IsInhibited() {
149+ t.Fatal("released while one inhibit is still held")
150+ }
151+
152+ if err := p.Uninhibit(ctx); err != nil {
153+ t.Fatal(err)
154+ }
155+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
156+ t.Fatalf("release call = %+v", c)
157+ }
158+ if p.IsInhibited() {
159+ t.Fatal("still inhibited after the last Uninhibit")
160+ }
161+}
162+
163+func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {
164+ portal := &fakePortal{endRequest: true}
165+ p := newTestInhibitor(t, portal)
166+ ctx := context.Background()
167+
168+ if err := p.Inhibit(ctx, "video playing"); err != nil {
169+ t.Fatal(err)
170+ }
171+ portal.next(t)
172+ if err := p.Uninhibit(ctx); err != nil {
173+ t.Fatalf("Uninhibit after the portal ended the request: %v", err)
174+ }
175+ portal.next(t)
176+
177+ // The connection stays usable for the next inhibition.
178+ if err := p.Inhibit(ctx, "next video"); err != nil {
179+ t.Fatal(err)
180+ }
181+ if c := portal.next(t); c.member != "Inhibit" {
182+ t.Fatalf("call = %s, want Inhibit", c.member)
183+ }
184+}
185+
186+func TestPortalInhibitorCloseReleasesActiveRequest(t *testing.T) {
187+ portal := &fakePortal{}
188+ p := newTestInhibitor(t, portal)
189+
190+ if err := p.Inhibit(context.Background(), "video playing"); err != nil {
191+ t.Fatal(err)
192+ }
193+ portal.next(t)
194+ if err := p.Close(); err != nil {
195+ t.Fatal(err)
196+ }
197+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
198+ t.Fatalf("Close call = %+v", c)
199+ }
200+ if p.IsInhibited() {
201+ t.Fatal("still inhibited after Close")
202+ }
203+}
204+
205+func TestPortalInhibitorWithoutPortalIsNoop(t *testing.T) {
206+ p := newPortalInhibitor(context.Background(), dial(t, startPrivateSessionBus(t)))
207+ t.Cleanup(func() { _ = p.Close() })
208+ if p.conn != nil {
209+ t.Fatal("kept the connection although no portal answered")
210+ }
211+
212+ ctx := context.Background()
213+ if err := p.Inhibit(ctx, "video playing"); err != nil {
214+ t.Fatal(err)
215+ }
216+ if !p.IsInhibited() {
217+ t.Fatal("refcount not tracked without a portal")
218+ }
219+ if err := p.Uninhibit(ctx); err != nil {
220+ t.Fatal(err)
221+ }
222+ if p.IsInhibited() {
223+ t.Fatal("still inhibited after Uninhibit")
224+ }
225+}
226+
227+func dial(t *testing.T, address string) *zerobus.Conn {
228+ t.Helper()
229+ conn, err := zerobus.Dial(address)
48230 if err != nil {
49231 t.Fatalf("connect to private D-Bus session bus: %v", err)
50232 }
233+ t.Cleanup(func() { _ = conn.Close() })
51234 return conn
52235 }
53236
237+// privateBusConfig is a session bus without service directories, so it never
238+// auto-starts the real portal installed on the host.
239+const privateBusConfig = `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
240+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
241+<busconfig>
242+ <type>session</type>
243+ <listen>unix:tmpdir=/tmp</listen>
244+ <policy context="default">
245+ <allow send_destination="*" eavesdrop="true"/>
246+ <allow eavesdrop="true"/>
247+ <allow own="*"/>
248+ </policy>
249+</busconfig>
250+`
251+
54252 func startPrivateSessionBus(t *testing.T) string {
55253 t.Helper()
56254
@@ -58,8 +256,13 @@ func startPrivateSessionBus(t *testing.T) string {
58256 t.Skipf("dbus-daemon unavailable: %v", err)
59257 }
60258
259+ config := filepath.Join(t.TempDir(), "bus.conf")
260+ if err := os.WriteFile(config, []byte(privateBusConfig), 0o600); err != nil {
261+ t.Fatalf("write dbus-daemon config: %v", err)
262+ }
263+
61264 var stderr bytes.Buffer
62- cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")
265+ cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
63266 cmd.Stderr = &stderr
64267
65268 stdout, err := cmd.StdoutPipe()
@@ -84,21 +287,3 @@ func startPrivateSessionBus(t *testing.T) string {
84287 }
85288 return strings.TrimSpace(address)
86289 }
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-}
@@ -4,53 +4,251 @@ import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6 "context"6 "context"
7+ "os"
7 "os/exec"8 "os/exec"
9+ "path/filepath"
8 "strings"10 "strings"
9- "sync"
10 "testing"11 "testing"
11 12
12- "github.com/godbus/dbus/v5"13+ "github.com/bnema/zerobus"
13 )14 )
14 15
15-func TestWatchForResponseCleanupSurvivesConnFieldCleared(t *testing.T) {16+const testRequestPath = "/org/freedesktop/portal/desktop/request/dumber/test"
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 17
22- inhibitor := &PortalInhibitor{conn: conn}18+// portalCall is a method call received by the fake portal.
23- ctx := &clearConnOnDoneContext{19+type portalCall struct {
24- Context: context.Background(),20+ member string
25- done: closedDoneChannel(),21+ path string
26- clear: func() {22+ flags uint32
27- inhibitor.mu.Lock()23+ reason string
28- inhibitor.conn = nil24+}
29- inhibitor.mu.Unlock()25+
30- },26+// fakePortal serves the Inhibit portal on a private bus and reports every
27+// call it answers on calls, before replying.
28+type fakePortal struct {
29+ calls chan portalCall
30+ // endRequest makes the portal answer Close with an error, like a
31+ // portal that already ended the request with a Response signal.
32+ endRequest bool
33+}
34+
35+func (f *fakePortal) serve(t *testing.T, conn *zerobus.Conn) {
36+ t.Helper()
37+ code, err := conn.RequestName(portalDest, zerobus.NameFlagDoNotQueue)
38+ if err != nil || code != zerobus.NamePrimaryOwner {
39+ t.Fatalf("RequestName = %d, %v", code, err)
31 }40 }
41+ go func() {
42+ for {
43+ m, err := conn.ReadMessage()
44+ if err != nil {
45+ return // connection closed at cleanup
46+ }
47+ if m.Type != zerobus.TypeMethodCall {
48+ continue
49+ }
50+ f.answer(conn, m)
51+ }
52+ }()
53+}
32 54
33- inhibitor.watchForResponse(ctx, "/org/freedesktop/portal/desktop/request/dumber/test")55+func (f *fakePortal) answer(conn *zerobus.Conn, m *zerobus.Message) {
56+ call := portalCall{member: m.Member, path: strings.Clone(m.Path)}
57+ switch m.Member {
58+ case "Get":
59+ e := conn.NewReply(m, "v")
60+ e.Variant("u")
61+ e.Uint32(3)
62+ case "Inhibit":
63+ body := m.Body()
64+ body.Str() // window
65+ call.flags = body.Uint32()
66+ end := body.Array('{')
67+ for body.More(end) {
68+ body.Struct()
69+ key := strings.Clone(body.Str())
70+ sig := body.Variant()
71+ if key == "reason" && sig == "s" {
72+ call.reason = strings.Clone(body.Str())
73+ continue
74+ }
75+ body.Skip(sig)
76+ }
77+ conn.NewReply(m, "o").ObjectPath(testRequestPath)
78+ case "Close":
79+ if f.endRequest {
80+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("request ended")
81+ } else {
82+ conn.NewReply(m, "")
83+ }
84+ default:
85+ conn.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "")
86+ }
87+ // Record before replying so the call is visible once the caller returns.
88+ f.calls <- call
89+ _, _ = conn.Send()
90+}
34 91
35- inhibitor.mu.Lock()92+func (f *fakePortal) next(t *testing.T) portalCall {
36- connCleared := inhibitor.conn == nil93+ t.Helper()
37- inhibitor.mu.Unlock()94+ select {
38- if !connCleared {95+ case c := <-f.calls:
39- t.Fatal("watchForResponse returned before observing context cancellation")96+ return c
97+ default:
98+ t.Fatal("expected a portal call")
99+ return portalCall{}
40 }100 }
41 }101 }
42 102
43-func connectPrivateSessionBus(t *testing.T) *dbus.Conn {103+func (f *fakePortal) none(t *testing.T) {
44 t.Helper()104 t.Helper()
105+ select {
106+ case c := <-f.calls:
107+ t.Fatalf("unexpected portal call %s on %s", c.member, c.path)
108+ default:
109+ }
110+}
45 111
112+// newTestInhibitor starts a private bus with a fake portal and returns an
113+// inhibitor connected to it.
114+func newTestInhibitor(t *testing.T, portal *fakePortal) *PortalInhibitor {
115+ t.Helper()
46 address := startPrivateSessionBus(t)116 address := startPrivateSessionBus(t)
47- conn, err := dbus.Connect(address)117+ portal.calls = make(chan portalCall, 8)
118+ portal.serve(t, dial(t, address))
119+
120+ p := newPortalInhibitor(context.Background(), dial(t, address))
121+ t.Cleanup(func() { _ = p.Close() })
122+ if c := portal.next(t); c.member != "Get" {
123+ t.Fatalf("first call = %s, want Get", c.member)
124+ }
125+ return p
126+}
127+
128+func TestPortalInhibitorRefcountsOneRequest(t *testing.T) {
129+ portal := &fakePortal{}
130+ p := newTestInhibitor(t, portal)
131+ ctx := context.Background()
132+
133+ if err := p.Inhibit(ctx, "video playing"); err != nil {
134+ t.Fatal(err)
135+ }
136+ c := portal.next(t)
137+ if c.member != "Inhibit" || c.flags != flagIdle|flagSuspend || c.reason != "video playing" {
138+ t.Fatalf("Inhibit call = %+v", c)
139+ }
140+
141+ if err := p.Inhibit(ctx, "second video"); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := p.Uninhibit(ctx); err != nil {
145+ t.Fatal(err)
146+ }
147+ portal.none(t)
148+ if !p.IsInhibited() {
149+ t.Fatal("released while one inhibit is still held")
150+ }
151+
152+ if err := p.Uninhibit(ctx); err != nil {
153+ t.Fatal(err)
154+ }
155+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
156+ t.Fatalf("release call = %+v", c)
157+ }
158+ if p.IsInhibited() {
159+ t.Fatal("still inhibited after the last Uninhibit")
160+ }
161+}
162+
163+func TestPortalInhibitorToleratesEndedRequest(t *testing.T) {
164+ portal := &fakePortal{endRequest: true}
165+ p := newTestInhibitor(t, portal)
166+ ctx := context.Background()
167+
168+ if err := p.Inhibit(ctx, "video playing"); err != nil {
169+ t.Fatal(err)
170+ }
171+ portal.next(t)
172+ if err := p.Uninhibit(ctx); err != nil {
173+ t.Fatalf("Uninhibit after the portal ended the request: %v", err)
174+ }
175+ portal.next(t)
176+
177+ // The connection stays usable for the next inhibition.
178+ if err := p.Inhibit(ctx, "next video"); err != nil {
179+ t.Fatal(err)
180+ }
181+ if c := portal.next(t); c.member != "Inhibit" {
182+ t.Fatalf("call = %s, want Inhibit", c.member)
183+ }
184+}
185+
186+func TestPortalInhibitorCloseReleasesActiveRequest(t *testing.T) {
187+ portal := &fakePortal{}
188+ p := newTestInhibitor(t, portal)
189+
190+ if err := p.Inhibit(context.Background(), "video playing"); err != nil {
191+ t.Fatal(err)
192+ }
193+ portal.next(t)
194+ if err := p.Close(); err != nil {
195+ t.Fatal(err)
196+ }
197+ if c := portal.next(t); c.member != "Close" || c.path != testRequestPath {
198+ t.Fatalf("Close call = %+v", c)
199+ }
200+ if p.IsInhibited() {
201+ t.Fatal("still inhibited after Close")
202+ }
203+}
204+
205+func TestPortalInhibitorWithoutPortalIsNoop(t *testing.T) {
206+ p := newPortalInhibitor(context.Background(), dial(t, startPrivateSessionBus(t)))
207+ t.Cleanup(func() { _ = p.Close() })
208+ if p.conn != nil {
209+ t.Fatal("kept the connection although no portal answered")
210+ }
211+
212+ ctx := context.Background()
213+ if err := p.Inhibit(ctx, "video playing"); err != nil {
214+ t.Fatal(err)
215+ }
216+ if !p.IsInhibited() {
217+ t.Fatal("refcount not tracked without a portal")
218+ }
219+ if err := p.Uninhibit(ctx); err != nil {
220+ t.Fatal(err)
221+ }
222+ if p.IsInhibited() {
223+ t.Fatal("still inhibited after Uninhibit")
224+ }
225+}
226+
227+func dial(t *testing.T, address string) *zerobus.Conn {
228+ t.Helper()
229+ conn, err := zerobus.Dial(address)
48 if err != nil {230 if err != nil {
49 t.Fatalf("connect to private D-Bus session bus: %v", err)231 t.Fatalf("connect to private D-Bus session bus: %v", err)
50 }232 }
233+ t.Cleanup(func() { _ = conn.Close() })
51 return conn234 return conn
52 }235 }
53 236
237+// privateBusConfig is a session bus without service directories, so it never
238+// auto-starts the real portal installed on the host.
239+const privateBusConfig = `<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
240+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
241+<busconfig>
242+ <type>session</type>
243+ <listen>unix:tmpdir=/tmp</listen>
244+ <policy context="default">
245+ <allow send_destination="*" eavesdrop="true"/>
246+ <allow eavesdrop="true"/>
247+ <allow own="*"/>
248+ </policy>
249+</busconfig>
250+`
251+
54 func startPrivateSessionBus(t *testing.T) string {252 func startPrivateSessionBus(t *testing.T) string {
55 t.Helper()253 t.Helper()
56 254
@@ -58,8 +256,13 @@ func startPrivateSessionBus(t *testing.T) string {
58 t.Skipf("dbus-daemon unavailable: %v", err)256 t.Skipf("dbus-daemon unavailable: %v", err)
59 }257 }
60 258
259+ config := filepath.Join(t.TempDir(), "bus.conf")
260+ if err := os.WriteFile(config, []byte(privateBusConfig), 0o600); err != nil {
261+ t.Fatalf("write dbus-daemon config: %v", err)
262+ }
263+
61 var stderr bytes.Buffer264 var stderr bytes.Buffer
62- cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address=1")265+ cmd := exec.Command("dbus-daemon", "--config-file="+config, "--nofork", "--print-address=1")
63 cmd.Stderr = &stderr266 cmd.Stderr = &stderr
64 267
65 stdout, err := cmd.StdoutPipe()268 stdout, err := cmd.StdoutPipe()
@@ -84,21 +287,3 @@ func startPrivateSessionBus(t *testing.T) string {
84 }287 }
85 return strings.TrimSpace(address)288 return strings.TrimSpace(address)
86 }289 }
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