Merge pull request 'feat(powerkey): hold logind key locks so binds decide power, sleep and reboot keys' (#20) from feat/powerkey-inhibit into main
10 files · +529 -40
M Makefile +1 -1
| @@ -61,7 +61,7 @@ arch: adapter-imports | |||
| 61 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 63 | # 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 | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | 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 shared | 61 | # 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|xkb | 64 | + | 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 | |||
| 100 | 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 | 121 | ## Bars | |
| 104 | 122 | ||
| 105 | 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. | |
| @@ -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 | ## Bars | 121 | ## 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 ( | |||
| 18 | 18 | var components = map[string]bool{ | |
| 19 | 19 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | |
| 20 | 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 | 24 | // 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 when | 24 | // 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 ( | |||
| 12 | 12 | "strings" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/busretry" | |
| 15 | 16 | "github.com/bnema/zerowrap" | |
| 16 | 17 | "github.com/godbus/dbus/v5" | |
| 17 | 18 | "github.com/godbus/dbus/v5/introspect" | |
| @@ -25,8 +26,6 @@ const ( | |||
| 25 | 26 | busDaemon = "org.freedesktop.DBus" | |
| 26 | 27 | // callTimeout bounds the bus calls Run makes itself. | |
| 27 | 28 | callTimeout = 3 * time.Second | |
| 28 | - | // retryMax bounds the delay between Serve's attempts. | |
| 29 | - | retryMax = time.Minute | |
| 30 | 29 | ) | |
| 31 | 30 | ||
| 32 | 31 | // ErrNameTaken is returned by Run when another program owns the name. | |
| @@ -54,40 +53,15 @@ type Reports struct { | |||
| 54 | 53 | ||
| 55 | 54 | // Serve runs the service until ctx ends. When the bus goes or another | |
| 56 | 55 | // 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 | 57 | 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 { | |
| 63 | 59 | 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 | |
| 72 | 62 | } | |
| 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 | + | }) | |
| 91 | 65 | } | |
| 92 | 66 | ||
| 93 | 67 | // 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.Second | 28 | 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 another | 54 | // 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 to | 55 | // 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 := retry | 58 | + | 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) | |||
| 65 | 65 | return s.run(ctx, inject) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 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 | |
| 72 | 73 | ||
| 73 | 74 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | |
| 74 | 75 | // 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 tries | 68 | + | // busRetry is the first delay before a bus service (the screensaver, the |
| 69 | - | // the session bus again: without one, only surface inhibitors count, and | 69 | + | // 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.Second | 71 | + | // either way. |
| 72 | + | const busRetry = 2 * time.Second | |||
| 72 | 73 | ||||
| 73 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | 74 | // 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 updates | 75 | // 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 ( | |||
| 17 | 17 | "github.com/bnema/neferwl/internal/adapters/launcher" | |
| 18 | 18 | "github.com/bnema/neferwl/internal/adapters/libinput" | |
| 19 | 19 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 20 | + | "github.com/bnema/neferwl/internal/adapters/powerkey" | |
| 20 | 21 | "github.com/bnema/neferwl/internal/adapters/sched" | |
| 21 | 22 | "github.com/bnema/neferwl/internal/adapters/screensaver" | |
| 22 | 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 | 361 | go func() { | |
| 361 | 362 | defer workers.Done() | |
| 362 | 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 | 374 | 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 error | 374 | var result error | ||