fix(power): keep an output that reconnects while off turned off (#107)
* fix(power): keep an output that reconnects while off turned off * fix(drm): start an output that reconnects while off without lighting it up * fix(drm): probe tearing once an output that started off turns on * test(app): check the output set remembers which outputs are off
- commit
- f28b0f0ac3c490daadace326f2688a2a30fa9d30
- author
- bnema · · 2026-10-03 12:51 UTC
- committer
- GitHub · · 2026-10-03 12:51 UTC
- parent
- b9da8c3
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
11 files · +208 -12
M docs/desktop.md +1 -1
| @@ -77,7 +77,7 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext | |||
| 77 | 77 | ||
| 78 | 78 | ## Idle and screen off | |
| 79 | 79 | ||
| 80 | - | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | |
| 80 | + | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. A display that disconnects in deep sleep comes back off when it reconnects, unless there was keyboard, pointer or touch input in the meantime. | |
| 81 | 81 | ||
| 82 | 82 | Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back. | |
| 83 | 83 | ||
| @@ -77,7 +77,7 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext | |||||
| 77 | 77 | ||||
| 78 | ## Idle and screen off | 78 | ## Idle and screen off | ||
| 79 | 79 | ||||
| 80 | - | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | 80 | + | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. A display that disconnects in deep sleep comes back off when it reconnects, unless there was keyboard, pointer or touch input in the meantime. |
| 81 | 81 | ||||
| 82 | Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back. | 82 | Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back. | ||
| 83 | 83 | ||||
M internal/adapters/drm/output.go +7 -0
| @@ -73,6 +73,9 @@ type Output struct { | |||
| 73 | 73 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | |
| 74 | 74 | // Set before Run. | |
| 75 | 75 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | |
| 76 | + | // StartOff, set before Run, keeps the display off until a scene turns | |
| 77 | + | // it on: a display that reconnects while turned off never lights up. | |
| 78 | + | StartOff bool | |
| 76 | 79 | // capHidden limits a report to what the child renderer of a hidden | |
| 77 | 80 | // workspace was given, window by window, while it may still read (its | |
| 78 | 81 | // device is not ordered with the display's fences). capped is set when | |
| @@ -888,6 +891,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 888 | 891 | } | |
| 889 | 892 | }() | |
| 890 | 893 | o.runContext = ctx | |
| 894 | + | o.wantOff = o.StartOff | |
| 891 | 895 | o.observeSecurity() | |
| 892 | 896 | r, err := newRenderer(o.Width(), o.Height()) | |
| 893 | 897 | if err != nil { | |
| @@ -1300,6 +1304,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1300 | 1304 | err = o.powerOff() | |
| 1301 | 1305 | } else if err = o.modeset(); err == nil { | |
| 1302 | 1306 | dirty = true | |
| 1307 | + | if !o.protected { | |
| 1308 | + | o.probeAsync(r) // skipped while it started off | |
| 1309 | + | } | |
| 1303 | 1310 | } | |
| 1304 | 1311 | if err != nil && !o.commitFailed(err, &enabled) { | |
| 1305 | 1312 | return err | |
| @@ -73,6 +73,9 @@ type Output struct { | |||||
| 73 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | 73 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | ||
| 74 | // Set before Run. | 74 | // Set before Run. | ||
| 75 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | 75 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | ||
| 76 | + | // StartOff, set before Run, keeps the display off until a scene turns | |||
| 77 | + | // it on: a display that reconnects while turned off never lights up. | |||
| 78 | + | StartOff bool | |||
| 76 | // capHidden limits a report to what the child renderer of a hidden | 79 | // capHidden limits a report to what the child renderer of a hidden | ||
| 77 | // workspace was given, window by window, while it may still read (its | 80 | // workspace was given, window by window, while it may still read (its | ||
| 78 | // device is not ordered with the display's fences). capped is set when | 81 | // device is not ordered with the display's fences). capped is set when | ||
| @@ -888,6 +891,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 888 | } | 891 | } | ||
| 889 | }() | 892 | }() | ||
| 890 | o.runContext = ctx | 893 | o.runContext = ctx | ||
| 894 | + | o.wantOff = o.StartOff | |||
| 891 | o.observeSecurity() | 895 | o.observeSecurity() | ||
| 892 | r, err := newRenderer(o.Width(), o.Height()) | 896 | r, err := newRenderer(o.Width(), o.Height()) | ||
| 893 | if err != nil { | 897 | if err != nil { | ||
| @@ -1300,6 +1304,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1300 | err = o.powerOff() | 1304 | err = o.powerOff() | ||
| 1301 | } else if err = o.modeset(); err == nil { | 1305 | } else if err = o.modeset(); err == nil { | ||
| 1302 | dirty = true | 1306 | dirty = true | ||
| 1307 | + | if !o.protected { | |||
| 1308 | + | o.probeAsync(r) // skipped while it started off | |||
| 1309 | + | } | |||
| 1303 | } | 1310 | } | ||
| 1304 | if err != nil && !o.commitFailed(err, &enabled) { | 1311 | if err != nil && !o.commitFailed(err, &enabled) { | ||
| 1305 | return err | 1312 | return err | ||
M internal/adapters/drm/power_test.go +83 -0
| @@ -116,3 +116,86 @@ func TestRunOutputPower(t *testing.T) { | |||
| 116 | 116 | t.Fatal(err) | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | + | ||
| 120 | + | // An output started off (it reconnected while turned off) never activates | |
| 121 | + | // its CRTC before a scene turns it on; its tearing probe, refused by an | |
| 122 | + | // inactive CRTC, waits until then. | |
| 123 | + | func TestRunOutputStartOff(t *testing.T) { | |
| 124 | + | o, k, commits, commitMu := testOutputMu(t) | |
| 125 | + | o.tearing, o.cursor = true, nil | |
| 126 | + | o.StartOff = true | |
| 127 | + | ready := make(chan error, 1) | |
| 128 | + | o.ready = ready | |
| 129 | + | r := portsmocks.NewMockRenderer(t) | |
| 130 | + | buf := func() ports.DMABuf { | |
| 131 | + | f, w, _ := os.Pipe() | |
| 132 | + | w.Close() | |
| 133 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 134 | + | } | |
| 135 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 136 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 137 | + | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |
| 138 | + | renders := make(chan struct{}, 8) | |
| 139 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 140 | + | renders <- struct{}{} | |
| 141 | + | return nil, nil | |
| 142 | + | }).Maybe() | |
| 143 | + | r.EXPECT().Close().Return().Once() | |
| 144 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 145 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 146 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |
| 147 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 148 | + | snapshot := func() []commitRec { | |
| 149 | + | commitMu.Lock() | |
| 150 | + | defer commitMu.Unlock() | |
| 151 | + | return append([]commitRec(nil), *commits...) | |
| 152 | + | } | |
| 153 | + | lit := func(cs []commitRec) bool { | |
| 154 | + | for _, c := range cs { | |
| 155 | + | if v, ok := c.req.value(tCrtc, pActive); ok && v != 0 && c.flags&atomicTestOnly == 0 { | |
| 156 | + | return true | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | return false | |
| 160 | + | } | |
| 161 | + | probed := func(cs []commitRec) bool { | |
| 162 | + | for _, c := range cs { | |
| 163 | + | if c.flags == atomicTestOnly|flipAsyncFlag { | |
| 164 | + | return true | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return false | |
| 168 | + | } | |
| 169 | + | scenes := make(chan ports.Scene, 1) | |
| 170 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 171 | + | done := make(chan error, 1) | |
| 172 | + | go func() { | |
| 173 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 174 | + | }() | |
| 175 | + | if err := <-ready; err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | for len(renders) > 0 { // the images are cleared at setup | |
| 179 | + | <-renders | |
| 180 | + | } | |
| 181 | + | off := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1, Off: true} | |
| 182 | + | scenes <- off | |
| 183 | + | time.Sleep(20 * time.Millisecond) | |
| 184 | + | if lit(snapshot()) { | |
| 185 | + | t.Fatal("a display started off lit up") | |
| 186 | + | } | |
| 187 | + | if probed(snapshot()) { | |
| 188 | + | t.Fatal("tearing probed while off") | |
| 189 | + | } | |
| 190 | + | if len(renders) > 0 { | |
| 191 | + | t.Fatal("rendered while off") | |
| 192 | + | } | |
| 193 | + | on := off | |
| 194 | + | on.Off = false | |
| 195 | + | scenes <- on | |
| 196 | + | waitFor(t, func() bool { return lit(snapshot()) && probed(snapshot()) }) | |
| 197 | + | cancel() | |
| 198 | + | if err := <-done; err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | } | |
| @@ -116,3 +116,86 @@ func TestRunOutputPower(t *testing.T) { | |||||
| 116 | t.Fatal(err) | 116 | t.Fatal(err) | ||
| 117 | } | 117 | } | ||
| 118 | } | 118 | } | ||
| 119 | + | ||||
| 120 | + | // An output started off (it reconnected while turned off) never activates | |||
| 121 | + | // its CRTC before a scene turns it on; its tearing probe, refused by an | |||
| 122 | + | // inactive CRTC, waits until then. | |||
| 123 | + | func TestRunOutputStartOff(t *testing.T) { | |||
| 124 | + | o, k, commits, commitMu := testOutputMu(t) | |||
| 125 | + | o.tearing, o.cursor = true, nil | |||
| 126 | + | o.StartOff = true | |||
| 127 | + | ready := make(chan error, 1) | |||
| 128 | + | o.ready = ready | |||
| 129 | + | r := portsmocks.NewMockRenderer(t) | |||
| 130 | + | buf := func() ports.DMABuf { | |||
| 131 | + | f, w, _ := os.Pipe() | |||
| 132 | + | w.Close() | |||
| 133 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 134 | + | } | |||
| 135 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 136 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 137 | + | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |||
| 138 | + | renders := make(chan struct{}, 8) | |||
| 139 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 140 | + | renders <- struct{}{} | |||
| 141 | + | return nil, nil | |||
| 142 | + | }).Maybe() | |||
| 143 | + | r.EXPECT().Close().Return().Once() | |||
| 144 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |||
| 145 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 146 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 147 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 148 | + | snapshot := func() []commitRec { | |||
| 149 | + | commitMu.Lock() | |||
| 150 | + | defer commitMu.Unlock() | |||
| 151 | + | return append([]commitRec(nil), *commits...) | |||
| 152 | + | } | |||
| 153 | + | lit := func(cs []commitRec) bool { | |||
| 154 | + | for _, c := range cs { | |||
| 155 | + | if v, ok := c.req.value(tCrtc, pActive); ok && v != 0 && c.flags&atomicTestOnly == 0 { | |||
| 156 | + | return true | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | return false | |||
| 160 | + | } | |||
| 161 | + | probed := func(cs []commitRec) bool { | |||
| 162 | + | for _, c := range cs { | |||
| 163 | + | if c.flags == atomicTestOnly|flipAsyncFlag { | |||
| 164 | + | return true | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return false | |||
| 168 | + | } | |||
| 169 | + | scenes := make(chan ports.Scene, 1) | |||
| 170 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 171 | + | done := make(chan error, 1) | |||
| 172 | + | go func() { | |||
| 173 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 174 | + | }() | |||
| 175 | + | if err := <-ready; err != nil { | |||
| 176 | + | t.Fatal(err) | |||
| 177 | + | } | |||
| 178 | + | for len(renders) > 0 { // the images are cleared at setup | |||
| 179 | + | <-renders | |||
| 180 | + | } | |||
| 181 | + | off := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1, Off: true} | |||
| 182 | + | scenes <- off | |||
| 183 | + | time.Sleep(20 * time.Millisecond) | |||
| 184 | + | if lit(snapshot()) { | |||
| 185 | + | t.Fatal("a display started off lit up") | |||
| 186 | + | } | |||
| 187 | + | if probed(snapshot()) { | |||
| 188 | + | t.Fatal("tearing probed while off") | |||
| 189 | + | } | |||
| 190 | + | if len(renders) > 0 { | |||
| 191 | + | t.Fatal("rendered while off") | |||
| 192 | + | } | |||
| 193 | + | on := off | |||
| 194 | + | on.Off = false | |||
| 195 | + | scenes <- on | |||
| 196 | + | waitFor(t, func() bool { return lit(snapshot()) && probed(snapshot()) }) | |||
| 197 | + | cancel() | |||
| 198 | + | if err := <-done; err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | } | |||
M internal/adapters/drm/vrr.go +3 -2
| @@ -8,9 +8,10 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // Tearing, variable refresh and content type of frame commits. | |
| 10 | 10 | ||
| 11 | - | // probeAsync checks once whether async commits may carry IN_FENCE_FD. | |
| 11 | + | // probeAsync checks once whether async commits may carry IN_FENCE_FD. An | |
| 12 | + | // inactive CRTC refuses async flips: the probe waits until it is on. | |
| 12 | 13 | func (o *Output) probeAsync(r ports.Renderer) { | |
| 13 | - | if o.asyncProbed || !o.tearing { | |
| 14 | + | if o.asyncProbed || !o.tearing || o.off { | |
| 14 | 15 | return | |
| 15 | 16 | } | |
| 16 | 17 | o.asyncProbed = true | |
| @@ -8,9 +8,10 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // Tearing, variable refresh and content type of frame commits. | 9 | // Tearing, variable refresh and content type of frame commits. | ||
| 10 | 10 | ||||
| 11 | - | // probeAsync checks once whether async commits may carry IN_FENCE_FD. | 11 | + | // probeAsync checks once whether async commits may carry IN_FENCE_FD. An |
| 12 | + | // inactive CRTC refuses async flips: the probe waits until it is on. | |||
| 12 | func (o *Output) probeAsync(r ports.Renderer) { | 13 | func (o *Output) probeAsync(r ports.Renderer) { | ||
| 13 | - | if o.asyncProbed || !o.tearing { | 14 | + | if o.asyncProbed || !o.tearing || o.off { |
| 14 | return | 15 | return | ||
| 15 | } | 16 | } | ||
| 16 | o.asyncProbed = true | 17 | o.asyncProbed = true | ||
M internal/app/drm.go +1 -0
| @@ -471,6 +471,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 471 | 471 | } | |
| 472 | 472 | }() | |
| 473 | 473 | o.NewCaptureRenderer = newRenderer | |
| 474 | + | o.StartOff = set.off[name] | |
| 474 | 475 | progress.started(name, source) | |
| 475 | 476 | if cur := o.Cursor(); cur != nil { | |
| 476 | 477 | curs.set(name, cur) | |
| @@ -471,6 +471,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 471 | } | 471 | } | ||
| 472 | }() | 472 | }() | ||
| 473 | o.NewCaptureRenderer = newRenderer | 473 | o.NewCaptureRenderer = newRenderer | ||
| 474 | + | o.StartOff = set.off[name] | |||
| 474 | progress.started(name, source) | 475 | progress.started(name, source) | ||
| 475 | if cur := o.Cursor(); cur != nil { | 476 | if cur := o.Cursor(); cur != nil { | ||
| 476 | curs.set(name, cur) | 477 | curs.set(name, cur) | ||
M internal/app/outputrouting_test.go +14 -0
| @@ -70,6 +70,20 @@ func TestOutputRouting(t *testing.T) { | |||
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | // The set remembers which outputs scenes turned off, also once they are | |
| 74 | + | // gone: drm starts an output that reconnects off (Output.StartOff). | |
| 75 | + | func TestOutputSetRemembersOff(t *testing.T) { | |
| 76 | + | set := newOutputSet(context.Background(), nil) | |
| 77 | + | set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}}) | |
| 78 | + | if !set.off["A"] || set.off["B"] { | |
| 79 | + | t.Fatal(set.off) | |
| 80 | + | } | |
| 81 | + | set.scenes([]ports.Scene{{Output: "A"}}) | |
| 82 | + | if len(set.off) != 0 { | |
| 83 | + | t.Fatal(set.off) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 73 | 87 | func TestOutputRoutingAllocations(t *testing.T) { | |
| 74 | 88 | set := newOutputSet(context.Background(), nil) | |
| 75 | 89 | // Populate the existing content slot before measuring, so the guard | |
| @@ -70,6 +70,20 @@ func TestOutputRouting(t *testing.T) { | |||||
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | // The set remembers which outputs scenes turned off, also once they are | |||
| 74 | + | // gone: drm starts an output that reconnects off (Output.StartOff). | |||
| 75 | + | func TestOutputSetRemembersOff(t *testing.T) { | |||
| 76 | + | set := newOutputSet(context.Background(), nil) | |||
| 77 | + | set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}}) | |||
| 78 | + | if !set.off["A"] || set.off["B"] { | |||
| 79 | + | t.Fatal(set.off) | |||
| 80 | + | } | |||
| 81 | + | set.scenes([]ports.Scene{{Output: "A"}}) | |||
| 82 | + | if len(set.off) != 0 { | |||
| 83 | + | t.Fatal(set.off) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 73 | func TestOutputRoutingAllocations(t *testing.T) { | 87 | func TestOutputRoutingAllocations(t *testing.T) { | ||
| 74 | set := newOutputSet(context.Background(), nil) | 88 | set := newOutputSet(context.Background(), nil) | ||
| 75 | // Populate the existing content slot before measuring, so the guard | 89 | // Populate the existing content slot before measuring, so the guard | ||
M internal/app/outputs.go +11 -3
| @@ -21,8 +21,11 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c | |||
| 21 | 21 | // each window is kept so a new output starts with every window drawn. | |
| 22 | 22 | // Only the goroutine running loop touches the set. | |
| 23 | 23 | type outputSet struct { | |
| 24 | - | outs map[string]*runningOutput | |
| 25 | - | latest map[ports.WindowID]ports.SurfaceContent | |
| 24 | + | outs map[string]*runningOutput | |
| 25 | + | latest map[ports.WindowID]ports.SurfaceContent | |
| 26 | + | // off is the latest Scene.Off per output name, kept after the output | |
| 27 | + | // goes: it reconnects off until core's first scene (Output.StartOff). | |
| 28 | + | off map[string]bool | |
| 26 | 29 | cursor ports.CursorChange | |
| 27 | 30 | stopped chan outputStopped | |
| 28 | 31 | captured chan<- ports.CaptureDone | |
| @@ -59,7 +62,7 @@ type runningOutput struct { | |||
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | |
| 62 | - | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})} | |
| 65 | + | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})} | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | // start runs an output in its own goroutine and replays the window contents. | |
| @@ -211,6 +214,11 @@ func (s *outputSet) setCursor(c ports.CursorChange) { | |||
| 211 | 214 | // scenes routes each scene to its output, replacing an unread one. | |
| 212 | 215 | func (s *outputSet) scenes(set []ports.Scene) { | |
| 213 | 216 | for _, sc := range set { | |
| 217 | + | if sc.Off { | |
| 218 | + | s.off[sc.Output] = true | |
| 219 | + | } else { | |
| 220 | + | delete(s.off, sc.Output) | |
| 221 | + | } | |
| 214 | 222 | if r := s.outs[sc.Output]; r != nil { | |
| 215 | 223 | select { | |
| 216 | 224 | case <-r.scenes: | |
| @@ -21,8 +21,11 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c | |||||
| 21 | // each window is kept so a new output starts with every window drawn. | 21 | // each window is kept so a new output starts with every window drawn. | ||
| 22 | // Only the goroutine running loop touches the set. | 22 | // Only the goroutine running loop touches the set. | ||
| 23 | type outputSet struct { | 23 | type outputSet struct { | ||
| 24 | - | outs map[string]*runningOutput | 24 | + | outs map[string]*runningOutput |
| 25 | - | latest map[ports.WindowID]ports.SurfaceContent | 25 | + | latest map[ports.WindowID]ports.SurfaceContent |
| 26 | + | // off is the latest Scene.Off per output name, kept after the output | |||
| 27 | + | // goes: it reconnects off until core's first scene (Output.StartOff). | |||
| 28 | + | off map[string]bool | |||
| 26 | cursor ports.CursorChange | 29 | cursor ports.CursorChange | ||
| 27 | stopped chan outputStopped | 30 | stopped chan outputStopped | ||
| 28 | captured chan<- ports.CaptureDone | 31 | captured chan<- ports.CaptureDone | ||
| @@ -59,7 +62,7 @@ type runningOutput struct { | |||||
| 59 | } | 62 | } | ||
| 60 | 63 | ||||
| 61 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | 64 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | ||
| 62 | - | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})} | 65 | + | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})} |
| 63 | } | 66 | } | ||
| 64 | 67 | ||||
| 65 | // start runs an output in its own goroutine and replays the window contents. | 68 | // start runs an output in its own goroutine and replays the window contents. | ||
| @@ -211,6 +214,11 @@ func (s *outputSet) setCursor(c ports.CursorChange) { | |||||
| 211 | // scenes routes each scene to its output, replacing an unread one. | 214 | // scenes routes each scene to its output, replacing an unread one. | ||
| 212 | func (s *outputSet) scenes(set []ports.Scene) { | 215 | func (s *outputSet) scenes(set []ports.Scene) { | ||
| 213 | for _, sc := range set { | 216 | for _, sc := range set { | ||
| 217 | + | if sc.Off { | |||
| 218 | + | s.off[sc.Output] = true | |||
| 219 | + | } else { | |||
| 220 | + | delete(s.off, sc.Output) | |||
| 221 | + | } | |||
| 214 | if r := s.outs[sc.Output]; r != nil { | 222 | if r := s.outs[sc.Output]; r != nil { | ||
| 215 | select { | 223 | select { | ||
| 216 | case <-r.scenes: | 224 | case <-r.scenes: | ||
M internal/core/core.go +9 -6
| @@ -79,12 +79,15 @@ type Core struct { | |||
| 79 | 79 | cfg ports.Config | |
| 80 | 80 | screens []*screen | |
| 81 | 81 | focusScreen int | |
| 82 | - | binds map[binding]Action | |
| 83 | - | pressed map[string]bool | |
| 84 | - | configures configures | |
| 85 | - | pointer WindowID | |
| 86 | - | grab WindowID | |
| 87 | - | buttons map[uint32]bool | |
| 82 | + | // offGone names outputs unplugged while turned off: a display that | |
| 83 | + | // reconnects from deep sleep comes back off, until the next input. | |
| 84 | + | offGone map[string]bool | |
| 85 | + | binds map[binding]Action | |
| 86 | + | pressed map[string]bool | |
| 87 | + | configures configures | |
| 88 | + | pointer WindowID | |
| 89 | + | grab WindowID | |
| 90 | + | buttons map[uint32]bool | |
| 88 | 91 | // drag is the pointer drag in progress (drag.go); swallow holds the | |
| 89 | 92 | // buttons whose release no client must see. mods are the modifiers | |
| 90 | 93 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | |
| @@ -79,12 +79,15 @@ type Core struct { | |||||
| 79 | cfg ports.Config | 79 | cfg ports.Config | ||
| 80 | screens []*screen | 80 | screens []*screen | ||
| 81 | focusScreen int | 81 | focusScreen int | ||
| 82 | - | binds map[binding]Action | 82 | + | // offGone names outputs unplugged while turned off: a display that |
| 83 | - | pressed map[string]bool | 83 | + | // reconnects from deep sleep comes back off, until the next input. |
| 84 | - | configures configures | 84 | + | offGone map[string]bool |
| 85 | - | pointer WindowID | 85 | + | binds map[binding]Action |
| 86 | - | grab WindowID | 86 | + | pressed map[string]bool |
| 87 | - | buttons map[uint32]bool | 87 | + | configures configures |
| 88 | + | pointer WindowID | |||
| 89 | + | grab WindowID | |||
| 90 | + | buttons map[uint32]bool | |||
| 88 | // drag is the pointer drag in progress (drag.go); swallow holds the | 91 | // drag is the pointer drag in progress (drag.go); swallow holds the | ||
| 89 | // buttons whose release no client must see. mods are the modifiers | 92 | // buttons whose release no client must see. mods are the modifiers | ||
| 90 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | 93 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | ||
M internal/core/power.go +3 -0
| @@ -9,7 +9,10 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | // userActivity tells wayland the user touched an input device, at most | |
| 11 | 11 | // once per ports.ActivityInterval: idle notification timers restart. | |
| 12 | + | // Input also forgets the outputs unplugged while off: the user is back, so | |
| 13 | + | // they reconnect on. | |
| 12 | 14 | func (c *Core) userActivity(ctx context.Context) error { | |
| 15 | + | clear(c.offGone) | |
| 13 | 16 | now := time.Now() | |
| 14 | 17 | if now.Sub(c.activity) < ports.ActivityInterval { | |
| 15 | 18 | return nil | |
| @@ -9,7 +9,10 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | // userActivity tells wayland the user touched an input device, at most | 10 | // userActivity tells wayland the user touched an input device, at most | ||
| 11 | // once per ports.ActivityInterval: idle notification timers restart. | 11 | // once per ports.ActivityInterval: idle notification timers restart. | ||
| 12 | + | // Input also forgets the outputs unplugged while off: the user is back, so | |||
| 13 | + | // they reconnect on. | |||
| 12 | func (c *Core) userActivity(ctx context.Context) error { | 14 | func (c *Core) userActivity(ctx context.Context) error { | ||
| 15 | + | clear(c.offGone) | |||
| 13 | now := time.Now() | 16 | now := time.Now() | ||
| 14 | if now.Sub(c.activity) < ports.ActivityInterval { | 17 | if now.Sub(c.activity) < ports.ActivityInterval { | ||
| 15 | return nil | 18 | return nil | ||
M internal/core/power_test.go +68 -0
| @@ -73,6 +73,74 @@ func TestOutputPower(t *testing.T) { | |||
| 73 | 73 | sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off }) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // An output unplugged while off (a display in deep sleep) reconnects off; | |
| 77 | + | // after input it reconnects on. Alone, it is the last output: core keeps a | |
| 78 | + | // placeholder screen meanwhile. | |
| 79 | + | func TestOutputReconnectedWhileOff(t *testing.T) { | |
| 80 | + | for _, alone := range []bool{false, true} { | |
| 81 | + | t.Run(map[bool]string{false: "beside another", true: "alone"}[alone], func(t *testing.T) { | |
| 82 | + | input, client, output, commands, scenes := powerCore(t) | |
| 83 | + | stop := make(chan struct{}) | |
| 84 | + | defer close(stop) | |
| 85 | + | active := make(chan struct{}, 1) | |
| 86 | + | go func() { // core blocks on a full command channel | |
| 87 | + | for { | |
| 88 | + | select { | |
| 89 | + | case c := <-commands: | |
| 90 | + | if _, ok := c.(ports.UserActivity); ok { | |
| 91 | + | active <- struct{}{} | |
| 92 | + | } | |
| 93 | + | case <-stop: | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | }() | |
| 98 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |
| 99 | + | if alone { | |
| 100 | + | output <- ports.OutputRemoved{Name: "OUT-1"} | |
| 101 | + | gone(t, scenes, "OUT-1") | |
| 102 | + | } | |
| 103 | + | client <- ports.OutputPower{Output: "OUT-2", On: false} | |
| 104 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |
| 105 | + | output <- ports.OutputRemoved{Name: "OUT-2"} | |
| 106 | + | gone(t, scenes, "OUT-2") // no older scene can answer below | |
| 107 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |
| 108 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |
| 109 | + | ||
| 110 | + | client <- ports.OutputPower{Output: "OUT-2", On: true} | |
| 111 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |
| 112 | + | client <- ports.OutputPower{Output: "OUT-2", On: false} | |
| 113 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |
| 114 | + | output <- ports.OutputRemoved{Name: "OUT-2"} | |
| 115 | + | gone(t, scenes, "OUT-2") | |
| 116 | + | input <- ports.PointerMotion{X: 1, Y: 1} | |
| 117 | + | select { // input and outputs are separate channels: order them | |
| 118 | + | case <-active: | |
| 119 | + | case <-time.After(2 * time.Second): | |
| 120 | + | t.Fatal("input not handled") | |
| 121 | + | } | |
| 122 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |
| 123 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // gone waits for a scene set without the named output. | |
| 129 | + | func gone(t *testing.T, scenes <-chan []ports.Scene, name string) { | |
| 130 | + | t.Helper() | |
| 131 | + | deadline := time.After(2 * time.Second) | |
| 132 | + | for { | |
| 133 | + | select { | |
| 134 | + | case set := <-scenes: | |
| 135 | + | if !slices.ContainsFunc(set, func(s ports.Scene) bool { return s.Output == name }) { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | case <-deadline: | |
| 139 | + | t.Fatalf("%s still in the scenes", name) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 76 | 144 | // Input reports user activity to wayland, at most once per interval. | |
| 77 | 145 | func TestUserActivity(t *testing.T) { | |
| 78 | 146 | input, _, _, commands, _ := powerCore(t) | |
| @@ -73,6 +73,74 @@ func TestOutputPower(t *testing.T) { | |||||
| 73 | sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off }) | 73 | sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off }) | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | // An output unplugged while off (a display in deep sleep) reconnects off; | |||
| 77 | + | // after input it reconnects on. Alone, it is the last output: core keeps a | |||
| 78 | + | // placeholder screen meanwhile. | |||
| 79 | + | func TestOutputReconnectedWhileOff(t *testing.T) { | |||
| 80 | + | for _, alone := range []bool{false, true} { | |||
| 81 | + | t.Run(map[bool]string{false: "beside another", true: "alone"}[alone], func(t *testing.T) { | |||
| 82 | + | input, client, output, commands, scenes := powerCore(t) | |||
| 83 | + | stop := make(chan struct{}) | |||
| 84 | + | defer close(stop) | |||
| 85 | + | active := make(chan struct{}, 1) | |||
| 86 | + | go func() { // core blocks on a full command channel | |||
| 87 | + | for { | |||
| 88 | + | select { | |||
| 89 | + | case c := <-commands: | |||
| 90 | + | if _, ok := c.(ports.UserActivity); ok { | |||
| 91 | + | active <- struct{}{} | |||
| 92 | + | } | |||
| 93 | + | case <-stop: | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | }() | |||
| 98 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |||
| 99 | + | if alone { | |||
| 100 | + | output <- ports.OutputRemoved{Name: "OUT-1"} | |||
| 101 | + | gone(t, scenes, "OUT-1") | |||
| 102 | + | } | |||
| 103 | + | client <- ports.OutputPower{Output: "OUT-2", On: false} | |||
| 104 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |||
| 105 | + | output <- ports.OutputRemoved{Name: "OUT-2"} | |||
| 106 | + | gone(t, scenes, "OUT-2") // no older scene can answer below | |||
| 107 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |||
| 108 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |||
| 109 | + | ||||
| 110 | + | client <- ports.OutputPower{Output: "OUT-2", On: true} | |||
| 111 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |||
| 112 | + | client <- ports.OutputPower{Output: "OUT-2", On: false} | |||
| 113 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off }) | |||
| 114 | + | output <- ports.OutputRemoved{Name: "OUT-2"} | |||
| 115 | + | gone(t, scenes, "OUT-2") | |||
| 116 | + | input <- ports.PointerMotion{X: 1, Y: 1} | |||
| 117 | + | select { // input and outputs are separate channels: order them | |||
| 118 | + | case <-active: | |||
| 119 | + | case <-time.After(2 * time.Second): | |||
| 120 | + | t.Fatal("input not handled") | |||
| 121 | + | } | |||
| 122 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |||
| 123 | + | sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off }) | |||
| 124 | + | }) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // gone waits for a scene set without the named output. | |||
| 129 | + | func gone(t *testing.T, scenes <-chan []ports.Scene, name string) { | |||
| 130 | + | t.Helper() | |||
| 131 | + | deadline := time.After(2 * time.Second) | |||
| 132 | + | for { | |||
| 133 | + | select { | |||
| 134 | + | case set := <-scenes: | |||
| 135 | + | if !slices.ContainsFunc(set, func(s ports.Scene) bool { return s.Output == name }) { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | case <-deadline: | |||
| 139 | + | t.Fatalf("%s still in the scenes", name) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 76 | // Input reports user activity to wayland, at most once per interval. | 144 | // Input reports user activity to wayland, at most once per interval. | ||
| 77 | func TestUserActivity(t *testing.T) { | 145 | func TestUserActivity(t *testing.T) { | ||
| 78 | input, _, _, commands, _ := powerCore(t) | 146 | input, _, _, commands, _ := powerCore(t) | ||
M internal/core/screens.go +8 -0
| @@ -196,6 +196,8 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 196 | 196 | s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)} | |
| 197 | 197 | c.screens = append(c.screens, s) | |
| 198 | 198 | } | |
| 199 | + | s.off = c.offGone[info.Name] | |
| 200 | + | delete(c.offGone, info.Name) | |
| 199 | 201 | s.mon.Name, s.mon.Key = info.Name, info.Key() | |
| 200 | 202 | c.settings(s.mon) | |
| 201 | 203 | s.cfgScale = c.configScale(info.Name) | |
| @@ -224,6 +226,12 @@ func (c *Core) removeScreen(name string) { | |||
| 224 | 226 | if c.configures.cw.sc == c.screens[i] { | |
| 225 | 227 | c.configures.cw.reset() | |
| 226 | 228 | } | |
| 229 | + | if c.screens[i].off { | |
| 230 | + | if c.offGone == nil { | |
| 231 | + | c.offGone = map[string]bool{} | |
| 232 | + | } | |
| 233 | + | c.offGone[name] = true | |
| 234 | + | } | |
| 227 | 235 | if len(c.screens) == 1 { | |
| 228 | 236 | c.screens[0].info = ports.OutputInfo{} | |
| 229 | 237 | c.screens[0].layers = nil | |
| @@ -196,6 +196,8 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||||
| 196 | s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)} | 196 | s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)} | ||
| 197 | c.screens = append(c.screens, s) | 197 | c.screens = append(c.screens, s) | ||
| 198 | } | 198 | } | ||
| 199 | + | s.off = c.offGone[info.Name] | |||
| 200 | + | delete(c.offGone, info.Name) | |||
| 199 | s.mon.Name, s.mon.Key = info.Name, info.Key() | 201 | s.mon.Name, s.mon.Key = info.Name, info.Key() | ||
| 200 | c.settings(s.mon) | 202 | c.settings(s.mon) | ||
| 201 | s.cfgScale = c.configScale(info.Name) | 203 | s.cfgScale = c.configScale(info.Name) | ||
| @@ -224,6 +226,12 @@ func (c *Core) removeScreen(name string) { | |||||
| 224 | if c.configures.cw.sc == c.screens[i] { | 226 | if c.configures.cw.sc == c.screens[i] { | ||
| 225 | c.configures.cw.reset() | 227 | c.configures.cw.reset() | ||
| 226 | } | 228 | } | ||
| 229 | + | if c.screens[i].off { | |||
| 230 | + | if c.offGone == nil { | |||
| 231 | + | c.offGone = map[string]bool{} | |||
| 232 | + | } | |||
| 233 | + | c.offGone[name] = true | |||
| 234 | + | } | |||
| 227 | if len(c.screens) == 1 { | 235 | if len(c.screens) == 1 { | ||
| 228 | c.screens[0].info = ports.OutputInfo{} | 236 | c.screens[0].info = ports.OutputInfo{} | ||
| 229 | c.screens[0].layers = nil | 237 | c.screens[0].layers = nil | ||