detached 4fd770d

Merge pull request 'feat(powerkey): hold logind key locks so binds decide power, sleep and reboot keys' (#20) from feat/powerkey-inhibit into main

commit
4fd770d66c74630e973e620b519c4985ff679ce3
author
bnema · · 2026-10-08 09:37 UTC
merge of
cae3aeb + 4c78479 · diff against the first parent
signature
unsigned
10 files · +529 -40
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := busretry|outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := busretry|outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M docs/desktop.md +18 -0
@@ -100,6 +100,24 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
100100 exec wlopm --off '*'
101101 ```
102102
103+## Power keys
104+
105+NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it.
106+
107+| logind key | Kernel key | Bind |
108+|---|---|---|
109+| power | `KEY_POWER` | `XF86PowerOff` |
110+| suspend | `KEY_SLEEP` | `XF86Sleep` |
111+| hibernate | `KEY_SUSPEND` | `XF86Suspend` |
112+| reboot | `KEY_RESTART` | `code:408` (no keysym) |
113+
114+```text
115+bind.XF86PowerOff = spawn systemctl suspend
116+bind.XF86Suspend = spawn systemctl hibernate
117+```
118+
119+The lid switch is not covered: logind still handles it. The locks last as long as NeferWL runs; if it crashes, logind handles the keys again. When logind restarts, NeferWL keeps the old locks until it has taken new ones. If the system bus or logind is missing, NeferWL logs a warning and tries again later, and the keys keep logind's behaviour until it succeeds.
120+
103121 ## Bars
104122
105123 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.
@@ -100,6 +100,24 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
100 exec wlopm --off '*'100 exec wlopm --off '*'
101 ```101 ```
102 102
103+## Power keys
104+
105+NeferWL takes logind's `handle-power-key`, `handle-suspend-key`, `handle-hibernate-key` and `handle-reboot-key` inhibitor locks. logind then no longer powers off, suspends, hibernates or reboots when one of those keys is pressed: the key reaches NeferWL, and nothing happens unless a bind uses it.
106+
107+| logind key | Kernel key | Bind |
108+|---|---|---|
109+| power | `KEY_POWER` | `XF86PowerOff` |
110+| suspend | `KEY_SLEEP` | `XF86Sleep` |
111+| hibernate | `KEY_SUSPEND` | `XF86Suspend` |
112+| reboot | `KEY_RESTART` | `code:408` (no keysym) |
113+
114+```text
115+bind.XF86PowerOff = spawn systemctl suspend
116+bind.XF86Suspend = spawn systemctl hibernate
117+```
118+
119+The lid switch is not covered: logind still handles it. The locks last as long as NeferWL runs; if it crashes, logind handles the keys again. When logind restarts, NeferWL keeps the old locks until it has taken new ones. If the system bus or logind is missing, NeferWL logs a warning and tries again later, and the keys keep logind's behaviour until it succeeds.
120+
103 ## Bars121 ## Bars
104 122
105 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.123 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. Every workspace sends an `id`, the same string as its `workspace_id` in the state file. A configured (named) workspace has `name:<configured name>`, stable across launches; renaming it in the config removes its handle and sends a new one. A numbered or dynamic workspace has `<prefix>-<n>`: the prefix is 8 lowercase hex characters drawn once per compositor launch, so the `id` is unique per launch, stable while the workspace exists (across reordering and moves between outputs), and never repeats in the next launch. Numbered workspaces carry an `id` too, so a bar or script can target them during the session, but it is unique for the current launch only: do not store preferences keyed on it across launches. Only named workspaces have an `id` that is stable across launches.
A internal/adapters/busretry/busretry.go +51 -0
@@ -0,0 +1,51 @@
1+// Package busretry keeps a D-Bus service running across bus restarts and
2+// busy names: it runs an attempt again after a growing delay until the
3+// context ends.
4+package busretry
5+
6+import (
7+ "context"
8+ "errors"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// maxDelay bounds the delay between attempts.
15+const maxDelay = time.Minute
16+
17+// Run calls attempt until ctx ends. When attempt returns, it waits retry,
18+// doubling up to maxDelay on each failure, and calls it again. An attempt that
19+// ran longer than maxDelay before failing starts the delay over: its failure is
20+// new, not a retry streak. what names the feature in logs ("X ignored until
21+// the next attempt"); a failure is warned once per distinct error, then
22+// logged at debug level.
23+func Run(ctx context.Context, retry time.Duration, what string, log zerowrap.Logger, attempt func(context.Context) error) {
24+ delay := retry
25+ var last string
26+ for {
27+ start := time.Now()
28+ err := attempt(ctx)
29+ if ctx.Err() != nil {
30+ return
31+ }
32+ if err == nil {
33+ err = errors.New(what + " stopped")
34+ }
35+ if time.Since(start) > maxDelay {
36+ delay, last = retry, ""
37+ }
38+ if msg := err.Error(); msg != last {
39+ last = msg
40+ log.Warn().Err(err).Dur("retry", delay).Msg(what + " ignored until the next attempt")
41+ } else {
42+ log.Debug().Err(err).Dur("retry", delay).Msg(what + " still ignored")
43+ }
44+ select {
45+ case <-ctx.Done():
46+ return
47+ case <-time.After(delay):
48+ }
49+ delay = min(2*delay, maxDelay)
50+ }
51+}
@@ -0,0 +1,51 @@
1+// Package busretry keeps a D-Bus service running across bus restarts and
2+// busy names: it runs an attempt again after a growing delay until the
3+// context ends.
4+package busretry
5+
6+import (
7+ "context"
8+ "errors"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// maxDelay bounds the delay between attempts.
15+const maxDelay = time.Minute
16+
17+// Run calls attempt until ctx ends. When attempt returns, it waits retry,
18+// doubling up to maxDelay on each failure, and calls it again. An attempt that
19+// ran longer than maxDelay before failing starts the delay over: its failure is
20+// new, not a retry streak. what names the feature in logs ("X ignored until
21+// the next attempt"); a failure is warned once per distinct error, then
22+// logged at debug level.
23+func Run(ctx context.Context, retry time.Duration, what string, log zerowrap.Logger, attempt func(context.Context) error) {
24+ delay := retry
25+ var last string
26+ for {
27+ start := time.Now()
28+ err := attempt(ctx)
29+ if ctx.Err() != nil {
30+ return
31+ }
32+ if err == nil {
33+ err = errors.New(what + " stopped")
34+ }
35+ if time.Since(start) > maxDelay {
36+ delay, last = retry, ""
37+ }
38+ if msg := err.Error(); msg != last {
39+ last = msg
40+ log.Warn().Err(err).Dur("retry", delay).Msg(what + " ignored until the next attempt")
41+ } else {
42+ log.Debug().Err(err).Dur("retry", delay).Msg(what + " still ignored")
43+ }
44+ select {
45+ case <-ctx.Done():
46+ return
47+ case <-time.After(delay):
48+ }
49+ delay = min(2*delay, maxDelay)
50+ }
51+}
A internal/adapters/busretry/busretry_test.go +67 -0
@@ -0,0 +1,67 @@
1+package busretry
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+)
11+
12+// A failing or returning attempt runs again until ctx ends, and Run returns
13+// without waiting out the delay.
14+func TestRunRetriesUntilCancelled(t *testing.T) {
15+ ctx, cancel := context.WithCancel(context.Background())
16+ calls := make(chan int, 8)
17+ n := 0
18+ done := make(chan struct{})
19+ go func() {
20+ defer close(done)
21+ Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error {
22+ n++
23+ calls <- n
24+ if n%2 == 0 {
25+ return nil
26+ }
27+ return errors.New("bus gone")
28+ })
29+ }()
30+ for want := 1; want <= 3; want++ {
31+ select {
32+ case got := <-calls:
33+ if got != want {
34+ t.Fatalf("attempt %d, want %d", got, want)
35+ }
36+ case <-time.After(5 * time.Second):
37+ t.Fatalf("attempt %d never ran", want)
38+ }
39+ }
40+ cancel()
41+ select {
42+ case <-done:
43+ case <-time.After(5 * time.Second):
44+ t.Fatal("Run outlived its context")
45+ }
46+}
47+
48+// A long delay does not hold Run once ctx ends.
49+func TestRunStopsDuringDelay(t *testing.T) {
50+ ctx, cancel := context.WithCancel(context.Background())
51+ started := make(chan struct{})
52+ done := make(chan struct{})
53+ go func() {
54+ defer close(done)
55+ Run(ctx, time.Hour, "test feature", logging.For(ctx, "test"), func(context.Context) error {
56+ close(started)
57+ return errors.New("fail")
58+ })
59+ }()
60+ <-started
61+ cancel()
62+ select {
63+ case <-done:
64+ case <-time.After(5 * time.Second):
65+ t.Fatal("Run waits out the retry delay after cancel")
66+ }
67+}
@@ -0,0 +1,67 @@
1+package busretry
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+)
11+
12+// A failing or returning attempt runs again until ctx ends, and Run returns
13+// without waiting out the delay.
14+func TestRunRetriesUntilCancelled(t *testing.T) {
15+ ctx, cancel := context.WithCancel(context.Background())
16+ calls := make(chan int, 8)
17+ n := 0
18+ done := make(chan struct{})
19+ go func() {
20+ defer close(done)
21+ Run(ctx, time.Millisecond, "test feature", logging.For(ctx, "test"), func(context.Context) error {
22+ n++
23+ calls <- n
24+ if n%2 == 0 {
25+ return nil
26+ }
27+ return errors.New("bus gone")
28+ })
29+ }()
30+ for want := 1; want <= 3; want++ {
31+ select {
32+ case got := <-calls:
33+ if got != want {
34+ t.Fatalf("attempt %d, want %d", got, want)
35+ }
36+ case <-time.After(5 * time.Second):
37+ t.Fatalf("attempt %d never ran", want)
38+ }
39+ }
40+ cancel()
41+ select {
42+ case <-done:
43+ case <-time.After(5 * time.Second):
44+ t.Fatal("Run outlived its context")
45+ }
46+}
47+
48+// A long delay does not hold Run once ctx ends.
49+func TestRunStopsDuringDelay(t *testing.T) {
50+ ctx, cancel := context.WithCancel(context.Background())
51+ started := make(chan struct{})
52+ done := make(chan struct{})
53+ go func() {
54+ defer close(done)
55+ Run(ctx, time.Hour, "test feature", logging.For(ctx, "test"), func(context.Context) error {
56+ close(started)
57+ return errors.New("fail")
58+ })
59+ }()
60+ <-started
61+ cancel()
62+ select {
63+ case <-done:
64+ case <-time.After(5 * time.Second):
65+ t.Fatal("Run waits out the retry delay after cancel")
66+ }
67+}
M internal/adapters/logging/logging.go +1 -1
@@ -18,7 +18,7 @@ import (
1818 var components = map[string]bool{
1919 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
2020 "render": true, "sync": true, "config": true, "app": true,
21- "screensaver": true, "sched": true,
21+ "screensaver": true, "powerkey": true, "sched": true,
2222 }
2323
2424 // categories are debug switches narrower than a component, only on when
@@ -18,7 +18,7 @@ import (
18 var components = map[string]bool{18 var components = map[string]bool{
19 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,19 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
20 "render": true, "sync": true, "config": true, "app": true,20 "render": true, "sync": true, "config": true, "app": true,
21- "screensaver": true, "sched": true,21+ "screensaver": true, "powerkey": true, "sched": true,
22 }22 }
23 23
24 // categories are debug switches narrower than a component, only on when24 // categories are debug switches narrower than a component, only on when
A internal/adapters/powerkey/powerkey.go +125 -0
@@ -0,0 +1,125 @@
1+// Package powerkey takes logind's inhibitor locks for the power, suspend,
2+// hibernate and reboot keys, so pressing one no longer powers the machine
3+// off or to sleep by itself: the key reaches the compositor as a key event
4+// (XF86PowerOff, XF86Sleep, ...) and only a bind acts on it.
5+package powerkey
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/adapters/busretry"
15+ "github.com/bnema/zerowrap"
16+ "github.com/godbus/dbus/v5"
17+)
18+
19+const (
20+ logind = "org.freedesktop.login1"
21+ logindPath = "/org/freedesktop/login1"
22+ manager = "org.freedesktop.login1.Manager"
23+ // busDaemon is the sender of the bus's own signals. Any client can send
24+ // a NameOwnerChanged to us directly, so only this sender is trusted.
25+ busDaemon = "org.freedesktop.DBus"
26+ // what lists the logind actions NeferWL takes over.
27+ what = "handle-power-key:handle-suspend-key:handle-hibernate-key:handle-reboot-key"
28+ // callTimeout bounds the Inhibit call.
29+ callTimeout = 3 * time.Second
30+)
31+
32+// Serve holds the locks until ctx ends. address is the bus to use, the
33+// system bus when empty. When the bus or logind goes, it takes the locks
34+// again after retry, doubling up to a minute. Meanwhile the old lock stays
35+// open: logind keeps an inhibitor across its own restart for as long as a
36+// client holds the descriptor, so the keys stay blocked in the gap, and the
37+// old lock is closed only once a new one is in place.
38+func Serve(ctx context.Context, address string, retry time.Duration, log zerowrap.Logger) {
39+ var lock *os.File
40+ defer func() {
41+ if lock != nil {
42+ _ = lock.Close()
43+ }
44+ }()
45+ busretry.Run(ctx, retry, "logind power key lock", log, func(ctx context.Context) error {
46+ return hold(ctx, address, log, &lock)
47+ })
48+}
49+
50+// hold takes the locks, replaces *lock with them, and waits until ctx ends
51+// (nil) or the bus or logind goes (an error).
52+func hold(ctx context.Context, address string, log zerowrap.Logger, lock **os.File) error {
53+ conn, err := connect(ctx, address)
54+ if err != nil {
55+ return err
56+ }
57+ defer func() { _ = conn.Close() }()
58+ signals := make(chan *dbus.Signal, 16)
59+ conn.Signal(signals)
60+ // Ask for logind's departure before the lock exists: a restart between
61+ // the two would go unseen.
62+ if err := conn.AddMatchSignalContext(ctx, dbus.WithMatchSender(busDaemon), dbus.WithMatchInterface(busDaemon), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, logind)); err != nil {
63+ return fmt.Errorf("watch %s: %w", logind, err)
64+ }
65+ callCtx, cancel := context.WithTimeout(ctx, callTimeout)
66+ defer cancel()
67+ var fd dbus.UnixFD
68+ if err := conn.Object(logind, logindPath).CallWithContext(callCtx, manager+".Inhibit", 0, what, "neferwl", "power keys are bound in the compositor", "block").Store(&fd); err != nil {
69+ return fmt.Errorf("inhibit %s: %w", what, err)
70+ }
71+ // logind releases a lock when its descriptor closes.
72+ old := *lock
73+ *lock = os.NewFile(uintptr(fd), "logind-inhibit")
74+ if old != nil {
75+ _ = old.Close()
76+ }
77+ log.Info().Str("what", what).Msg("holding logind key locks")
78+ for {
79+ select {
80+ case <-ctx.Done():
81+ return nil
82+ case sig, ok := <-signals:
83+ if !ok {
84+ if ctx.Err() != nil {
85+ return nil // WithContext closed the connection
86+ }
87+ return errors.New("bus connection closed")
88+ }
89+ if lost(sig) {
90+ return errors.New(logind + " left the bus")
91+ }
92+ }
93+ }
94+}
95+
96+func connect(ctx context.Context, address string) (*dbus.Conn, error) {
97+ var conn *dbus.Conn
98+ var err error
99+ if address == "" {
100+ conn, err = dbus.SystemBusPrivate(dbus.WithContext(ctx))
101+ } else {
102+ conn, err = dbus.Dial(address, dbus.WithContext(ctx))
103+ }
104+ if err != nil {
105+ return nil, fmt.Errorf("bus: %w", err)
106+ }
107+ if err = conn.Auth(nil); err == nil {
108+ err = conn.Hello()
109+ }
110+ if err != nil {
111+ _ = conn.Close()
112+ return nil, fmt.Errorf("bus: %w", err)
113+ }
114+ return conn, nil
115+}
116+
117+// lost reports whether sig is the bus saying logind has no owner any more.
118+func lost(sig *dbus.Signal) bool {
119+ if sig.Sender != busDaemon || sig.Name != busDaemon+".NameOwnerChanged" || len(sig.Body) != 3 {
120+ return false
121+ }
122+ name, ok1 := sig.Body[0].(string)
123+ owner, ok2 := sig.Body[2].(string)
124+ return ok1 && ok2 && name == logind && owner == ""
125+}
@@ -0,0 +1,125 @@
1+// Package powerkey takes logind's inhibitor locks for the power, suspend,
2+// hibernate and reboot keys, so pressing one no longer powers the machine
3+// off or to sleep by itself: the key reaches the compositor as a key event
4+// (XF86PowerOff, XF86Sleep, ...) and only a bind acts on it.
5+package powerkey
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/adapters/busretry"
15+ "github.com/bnema/zerowrap"
16+ "github.com/godbus/dbus/v5"
17+)
18+
19+const (
20+ logind = "org.freedesktop.login1"
21+ logindPath = "/org/freedesktop/login1"
22+ manager = "org.freedesktop.login1.Manager"
23+ // busDaemon is the sender of the bus's own signals. Any client can send
24+ // a NameOwnerChanged to us directly, so only this sender is trusted.
25+ busDaemon = "org.freedesktop.DBus"
26+ // what lists the logind actions NeferWL takes over.
27+ what = "handle-power-key:handle-suspend-key:handle-hibernate-key:handle-reboot-key"
28+ // callTimeout bounds the Inhibit call.
29+ callTimeout = 3 * time.Second
30+)
31+
32+// Serve holds the locks until ctx ends. address is the bus to use, the
33+// system bus when empty. When the bus or logind goes, it takes the locks
34+// again after retry, doubling up to a minute. Meanwhile the old lock stays
35+// open: logind keeps an inhibitor across its own restart for as long as a
36+// client holds the descriptor, so the keys stay blocked in the gap, and the
37+// old lock is closed only once a new one is in place.
38+func Serve(ctx context.Context, address string, retry time.Duration, log zerowrap.Logger) {
39+ var lock *os.File
40+ defer func() {
41+ if lock != nil {
42+ _ = lock.Close()
43+ }
44+ }()
45+ busretry.Run(ctx, retry, "logind power key lock", log, func(ctx context.Context) error {
46+ return hold(ctx, address, log, &lock)
47+ })
48+}
49+
50+// hold takes the locks, replaces *lock with them, and waits until ctx ends
51+// (nil) or the bus or logind goes (an error).
52+func hold(ctx context.Context, address string, log zerowrap.Logger, lock **os.File) error {
53+ conn, err := connect(ctx, address)
54+ if err != nil {
55+ return err
56+ }
57+ defer func() { _ = conn.Close() }()
58+ signals := make(chan *dbus.Signal, 16)
59+ conn.Signal(signals)
60+ // Ask for logind's departure before the lock exists: a restart between
61+ // the two would go unseen.
62+ if err := conn.AddMatchSignalContext(ctx, dbus.WithMatchSender(busDaemon), dbus.WithMatchInterface(busDaemon), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, logind)); err != nil {
63+ return fmt.Errorf("watch %s: %w", logind, err)
64+ }
65+ callCtx, cancel := context.WithTimeout(ctx, callTimeout)
66+ defer cancel()
67+ var fd dbus.UnixFD
68+ if err := conn.Object(logind, logindPath).CallWithContext(callCtx, manager+".Inhibit", 0, what, "neferwl", "power keys are bound in the compositor", "block").Store(&fd); err != nil {
69+ return fmt.Errorf("inhibit %s: %w", what, err)
70+ }
71+ // logind releases a lock when its descriptor closes.
72+ old := *lock
73+ *lock = os.NewFile(uintptr(fd), "logind-inhibit")
74+ if old != nil {
75+ _ = old.Close()
76+ }
77+ log.Info().Str("what", what).Msg("holding logind key locks")
78+ for {
79+ select {
80+ case <-ctx.Done():
81+ return nil
82+ case sig, ok := <-signals:
83+ if !ok {
84+ if ctx.Err() != nil {
85+ return nil // WithContext closed the connection
86+ }
87+ return errors.New("bus connection closed")
88+ }
89+ if lost(sig) {
90+ return errors.New(logind + " left the bus")
91+ }
92+ }
93+ }
94+}
95+
96+func connect(ctx context.Context, address string) (*dbus.Conn, error) {
97+ var conn *dbus.Conn
98+ var err error
99+ if address == "" {
100+ conn, err = dbus.SystemBusPrivate(dbus.WithContext(ctx))
101+ } else {
102+ conn, err = dbus.Dial(address, dbus.WithContext(ctx))
103+ }
104+ if err != nil {
105+ return nil, fmt.Errorf("bus: %w", err)
106+ }
107+ if err = conn.Auth(nil); err == nil {
108+ err = conn.Hello()
109+ }
110+ if err != nil {
111+ _ = conn.Close()
112+ return nil, fmt.Errorf("bus: %w", err)
113+ }
114+ return conn, nil
115+}
116+
117+// lost reports whether sig is the bus saying logind has no owner any more.
118+func lost(sig *dbus.Signal) bool {
119+ if sig.Sender != busDaemon || sig.Name != busDaemon+".NameOwnerChanged" || len(sig.Body) != 3 {
120+ return false
121+ }
122+ name, ok1 := sig.Body[0].(string)
123+ owner, ok2 := sig.Body[2].(string)
124+ return ok1 && ok2 && name == logind && owner == ""
125+}
A internal/adapters/powerkey/powerkey_test.go +245 -0
@@ -0,0 +1,245 @@
1+package powerkey
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+ "github.com/godbus/dbus/v5"
18+)
19+
20+// startBus starts a dbus-daemon of its own, so tests never touch the
21+// system bus. CI must run these tests: there, a missing daemon fails.
22+func startBus(t *testing.T) string {
23+ t.Helper()
24+ bin, err := exec.LookPath("dbus-daemon")
25+ if err != nil {
26+ if os.Getenv("CI") != "" {
27+ t.Fatal("dbus-daemon not installed")
28+ }
29+ t.Skip("dbus-daemon not installed")
30+ }
31+ socket := filepath.Join(t.TempDir(), "bus")
32+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket)
33+ out, err := cmd.StdoutPipe()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := cmd.Start(); err != nil {
38+ t.Fatal(err)
39+ }
40+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
41+ line, err := bufio.NewReader(out).ReadString('\n')
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ return strings.TrimSpace(line)
46+}
47+
48+// grant is one Inhibit call logind answered.
49+type grant struct {
50+ what, mode string
51+ hold *os.File // our end of the lock: the client has its own copy
52+ released *os.File // reads EOF once every copy of the lock is closed
53+}
54+
55+// logindPeer plays logind on the private bus: it owns the name and answers
56+// Inhibit with a pipe, whose other end shows when the client lets go.
57+type logindPeer struct {
58+ conn *dbus.Conn
59+ grants chan grant
60+}
61+
62+func newPeer(t *testing.T, address string) *logindPeer {
63+ t.Helper()
64+ conn, err := dbus.Connect(address)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ t.Cleanup(func() { _ = conn.Close() })
69+ p := &logindPeer{conn: conn, grants: make(chan grant, 8)}
70+ table := map[string]any{"Inhibit": func(what, who, why, mode string) (dbus.UnixFD, *dbus.Error) {
71+ r, w, err := os.Pipe()
72+ if err != nil {
73+ return 0, dbus.MakeFailedError(err)
74+ }
75+ t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
76+ fd := dbus.UnixFD(w.Fd()) // before the send: the test closes w after it
77+ p.grants <- grant{what: what, mode: mode, hold: w, released: r}
78+ return fd, nil
79+ }}
80+ if err := conn.ExportMethodTable(table, logindPath, manager); err != nil {
81+ t.Fatal(err)
82+ }
83+ p.own(t)
84+ return p
85+}
86+
87+func (p *logindPeer) own(t *testing.T) {
88+ t.Helper()
89+ if r, err := p.conn.RequestName(logind, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
90+ t.Fatal(r, err)
91+ }
92+}
93+
94+func (p *logindPeer) next(t *testing.T) grant {
95+ t.Helper()
96+ select {
97+ case g := <-p.grants:
98+ return g
99+ case <-time.After(5 * time.Second):
100+ t.Fatal("no Inhibit call")
101+ return grant{}
102+ }
103+}
104+
105+// logBuf is a log sink the test reads while Serve writes.
106+type logBuf struct {
107+ mu sync.Mutex
108+ buf bytes.Buffer
109+}
110+
111+func (l *logBuf) Write(p []byte) (int, error) {
112+ l.mu.Lock()
113+ defer l.mu.Unlock()
114+ return l.buf.Write(p)
115+}
116+
117+func (l *logBuf) count(s string) int {
118+ l.mu.Lock()
119+ defer l.mu.Unlock()
120+ return strings.Count(l.buf.String(), s)
121+}
122+
123+// wait blocks until the log has n lines containing msg.
124+func (l *logBuf) wait(t *testing.T, msg string, n int) {
125+ t.Helper()
126+ for deadline := time.Now().Add(5 * time.Second); l.count(msg) < n; time.Sleep(5 * time.Millisecond) {
127+ if time.Now().After(deadline) {
128+ t.Fatalf("log never had %d x %q", n, msg)
129+ }
130+ }
131+}
132+
133+// holding waits until Serve has received n lock answers: only then does it
134+// own the descriptor.
135+func (l *logBuf) holding(t *testing.T, n int) {
136+ t.Helper()
137+ l.wait(t, "holding logind key locks", n)
138+}
139+
140+func run(t *testing.T, address string) (stop func(), log *logBuf) {
141+ t.Helper()
142+ ctx, cancel := context.WithCancel(context.Background())
143+ done := make(chan struct{})
144+ log = &logBuf{}
145+ logger := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: log}).WithField("component", "powerkey")
146+ go func() {
147+ defer close(done)
148+ Serve(ctx, address, 10*time.Millisecond, logger)
149+ }()
150+ var once bool
151+ stop = func() {
152+ if once {
153+ return
154+ }
155+ once = true
156+ cancel()
157+ select {
158+ case <-done:
159+ case <-time.After(5 * time.Second):
160+ t.Error("Serve outlived its context")
161+ }
162+ }
163+ t.Cleanup(stop)
164+ return stop, log
165+}
166+
167+// released reports whether every copy of the lock is closed, once the
168+// peer's own copy is.
169+func released(t *testing.T, g grant) bool {
170+ t.Helper()
171+ _ = g.hold.Close()
172+ _ = g.released.SetReadDeadline(time.Now().Add(2 * time.Second))
173+ _, err := io.ReadAll(g.released)
174+ return err == nil
175+}
176+
177+// Serve blocks the four key handlers and lets go of them when it stops.
178+func TestServeHoldsAndReleasesTheLock(t *testing.T) {
179+ address := startBus(t)
180+ peer := newPeer(t, address)
181+ stop, log := run(t, address)
182+ g := peer.next(t)
183+ log.holding(t, 1)
184+ if g.what != what || g.mode != "block" {
185+ t.Fatalf("Inhibit(%q, %q)", g.what, g.mode)
186+ }
187+ stop()
188+ if !released(t, g) {
189+ t.Fatal("the lock outlived Serve")
190+ }
191+}
192+
193+// When logind restarts, the locks are taken again and the old ones closed.
194+func TestServeTakesTheLockAgainAfterLogindRestarts(t *testing.T) {
195+ address := startBus(t)
196+ peer := newPeer(t, address)
197+ _, log := run(t, address)
198+ first := peer.next(t)
199+ log.holding(t, 1)
200+ if _, err := peer.conn.ReleaseName(logind); err != nil {
201+ t.Fatal(err)
202+ }
203+ log.wait(t, "ignored until the next attempt", 1)
204+ // The gap: the old lock is still ours, logind keeps it through a restart.
205+ peer.own(t)
206+ peer.next(t)
207+ log.holding(t, 2)
208+ if !released(t, first) {
209+ t.Fatal("the first lock was not closed once the second was taken")
210+ }
211+}
212+
213+// Without logind on the bus, Serve keeps trying until it appears.
214+func TestServeWaitsForLogind(t *testing.T) {
215+ address := startBus(t)
216+ peer := newPeer(t, address)
217+ if _, err := peer.conn.ReleaseName(logind); err != nil {
218+ t.Fatal(err)
219+ }
220+ _, log := run(t, address)
221+ log.wait(t, "ignored until the next attempt", 1)
222+ peer.own(t)
223+ peer.next(t)
224+ log.holding(t, 1)
225+}
226+
227+func TestLostTrustsOnlyTheBus(t *testing.T) {
228+ body := []any{logind, "", ""}
229+ for name, tc := range map[string]struct {
230+ sig *dbus.Signal
231+ want bool
232+ }{
233+ "bus, logind gone": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: body}, true},
234+ "bus, logind moved": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind, "", ":1.5"}}, false},
235+ "bus, other name": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{"x.y", "", ""}}, false},
236+ "client forging it": {&dbus.Signal{Sender: ":1.9", Name: busDaemon + ".NameOwnerChanged", Body: body}, false},
237+ "other signal": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameAcquired", Body: body}, false},
238+ "malformed body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{1, 2, 3}}, false},
239+ "short body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind}}, false},
240+ } {
241+ if got := lost(tc.sig); got != tc.want {
242+ t.Errorf("%s: lost = %v, want %v", name, got, tc.want)
243+ }
244+ }
245+}
@@ -0,0 +1,245 @@
1+package powerkey
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+ "github.com/godbus/dbus/v5"
18+)
19+
20+// startBus starts a dbus-daemon of its own, so tests never touch the
21+// system bus. CI must run these tests: there, a missing daemon fails.
22+func startBus(t *testing.T) string {
23+ t.Helper()
24+ bin, err := exec.LookPath("dbus-daemon")
25+ if err != nil {
26+ if os.Getenv("CI") != "" {
27+ t.Fatal("dbus-daemon not installed")
28+ }
29+ t.Skip("dbus-daemon not installed")
30+ }
31+ socket := filepath.Join(t.TempDir(), "bus")
32+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket)
33+ out, err := cmd.StdoutPipe()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := cmd.Start(); err != nil {
38+ t.Fatal(err)
39+ }
40+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
41+ line, err := bufio.NewReader(out).ReadString('\n')
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ return strings.TrimSpace(line)
46+}
47+
48+// grant is one Inhibit call logind answered.
49+type grant struct {
50+ what, mode string
51+ hold *os.File // our end of the lock: the client has its own copy
52+ released *os.File // reads EOF once every copy of the lock is closed
53+}
54+
55+// logindPeer plays logind on the private bus: it owns the name and answers
56+// Inhibit with a pipe, whose other end shows when the client lets go.
57+type logindPeer struct {
58+ conn *dbus.Conn
59+ grants chan grant
60+}
61+
62+func newPeer(t *testing.T, address string) *logindPeer {
63+ t.Helper()
64+ conn, err := dbus.Connect(address)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ t.Cleanup(func() { _ = conn.Close() })
69+ p := &logindPeer{conn: conn, grants: make(chan grant, 8)}
70+ table := map[string]any{"Inhibit": func(what, who, why, mode string) (dbus.UnixFD, *dbus.Error) {
71+ r, w, err := os.Pipe()
72+ if err != nil {
73+ return 0, dbus.MakeFailedError(err)
74+ }
75+ t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
76+ fd := dbus.UnixFD(w.Fd()) // before the send: the test closes w after it
77+ p.grants <- grant{what: what, mode: mode, hold: w, released: r}
78+ return fd, nil
79+ }}
80+ if err := conn.ExportMethodTable(table, logindPath, manager); err != nil {
81+ t.Fatal(err)
82+ }
83+ p.own(t)
84+ return p
85+}
86+
87+func (p *logindPeer) own(t *testing.T) {
88+ t.Helper()
89+ if r, err := p.conn.RequestName(logind, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
90+ t.Fatal(r, err)
91+ }
92+}
93+
94+func (p *logindPeer) next(t *testing.T) grant {
95+ t.Helper()
96+ select {
97+ case g := <-p.grants:
98+ return g
99+ case <-time.After(5 * time.Second):
100+ t.Fatal("no Inhibit call")
101+ return grant{}
102+ }
103+}
104+
105+// logBuf is a log sink the test reads while Serve writes.
106+type logBuf struct {
107+ mu sync.Mutex
108+ buf bytes.Buffer
109+}
110+
111+func (l *logBuf) Write(p []byte) (int, error) {
112+ l.mu.Lock()
113+ defer l.mu.Unlock()
114+ return l.buf.Write(p)
115+}
116+
117+func (l *logBuf) count(s string) int {
118+ l.mu.Lock()
119+ defer l.mu.Unlock()
120+ return strings.Count(l.buf.String(), s)
121+}
122+
123+// wait blocks until the log has n lines containing msg.
124+func (l *logBuf) wait(t *testing.T, msg string, n int) {
125+ t.Helper()
126+ for deadline := time.Now().Add(5 * time.Second); l.count(msg) < n; time.Sleep(5 * time.Millisecond) {
127+ if time.Now().After(deadline) {
128+ t.Fatalf("log never had %d x %q", n, msg)
129+ }
130+ }
131+}
132+
133+// holding waits until Serve has received n lock answers: only then does it
134+// own the descriptor.
135+func (l *logBuf) holding(t *testing.T, n int) {
136+ t.Helper()
137+ l.wait(t, "holding logind key locks", n)
138+}
139+
140+func run(t *testing.T, address string) (stop func(), log *logBuf) {
141+ t.Helper()
142+ ctx, cancel := context.WithCancel(context.Background())
143+ done := make(chan struct{})
144+ log = &logBuf{}
145+ logger := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: log}).WithField("component", "powerkey")
146+ go func() {
147+ defer close(done)
148+ Serve(ctx, address, 10*time.Millisecond, logger)
149+ }()
150+ var once bool
151+ stop = func() {
152+ if once {
153+ return
154+ }
155+ once = true
156+ cancel()
157+ select {
158+ case <-done:
159+ case <-time.After(5 * time.Second):
160+ t.Error("Serve outlived its context")
161+ }
162+ }
163+ t.Cleanup(stop)
164+ return stop, log
165+}
166+
167+// released reports whether every copy of the lock is closed, once the
168+// peer's own copy is.
169+func released(t *testing.T, g grant) bool {
170+ t.Helper()
171+ _ = g.hold.Close()
172+ _ = g.released.SetReadDeadline(time.Now().Add(2 * time.Second))
173+ _, err := io.ReadAll(g.released)
174+ return err == nil
175+}
176+
177+// Serve blocks the four key handlers and lets go of them when it stops.
178+func TestServeHoldsAndReleasesTheLock(t *testing.T) {
179+ address := startBus(t)
180+ peer := newPeer(t, address)
181+ stop, log := run(t, address)
182+ g := peer.next(t)
183+ log.holding(t, 1)
184+ if g.what != what || g.mode != "block" {
185+ t.Fatalf("Inhibit(%q, %q)", g.what, g.mode)
186+ }
187+ stop()
188+ if !released(t, g) {
189+ t.Fatal("the lock outlived Serve")
190+ }
191+}
192+
193+// When logind restarts, the locks are taken again and the old ones closed.
194+func TestServeTakesTheLockAgainAfterLogindRestarts(t *testing.T) {
195+ address := startBus(t)
196+ peer := newPeer(t, address)
197+ _, log := run(t, address)
198+ first := peer.next(t)
199+ log.holding(t, 1)
200+ if _, err := peer.conn.ReleaseName(logind); err != nil {
201+ t.Fatal(err)
202+ }
203+ log.wait(t, "ignored until the next attempt", 1)
204+ // The gap: the old lock is still ours, logind keeps it through a restart.
205+ peer.own(t)
206+ peer.next(t)
207+ log.holding(t, 2)
208+ if !released(t, first) {
209+ t.Fatal("the first lock was not closed once the second was taken")
210+ }
211+}
212+
213+// Without logind on the bus, Serve keeps trying until it appears.
214+func TestServeWaitsForLogind(t *testing.T) {
215+ address := startBus(t)
216+ peer := newPeer(t, address)
217+ if _, err := peer.conn.ReleaseName(logind); err != nil {
218+ t.Fatal(err)
219+ }
220+ _, log := run(t, address)
221+ log.wait(t, "ignored until the next attempt", 1)
222+ peer.own(t)
223+ peer.next(t)
224+ log.holding(t, 1)
225+}
226+
227+func TestLostTrustsOnlyTheBus(t *testing.T) {
228+ body := []any{logind, "", ""}
229+ for name, tc := range map[string]struct {
230+ sig *dbus.Signal
231+ want bool
232+ }{
233+ "bus, logind gone": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: body}, true},
234+ "bus, logind moved": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind, "", ":1.5"}}, false},
235+ "bus, other name": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{"x.y", "", ""}}, false},
236+ "client forging it": {&dbus.Signal{Sender: ":1.9", Name: busDaemon + ".NameOwnerChanged", Body: body}, false},
237+ "other signal": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameAcquired", Body: body}, false},
238+ "malformed body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{1, 2, 3}}, false},
239+ "short body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind}}, false},
240+ } {
241+ if got := lost(tc.sig); got != tc.want {
242+ t.Errorf("%s: lost = %v, want %v", name, got, tc.want)
243+ }
244+ }
245+}
M internal/adapters/screensaver/screensaver.go +7 -33
@@ -12,6 +12,7 @@ import (
1212 "strings"
1313 "time"
1414
15+ "github.com/bnema/neferwl/internal/adapters/busretry"
1516 "github.com/bnema/zerowrap"
1617 "github.com/godbus/dbus/v5"
1718 "github.com/godbus/dbus/v5/introspect"
@@ -25,8 +26,6 @@ const (
2526 busDaemon = "org.freedesktop.DBus"
2627 // callTimeout bounds the bus calls Run makes itself.
2728 callTimeout = 3 * time.Second
28- // retryMax bounds the delay between Serve's attempts.
29- retryMax = time.Minute
3029 )
3130
3231 // ErrNameTaken is returned by Run when another program owns the name.
@@ -54,40 +53,15 @@ type Reports struct {
5453
5554 // Serve runs the service until ctx ends. When the bus goes or another
5655 // program owns the name, it tries again after retry, doubling up to
57-// retryMax, so a restarted bus or a released name is picked up.
56+// a minute, so a restarted bus or a released name is picked up.
5857 func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) {
59- delay := retry
60- var last string
61- for {
62- start := time.Now()
58+ busretry.Run(ctx, retry, "D-Bus idle inhibitors", log, func(ctx context.Context) error {
6359 s, err := New(ctx, address, log)
64- if err == nil {
65- err = s.Run(ctx, r)
66- }
67- if ctx.Err() != nil {
68- return
69- }
70- if err == nil {
71- err = errors.New("screensaver service stopped")
60+ if err != nil {
61+ return err
7262 }
73- if time.Since(start) > retryMax {
74- // It served a while: the failure is new, not a retry streak.
75- delay, last = retry, ""
76- }
77- // Warn once per kind of failure, not on every attempt.
78- if msg := err.Error(); msg != last {
79- last = msg
80- log.Warn().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors ignored until the next attempt")
81- } else {
82- log.Debug().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors still ignored")
83- }
84- select {
85- case <-ctx.Done():
86- return
87- case <-time.After(delay):
88- }
89- delay = min(2*delay, retryMax)
90- }
63+ return s.Run(ctx, r)
64+ })
9165 }
9266
9367 // Service owns one private session bus connection. Run must be called once.
@@ -12,6 +12,7 @@ import (
12 "strings"12 "strings"
13 "time"13 "time"
14 14
15+ "github.com/bnema/neferwl/internal/adapters/busretry"
15 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
16 "github.com/godbus/dbus/v5"17 "github.com/godbus/dbus/v5"
17 "github.com/godbus/dbus/v5/introspect"18 "github.com/godbus/dbus/v5/introspect"
@@ -25,8 +26,6 @@ const (
25 busDaemon = "org.freedesktop.DBus"26 busDaemon = "org.freedesktop.DBus"
26 // callTimeout bounds the bus calls Run makes itself.27 // callTimeout bounds the bus calls Run makes itself.
27 callTimeout = 3 * time.Second28 callTimeout = 3 * time.Second
28- // retryMax bounds the delay between Serve's attempts.
29- retryMax = time.Minute
30 )29 )
31 30
32 // ErrNameTaken is returned by Run when another program owns the name.31 // ErrNameTaken is returned by Run when another program owns the name.
@@ -54,40 +53,15 @@ type Reports struct {
54 53
55 // Serve runs the service until ctx ends. When the bus goes or another54 // Serve runs the service until ctx ends. When the bus goes or another
56 // program owns the name, it tries again after retry, doubling up to55 // program owns the name, it tries again after retry, doubling up to
57-// retryMax, so a restarted bus or a released name is picked up.56+// a minute, so a restarted bus or a released name is picked up.
58 func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) {57 func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) {
59- delay := retry58+ busretry.Run(ctx, retry, "D-Bus idle inhibitors", log, func(ctx context.Context) error {
60- var last string
61- for {
62- start := time.Now()
63 s, err := New(ctx, address, log)59 s, err := New(ctx, address, log)
64- if err == nil {60+ if err != nil {
65- err = s.Run(ctx, r)61+ return err
66- }
67- if ctx.Err() != nil {
68- return
69- }
70- if err == nil {
71- err = errors.New("screensaver service stopped")
72 }62 }
73- if time.Since(start) > retryMax {63+ return s.Run(ctx, r)
74- // It served a while: the failure is new, not a retry streak.64+ })
75- delay, last = retry, ""
76- }
77- // Warn once per kind of failure, not on every attempt.
78- if msg := err.Error(); msg != last {
79- last = msg
80- log.Warn().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors ignored until the next attempt")
81- } else {
82- log.Debug().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors still ignored")
83- }
84- select {
85- case <-ctx.Done():
86- return
87- case <-time.After(delay):
88- }
89- delay = min(2*delay, retryMax)
90- }
91 }65 }
92 66
93 // Service owns one private session bus connection. Run must be called once.67 // Service owns one private session bus connection. Run must be called once.
M internal/app/app.go +5 -4
@@ -65,10 +65,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
6565 return s.run(ctx, inject)
6666 }
6767
68-// screensaverRetry is the first delay before the screensaver service tries
69-// the session bus again: without one, only surface inhibitors count, and
70-// the session goes on either way.
71-const screensaverRetry = 2 * time.Second
68+// busRetry is the first delay before a bus service (the screensaver, the
69+// logind key lock) tries its bus again: without it, only surface inhibitors
70+// count and the power keys keep logind's behaviour, and the session goes on
71+// either way.
72+const busRetry = 2 * time.Second
7273
7374 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
7475 // to the input goroutine and sends it to clients; a repeat-only change just updates
@@ -65,10 +65,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
65 return s.run(ctx, inject)65 return s.run(ctx, inject)
66 }66 }
67 67
68-// screensaverRetry is the first delay before the screensaver service tries68+// busRetry is the first delay before a bus service (the screensaver, the
69-// the session bus again: without one, only surface inhibitors count, and69+// logind key lock) tries its bus again: without it, only surface inhibitors
70-// the session goes on either way.70+// count and the power keys keep logind's behaviour, and the session goes on
71-const screensaverRetry = 2 * time.Second71+// either way.
72+const busRetry = 2 * time.Second
72 73
73 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it74 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
74 // to the input goroutine and sends it to clients; a repeat-only change just updates75 // to the input goroutine and sends it to clients; a repeat-only change just updates
M internal/app/session.go +9 -1
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/neferwl/internal/adapters/launcher"
1818 "github.com/bnema/neferwl/internal/adapters/libinput"
1919 "github.com/bnema/neferwl/internal/adapters/logging"
20+ "github.com/bnema/neferwl/internal/adapters/powerkey"
2021 "github.com/bnema/neferwl/internal/adapters/sched"
2122 "github.com/bnema/neferwl/internal/adapters/screensaver"
2223 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
@@ -360,7 +361,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
360361 go func() {
361362 defer workers.Done()
362363 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
363- screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
364+ screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
365+ }()
366+ // The same goes for the system bus: logind's locks keep the power
367+ // keys from acting before a bind sees them.
368+ workers.Add(1)
369+ go func() {
370+ defer workers.Done()
371+ powerkey.Serve(ctx, "", busRetry, logging.For(ctx, "powerkey"))
364372 }()
365373 }
366374 var result error
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/neferwl/internal/adapters/launcher"17 "github.com/bnema/neferwl/internal/adapters/launcher"
18 "github.com/bnema/neferwl/internal/adapters/libinput"18 "github.com/bnema/neferwl/internal/adapters/libinput"
19 "github.com/bnema/neferwl/internal/adapters/logging"19 "github.com/bnema/neferwl/internal/adapters/logging"
20+ "github.com/bnema/neferwl/internal/adapters/powerkey"
20 "github.com/bnema/neferwl/internal/adapters/sched"21 "github.com/bnema/neferwl/internal/adapters/sched"
21 "github.com/bnema/neferwl/internal/adapters/screensaver"22 "github.com/bnema/neferwl/internal/adapters/screensaver"
22 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"23 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
@@ -360,7 +361,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
360 go func() {361 go func() {
361 defer workers.Done()362 defer workers.Done()
362 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}363 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
363- screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))364+ screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
365+ }()
366+ // The same goes for the system bus: logind's locks keep the power
367+ // keys from acting before a bind sees them.
368+ workers.Add(1)
369+ go func() {
370+ defer workers.Done()
371+ powerkey.Serve(ctx, "", busRetry, logging.For(ctx, "powerkey"))
364 }()372 }()
365 }373 }
366 var result error374 var result error

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 26

Tags 10

↑↓ moveenter openesc close