detached 7bfbe4f

fix(drm): keep a display off across modesets, make failed power objects inert and floor idle timeouts

commit
7bfbe4fdf4cdb44128b281343fb6c48c5570093d
author
brice · · 2026-09-26 18:20 UTC
parent
e2a318d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +78 -16
M internal/adapters/drm/output.go +20 -13
@@ -95,9 +95,10 @@ type Output struct {
9595 vrrProp uint32
9696 vrrOn bool
9797 composedSince time.Time
98- // off: the CRTC is inactive because a client turned the display off
99- // (Scene.Off); cleared by every modeset.
100- off bool
98+ // wantOff is the latest Scene.Off: a client turned the display off.
99+ // off: the CRTC is inactive for it. Every modeset (resume, recovery)
100+ // follows wantOff, so a display turned off never lights up.
101+ wantOff, off bool
101102 // kind is how the images were made.
102103 kind imageKind
103104 // formats receives the direct scanout formats after each modeset;
@@ -413,7 +414,7 @@ func (o *Output) modeset() error {
413414 if err != nil {
414415 return err
415416 }
416- if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {
417+ if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil {
417418 _ = o.k.destroyBlob(blob)
418419 return fmt.Errorf("modeset: %w", err)
419420 }
@@ -426,13 +427,16 @@ func (o *Output) modeset() error {
426427 _ = o.k.destroyBlob(o.modeBlob)
427428 }
428429 o.modeBlob = blob
429- o.vrrOn, o.overlayOn, o.off = false, 0, false
430+ o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff
430431 if o.cursor != nil {
431432 o.cursor.applied = cursorState{}
432433 o.cursor.screen, o.cursor.flying = 0, false
433434 }
434- o.log.Info().Str("connector", o.conn.name).Msg("modeset")
435- o.testCursor()
435+ o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
436+ if !o.off {
437+ // An inactive CRTC tells nothing about the cursor plane.
438+ o.testCursor()
439+ }
436440 o.sendFormats()
437441 return nil
438442 }
@@ -479,7 +483,8 @@ func (o *Output) testModeset() error {
479483 return err
480484 }
481485 defer func() { _ = o.k.destroyBlob(blob) }()
482- if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {
486+ // Tested active: an inactive CRTC would accept any image.
487+ if err := o.k.commit(o.modesetReq(blob, true), atomicTestOnly|atomicAllowModes, 0); err != nil {
483488 return fmt.Errorf("modeset test: %w", err)
484489 }
485490 return nil
@@ -513,11 +518,12 @@ func (o *Output) cursorOff(err error) {
513518 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
514519 }
515520
516-// modesetReq is the modeset of the front image with mode blob.
517-func (o *Output) modesetReq(blob uint32) *atomicReq {
521+// modesetReq is the modeset of the front image with mode blob; active
522+// false keeps the display off (output power).
523+func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
518524 req := &atomicReq{}
519525 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
520- req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
526+ req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
521527 req.set(o.crtc, o.vrrProp, 0)
522528 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
523529 o.primaryProps(req, o.fbs[1-o.back])
@@ -965,6 +971,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
965971 stateDirty = true
966972 }
967973 scene, haveScene, dirty = s, true, true
974+ o.wantOff = s.Off
968975 case c := <-cursor:
969976 want = c
970977 // Before the first scene the scale is unknown: loaded then.
@@ -998,9 +1005,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9981005 }
9991006 // Output power: off waits for no commit in flight; on is a modeset
10001007 // and a full frame.
1001- if haveScene && scene.Off != o.off {
1008+ if o.wantOff != o.off {
10021009 var err error
1003- if scene.Off {
1010+ if o.wantOff {
10041011 err = o.powerOff()
10051012 } else if err = o.modeset(); err == nil {
10061013 dirty = true
@@ -95,9 +95,10 @@ type Output struct {
95 vrrProp uint3295 vrrProp uint32
96 vrrOn bool96 vrrOn bool
97 composedSince time.Time97 composedSince time.Time
98- // off: the CRTC is inactive because a client turned the display off98+ // wantOff is the latest Scene.Off: a client turned the display off.
99- // (Scene.Off); cleared by every modeset.99+ // off: the CRTC is inactive for it. Every modeset (resume, recovery)
100- off bool100+ // follows wantOff, so a display turned off never lights up.
101+ wantOff, off bool
101 // kind is how the images were made.102 // kind is how the images were made.
102 kind imageKind103 kind imageKind
103 // formats receives the direct scanout formats after each modeset;104 // formats receives the direct scanout formats after each modeset;
@@ -413,7 +414,7 @@ func (o *Output) modeset() error {
413 if err != nil {414 if err != nil {
414 return err415 return err
415 }416 }
416- if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {417+ if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil {
417 _ = o.k.destroyBlob(blob)418 _ = o.k.destroyBlob(blob)
418 return fmt.Errorf("modeset: %w", err)419 return fmt.Errorf("modeset: %w", err)
419 }420 }
@@ -426,13 +427,16 @@ func (o *Output) modeset() error {
426 _ = o.k.destroyBlob(o.modeBlob)427 _ = o.k.destroyBlob(o.modeBlob)
427 }428 }
428 o.modeBlob = blob429 o.modeBlob = blob
429- o.vrrOn, o.overlayOn, o.off = false, 0, false430+ o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff
430 if o.cursor != nil {431 if o.cursor != nil {
431 o.cursor.applied = cursorState{}432 o.cursor.applied = cursorState{}
432 o.cursor.screen, o.cursor.flying = 0, false433 o.cursor.screen, o.cursor.flying = 0, false
433 }434 }
434- o.log.Info().Str("connector", o.conn.name).Msg("modeset")435+ o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
435- o.testCursor()436+ if !o.off {
437+ // An inactive CRTC tells nothing about the cursor plane.
438+ o.testCursor()
439+ }
436 o.sendFormats()440 o.sendFormats()
437 return nil441 return nil
438 }442 }
@@ -479,7 +483,8 @@ func (o *Output) testModeset() error {
479 return err483 return err
480 }484 }
481 defer func() { _ = o.k.destroyBlob(blob) }()485 defer func() { _ = o.k.destroyBlob(blob) }()
482- if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {486+ // Tested active: an inactive CRTC would accept any image.
487+ if err := o.k.commit(o.modesetReq(blob, true), atomicTestOnly|atomicAllowModes, 0); err != nil {
483 return fmt.Errorf("modeset test: %w", err)488 return fmt.Errorf("modeset test: %w", err)
484 }489 }
485 return nil490 return nil
@@ -513,11 +518,12 @@ func (o *Output) cursorOff(err error) {
513 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")518 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
514 }519 }
515 520
516-// modesetReq is the modeset of the front image with mode blob.521+// modesetReq is the modeset of the front image with mode blob; active
517-func (o *Output) modesetReq(blob uint32) *atomicReq {522+// false keeps the display off (output power).
523+func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
518 req := &atomicReq{}524 req := &atomicReq{}
519 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))525 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
520- req.set(o.crtc, o.crtcProps["ACTIVE"], 1)526+ req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
521 req.set(o.crtc, o.vrrProp, 0)527 req.set(o.crtc, o.vrrProp, 0)
522 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))528 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
523 o.primaryProps(req, o.fbs[1-o.back])529 o.primaryProps(req, o.fbs[1-o.back])
@@ -965,6 +971,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
965 stateDirty = true971 stateDirty = true
966 }972 }
967 scene, haveScene, dirty = s, true, true973 scene, haveScene, dirty = s, true, true
974+ o.wantOff = s.Off
968 case c := <-cursor:975 case c := <-cursor:
969 want = c976 want = c
970 // Before the first scene the scale is unknown: loaded then.977 // Before the first scene the scale is unknown: loaded then.
@@ -998,9 +1005,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
998 }1005 }
999 // Output power: off waits for no commit in flight; on is a modeset1006 // Output power: off waits for no commit in flight; on is a modeset
1000 // and a full frame.1007 // and a full frame.
1001- if haveScene && scene.Off != o.off {1008+ if o.wantOff != o.off {
1002 var err error1009 var err error
1003- if scene.Off {1010+ if o.wantOff {
1004 err = o.powerOff()1011 err = o.powerOff()
1005 } else if err = o.modeset(); err == nil {1012 } else if err = o.modeset(); err == nil {
1006 dirty = true1013 dirty = true
M internal/adapters/drm/power_test.go +14 -1
@@ -43,10 +43,11 @@ func TestRunOutputPower(t *testing.T) {
4343 }
4444 active := func(c commitRec) (uint64, bool) { return c.req.value(tCrtc, pActive) }
4545 scenes := make(chan ports.Scene, 1)
46+ vt := make(chan bool)
4647 ctx, cancel := context.WithCancel(context.Background())
4748 done := make(chan error, 1)
4849 go func() {
49- 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))
50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))
5051 }()
5152 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
5253 scenes <- on
@@ -87,6 +88,18 @@ func TestRunOutputPower(t *testing.T) {
8788 t.Fatal("committed while off")
8889 }
8990
91+ // A VT switch back modesets while off: the CRTC stays inactive.
92+ vt <- false
93+ vt <- true
94+ waitFor(t, func() bool { return len(snapshot()) > n })
95+ time.Sleep(20 * time.Millisecond)
96+ for _, c := range snapshot()[n:] {
97+ if v, ok := active(c); ok && v != 0 && c.flags&atomicTestOnly == 0 {
98+ t.Fatal("VT resume turned the display on")
99+ }
100+ }
101+ n = len(snapshot())
102+
90103 scenes <- on
91104 <-renders
92105 waitFor(t, func() bool {
@@ -43,10 +43,11 @@ func TestRunOutputPower(t *testing.T) {
43 }43 }
44 active := func(c commitRec) (uint64, bool) { return c.req.value(tCrtc, pActive) }44 active := func(c commitRec) (uint64, bool) { return c.req.value(tCrtc, pActive) }
45 scenes := make(chan ports.Scene, 1)45 scenes := make(chan ports.Scene, 1)
46+ vt := make(chan bool)
46 ctx, cancel := context.WithCancel(context.Background())47 ctx, cancel := context.WithCancel(context.Background())
47 done := make(chan error, 1)48 done := make(chan error, 1)
48 go func() {49 go func() {
49- 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))50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))
50 }()51 }()
51 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}52 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
52 scenes <- on53 scenes <- on
@@ -87,6 +88,18 @@ func TestRunOutputPower(t *testing.T) {
87 t.Fatal("committed while off")88 t.Fatal("committed while off")
88 }89 }
89 90
91+ // A VT switch back modesets while off: the CRTC stays inactive.
92+ vt <- false
93+ vt <- true
94+ waitFor(t, func() bool { return len(snapshot()) > n })
95+ time.Sleep(20 * time.Millisecond)
96+ for _, c := range snapshot()[n:] {
97+ if v, ok := active(c); ok && v != 0 && c.flags&atomicTestOnly == 0 {
98+ t.Fatal("VT resume turned the display on")
99+ }
100+ }
101+ n = len(snapshot())
102+
90 scenes <- on103 scenes <- on
91 <-renders104 <-renders
92 waitFor(t, func() bool {105 waitFor(t, func() bool {
M internal/adapters/wayland/idle.go +6 -1
@@ -91,7 +91,9 @@ func (s *Server) armIdle(n *idleNotification) {
9191 return
9292 }
9393 gen := n.gen
94- n.timer = time.AfterFunc(n.timeout, func() {
94+ // Core reports activity at most once per ActivityInterval: a shorter
95+ // timeout would fire while the user is still typing.
96+ n.timer = time.AfterFunc(max(n.timeout, ports.ActivityInterval), func() {
9597 s.display.Do(func() {
9698 // A timer stopped or replaced while it waited for Do is stale.
9799 if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
@@ -200,8 +202,11 @@ func (s *Server) setOutputsOff(off map[string]bool) {
200202 kept := s.powers[:0]
201203 for _, p := range s.powers {
202204 if !names[p.output] {
205+ // Failed objects are inert, even if an output of that name
206+ // comes back.
203207 p.res.SendFailed()
204208 p.res.OnDestroy = nil
209+ p.output = ""
205210 continue
206211 }
207212 if off[p.output] != s.outputsOff[p.output] {
@@ -91,7 +91,9 @@ func (s *Server) armIdle(n *idleNotification) {
91 return91 return
92 }92 }
93 gen := n.gen93 gen := n.gen
94- n.timer = time.AfterFunc(n.timeout, func() {94+ // Core reports activity at most once per ActivityInterval: a shorter
95+ // timeout would fire while the user is still typing.
96+ n.timer = time.AfterFunc(max(n.timeout, ports.ActivityInterval), func() {
95 s.display.Do(func() {97 s.display.Do(func() {
96 // A timer stopped or replaced while it waited for Do is stale.98 // A timer stopped or replaced while it waited for Do is stale.
97 if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {99 if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
@@ -200,8 +202,11 @@ func (s *Server) setOutputsOff(off map[string]bool) {
200 kept := s.powers[:0]202 kept := s.powers[:0]
201 for _, p := range s.powers {203 for _, p := range s.powers {
202 if !names[p.output] {204 if !names[p.output] {
205+ // Failed objects are inert, even if an output of that name
206+ // comes back.
203 p.res.SendFailed()207 p.res.SendFailed()
204 p.res.OnDestroy = nil208 p.res.OnDestroy = nil
209+ p.output = ""
205 continue210 continue
206 }211 }
207 if off[p.output] != s.outputsOff[p.output] {212 if off[p.output] != s.outputsOff[p.output] {
M internal/adapters/wayland/idle_test.go +37 -0
@@ -154,6 +154,43 @@ func TestOutputPower(t *testing.T) {
154154 if e := p.next(t, c, 2*time.Second); e[0] != failed {
155155 t.Fatalf("after unplug %v", e)
156156 }
157+ // Back with the same name: the failed object controls nothing.
158+ commands <- ports.SetOutputs{Outputs: testOutputs}
159+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ select {
164+ case ev := <-events:
165+ if v, ok := ev.(ports.OutputPower); ok {
166+ t.Fatalf("failed object sent %+v", v)
167+ }
168+ case <-time.After(50 * time.Millisecond):
169+ }
170+}
171+
172+// A timeout shorter than the activity interval still waits for it, so
173+// continuous input keeps the notification from idling.
174+func TestIdleShortTimeout(t *testing.T) {
175+ s, _, commands, _, dir := contentServer(t)
176+ c := protocolClient(t, s, dir)
177+ seat := bindProtocol(t, c, "wl_seat")
178+ registerProtocol(t, c, seat)
179+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
180+ id, n := newEventProxy(c)
181+ start := time.Now()
182+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(0), seat)
183+ n.next(t, c, 2*time.Second)
184+ if d := time.Since(start); d < ports.ActivityInterval {
185+ t.Fatalf("idled after %v, before the activity interval", d)
186+ }
187+ commands <- ports.UserActivity{}
188+ n.next(t, c, 2*time.Second) // resumed
189+ for range 4 {
190+ time.Sleep(ports.ActivityInterval / 2)
191+ commands <- ports.UserActivity{}
192+ }
193+ n.none(t, c, ports.ActivityInterval/2)
157194 }
158195
159196 // An unknown mode is a protocol error.
@@ -154,6 +154,43 @@ func TestOutputPower(t *testing.T) {
154 if e := p.next(t, c, 2*time.Second); e[0] != failed {154 if e := p.next(t, c, 2*time.Second); e[0] != failed {
155 t.Fatalf("after unplug %v", e)155 t.Fatalf("after unplug %v", e)
156 }156 }
157+ // Back with the same name: the failed object controls nothing.
158+ commands <- ports.SetOutputs{Outputs: testOutputs}
159+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ select {
164+ case ev := <-events:
165+ if v, ok := ev.(ports.OutputPower); ok {
166+ t.Fatalf("failed object sent %+v", v)
167+ }
168+ case <-time.After(50 * time.Millisecond):
169+ }
170+}
171+
172+// A timeout shorter than the activity interval still waits for it, so
173+// continuous input keeps the notification from idling.
174+func TestIdleShortTimeout(t *testing.T) {
175+ s, _, commands, _, dir := contentServer(t)
176+ c := protocolClient(t, s, dir)
177+ seat := bindProtocol(t, c, "wl_seat")
178+ registerProtocol(t, c, seat)
179+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
180+ id, n := newEventProxy(c)
181+ start := time.Now()
182+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(0), seat)
183+ n.next(t, c, 2*time.Second)
184+ if d := time.Since(start); d < ports.ActivityInterval {
185+ t.Fatalf("idled after %v, before the activity interval", d)
186+ }
187+ commands <- ports.UserActivity{}
188+ n.next(t, c, 2*time.Second) // resumed
189+ for range 4 {
190+ time.Sleep(ports.ActivityInterval / 2)
191+ commands <- ports.UserActivity{}
192+ }
193+ n.none(t, c, ports.ActivityInterval/2)
157 }194 }
158 195
159 // An unknown mode is a protocol error.196 // An unknown mode is a protocol error.
M internal/ports/messages.go +1 -1
@@ -415,7 +415,7 @@ type UserActivity struct{}
415415 func (UserActivity) clientCommand() {}
416416
417417 // ActivityInterval is the most often core reports UserActivity: idle
418-// timers may start up to this much early.
418+// timers may start up to this much early, so no idle timeout is shorter.
419419 const ActivityInterval = 100 * time.Millisecond
420420
421421 // ConfigureWindow carries core → wayland geometry and state. Output is the
@@ -415,7 +415,7 @@ type UserActivity struct{}
415 func (UserActivity) clientCommand() {}415 func (UserActivity) clientCommand() {}
416 416
417 // ActivityInterval is the most often core reports UserActivity: idle417 // ActivityInterval is the most often core reports UserActivity: idle
418-// timers may start up to this much early.418+// timers may start up to this much early, so no idle timeout is shorter.
419 const ActivityInterval = 100 * time.Millisecond419 const ActivityInterval = 100 * time.Millisecond
420 420
421 // ConfigureWindow carries core → wayland geometry and state. Output is the421 // ConfigureWindow carries core → wayland geometry and state. Output is the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close