detached c746896

feat(output): support wlr-output-management-unstable-v1 (#12)

* feat(output): support explicit runtime output positions

* feat(wayland): add wlr output management heads and configurations

* feat(output): wait for first modeset before completing applies

* test(output): cover relay readiness rollback and reload

* test(wayland): verify stale output serial and inventory revisions

* test(wayland): cover output management protocol errors

* test(wayland): stabilize output inventory serial setup

* test(app): cover timeout rollback through output relay

* fix(wayland): maintain output management resource lifetimes and state

* test(wayland): cover initial placement and released current mode

* fix(app): track output apply readiness per instance

* fix(app): keep output settings relay responsive during backend apply

* fix(drm): tolerate connector inventory errors and report run panics

* fix(app): include hotplugged outputs in pending apply

* fix(app): wait for stopped instance before evaluating replacement

* fix(app): reject applies while reload backend work is pending

* fix(app): queue output settings relay sends in its select loop

* fix(app): keep output apply and inventory consistent on DRM errors
commit
c746896110317de79f420e6841a9288d9e246f64
author
bnema · · 2026-09-27 09:00 UTC
committer
GitHub · · 2026-09-27 09:00 UTC
parent
19da7f2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +2176 -34
M internal/adapters/drm/card.go +39 -0
@@ -125,6 +125,45 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
125125 return added, removed, replaced, nil
126126 }
127127
128+// ConnectedHeads inventories physical connectors independently of the desired
129+// enabled state. The caller owns Scan and must invoke this on the same goroutine.
130+func (c *Card) ConnectedHeads() ([]ports.OutputHead, error) {
131+ _, ids, err := resources(c.fd)
132+ if err != nil {
133+ return nil, err
134+ }
135+ var heads []ports.OutputHead
136+ for _, id := range ids {
137+ conn, err := readConnector(c.fd, id)
138+ if err != nil {
139+ c.log.Warn().Err(err).Uint32("connector", id).Msg("read connector for inventory")
140+ continue
141+ }
142+ if !conn.connected || len(conn.modes) == 0 {
143+ continue
144+ }
145+ monitor := readMonitor(c.path, conn.name)
146+ h := ports.OutputHead{Info: ports.OutputInfo{Name: conn.name, Make: monitor.Make, Model: monitor.Model, Serial: monitor.Serial, PhysicalW: conn.mmW, PhysicalH: conn.mmH}}
147+ if h.Info.Make == "Unknown" {
148+ h.Info.Make = ""
149+ }
150+ if h.Info.Model == "Unknown" {
151+ h.Info.Model = ""
152+ }
153+ if o := c.outputs[conn.name]; o != nil {
154+ h.Info = o.Info()
155+ m := ports.OutputMode{Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli()}
156+ h.Current = &m
157+ h.Enabled = true
158+ }
159+ for _, m := range conn.modes {
160+ h.Modes = append(h.Modes, ports.OutputMode{Width: int(m.HDisplay), Height: int(m.VDisplay), RefreshMilli: m.refreshMilli(), Preferred: m.Type&modeTypePrefered != 0})
161+ }
162+ heads = append(heads, h)
163+ }
164+ return heads, nil
165+}
166+
128167 // Release forgets an output closed by its goroutine, freeing its CRTC and
129168 // planes for the next Scan.
130169 func (c *Card) Release(name string) {
@@ -125,6 +125,45 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
125 return added, removed, replaced, nil125 return added, removed, replaced, nil
126 }126 }
127 127
128+// ConnectedHeads inventories physical connectors independently of the desired
129+// enabled state. The caller owns Scan and must invoke this on the same goroutine.
130+func (c *Card) ConnectedHeads() ([]ports.OutputHead, error) {
131+ _, ids, err := resources(c.fd)
132+ if err != nil {
133+ return nil, err
134+ }
135+ var heads []ports.OutputHead
136+ for _, id := range ids {
137+ conn, err := readConnector(c.fd, id)
138+ if err != nil {
139+ c.log.Warn().Err(err).Uint32("connector", id).Msg("read connector for inventory")
140+ continue
141+ }
142+ if !conn.connected || len(conn.modes) == 0 {
143+ continue
144+ }
145+ monitor := readMonitor(c.path, conn.name)
146+ h := ports.OutputHead{Info: ports.OutputInfo{Name: conn.name, Make: monitor.Make, Model: monitor.Model, Serial: monitor.Serial, PhysicalW: conn.mmW, PhysicalH: conn.mmH}}
147+ if h.Info.Make == "Unknown" {
148+ h.Info.Make = ""
149+ }
150+ if h.Info.Model == "Unknown" {
151+ h.Info.Model = ""
152+ }
153+ if o := c.outputs[conn.name]; o != nil {
154+ h.Info = o.Info()
155+ m := ports.OutputMode{Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli()}
156+ h.Current = &m
157+ h.Enabled = true
158+ }
159+ for _, m := range conn.modes {
160+ h.Modes = append(h.Modes, ports.OutputMode{Width: int(m.HDisplay), Height: int(m.VDisplay), RefreshMilli: m.refreshMilli(), Preferred: m.Type&modeTypePrefered != 0})
161+ }
162+ heads = append(heads, h)
163+ }
164+ return heads, nil
165+}
166+
128 // Release forgets an output closed by its goroutine, freeing its CRTC and167 // Release forgets an output closed by its goroutine, freeing its CRTC and
129 // planes for the next Scan.168 // planes for the next Scan.
130 func (c *Card) Release(name string) {169 func (c *Card) Release(name string) {
M internal/adapters/drm/output.go +27 -5
@@ -113,11 +113,16 @@ type Output struct {
113113 kind imageKind
114114 // formats receives the direct scanout formats after each modeset;
115115 // sampled and device are what they are built from (Want).
116- formats chan<- ports.OutputFormats
117- sampled []ports.DMABufFormat
118- device uint64
116+ formats chan<- ports.OutputFormats
117+ sampled []ports.DMABufFormat
118+ device uint64
119+ ready chan error // first successful modeset, or a startup failure
120+ readySent bool
119121 }
120122
123+// Ready reports the result of the first modeset exactly once.
124+func (o *Output) Ready() <-chan error { return o.ready }
125+
121126 // pendingFrame is what the pending frame commit shows.
122127 type pendingFrame struct {
123128 frame bool
@@ -286,7 +291,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
286291 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
287292 log := card.log
288293 pipe := slices.Index(card.crtcs, crtc)
289- o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
294+ o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
290295 var err error
291296 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
292297 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -456,6 +461,10 @@ func (o *Output) modeset() error {
456461 o.testCursor()
457462 }
458463 o.sendFormats()
464+ if o.ready != nil && !o.readySent {
465+ o.ready <- nil
466+ o.readySent = true
467+ }
459468 return nil
460469 }
461470
@@ -873,7 +882,20 @@ func (o *Output) Close() {
873882
874883 // Run renders scenes and commits them until ctx ends. active reports seat
875884 // enable/disable. What the output shows and read is reported on presented.
876-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error {
885+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
886+ defer func() {
887+ p := recover()
888+ if p != nil {
889+ runErr = fmt.Errorf("output panic: %v", p)
890+ }
891+ if o.ready != nil && !o.readySent {
892+ o.ready <- runErr
893+ o.readySent = true
894+ }
895+ if p != nil {
896+ panic(p)
897+ }
898+ }()
877899 r, err := newRenderer(o.Width(), o.Height())
878900 if err != nil {
879901 return fmt.Errorf("create renderer: %w", err)
@@ -113,11 +113,16 @@ type Output struct {
113 kind imageKind113 kind imageKind
114 // formats receives the direct scanout formats after each modeset;114 // formats receives the direct scanout formats after each modeset;
115 // sampled and device are what they are built from (Want).115 // sampled and device are what they are built from (Want).
116- formats chan<- ports.OutputFormats116+ formats chan<- ports.OutputFormats
117- sampled []ports.DMABufFormat117+ sampled []ports.DMABufFormat
118- device uint64118+ device uint64
119+ ready chan error // first successful modeset, or a startup failure
120+ readySent bool
119 }121 }
120 122
123+// Ready reports the result of the first modeset exactly once.
124+func (o *Output) Ready() <-chan error { return o.ready }
125+
121 // pendingFrame is what the pending frame commit shows.126 // pendingFrame is what the pending frame commit shows.
122 type pendingFrame struct {127 type pendingFrame struct {
123 frame bool128 frame bool
@@ -286,7 +291,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
286 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {291 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
287 log := card.log292 log := card.log
288 pipe := slices.Index(card.crtcs, crtc)293 pipe := slices.Index(card.crtcs, crtc)
289- o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}294+ o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
290 var err error295 var err error
291 if o.saved, err = getCrtc(card.fd, crtc); err != nil {296 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
292 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")297 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -456,6 +461,10 @@ func (o *Output) modeset() error {
456 o.testCursor()461 o.testCursor()
457 }462 }
458 o.sendFormats()463 o.sendFormats()
464+ if o.ready != nil && !o.readySent {
465+ o.ready <- nil
466+ o.readySent = true
467+ }
459 return nil468 return nil
460 }469 }
461 470
@@ -873,7 +882,20 @@ func (o *Output) Close() {
873 882
874 // Run renders scenes and commits them until ctx ends. active reports seat883 // Run renders scenes and commits them until ctx ends. active reports seat
875 // enable/disable. What the output shows and read is reported on presented.884 // enable/disable. What the output shows and read is reported on presented.
876-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error {885+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
886+ defer func() {
887+ p := recover()
888+ if p != nil {
889+ runErr = fmt.Errorf("output panic: %v", p)
890+ }
891+ if o.ready != nil && !o.readySent {
892+ o.ready <- runErr
893+ o.readySent = true
894+ }
895+ if p != nil {
896+ panic(p)
897+ }
898+ }()
877 r, err := newRenderer(o.Width(), o.Height())899 r, err := newRenderer(o.Width(), o.Height())
878 if err != nil {900 if err != nil {
879 return fmt.Errorf("create renderer: %w", err)901 return fmt.Errorf("create renderer: %w", err)
M internal/adapters/drm/output_test.go +19 -0
@@ -525,3 +525,22 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
525525 t.Fatal(err)
526526 }
527527 }
528+
529+func TestRunPanicReportsReadinessError(t *testing.T) {
530+ o, _, _ := testOutput(t)
531+ o.ready = make(chan error, 1)
532+ defer func() {
533+ if recover() == nil {
534+ t.Fatal("panic not propagated")
535+ }
536+ select {
537+ case err := <-o.Ready():
538+ if err == nil {
539+ t.Fatal("panic reported as ready")
540+ }
541+ default:
542+ t.Fatal("missing readiness")
543+ }
544+ }()
545+ o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil)
546+}
@@ -525,3 +525,22 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
525 t.Fatal(err)525 t.Fatal(err)
526 }526 }
527 }527 }
528+
529+func TestRunPanicReportsReadinessError(t *testing.T) {
530+ o, _, _ := testOutput(t)
531+ o.ready = make(chan error, 1)
532+ defer func() {
533+ if recover() == nil {
534+ t.Fatal("panic not propagated")
535+ }
536+ select {
537+ case err := <-o.Ready():
538+ if err == nil {
539+ t.Fatal("panic reported as ready")
540+ }
541+ default:
542+ t.Fatal("missing readiness")
543+ }
544+ }()
545+ o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil)
546+}
M internal/adapters/wayland/globals.go +1 -0
@@ -12,6 +12,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1212 func() error { return registerXDG(d, s) },
1313 func() error { return registerLayer(d, s) },
1414 func() error { return registerXDGOutput(d, s) },
15+ func() error { return registerOutputManagement(d, s) },
1516 func() error { return registerDecoration(d, s) },
1617 func() error { return registerScale(d, s) },
1718 func() error { return registerCursorShape(d, s) },
@@ -12,6 +12,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
12 func() error { return registerXDG(d, s) },12 func() error { return registerXDG(d, s) },
13 func() error { return registerLayer(d, s) },13 func() error { return registerLayer(d, s) },
14 func() error { return registerXDGOutput(d, s) },14 func() error { return registerXDGOutput(d, s) },
15+ func() error { return registerOutputManagement(d, s) },
15 func() error { return registerDecoration(d, s) },16 func() error { return registerDecoration(d, s) },
16 func() error { return registerScale(d, s) },17 func() error { return registerScale(d, s) },
17 func() error { return registerCursorShape(d, s) },18 func() error { return registerCursorShape(d, s) },
A internal/adapters/wayland/outputconfiguration.go +180 -0
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "image"
5+ "math"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+type outputConfiguration struct {
13+ manager *outputManager
14+ res *wlr.ZwlrOutputConfigurationV1
15+ heads map[string]*headConfiguration
16+ used, cancelled bool
17+ serial uint32
18+}
19+type headConfiguration struct {
20+ parent *outputConfiguration
21+ res *wlr.ZwlrOutputConfigurationHeadV1
22+ change ports.HeadChange
23+ modeSet, positionSet, transformSet, scaleSet, syncSet bool
24+}
25+
26+func (c *outputConfiguration) head(r *wlr.ZwlrOutputHeadV1) (string, bool) {
27+ if r == nil || !r.Resource.Alive() || r.Client() != c.res.Client() {
28+ return "", false
29+ }
30+ for name, h := range c.manager.heads {
31+ if h.res.Resource == r.Resource {
32+ return name, true
33+ }
34+ }
35+ return "", false
36+}
37+func (c *outputConfiguration) add(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1, id uint32, enabled bool) {
38+ if c.used {
39+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used")
40+ return
41+ }
42+ name, ok := c.head(head)
43+ if !ok {
44+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "unknown head")
45+ return
46+ }
47+ if _, exists := c.heads[name]; exists {
48+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "head configured twice")
49+ return
50+ }
51+ h := &headConfiguration{parent: c, change: ports.HeadChange{Name: name, Enabled: enabled}}
52+ c.heads[name] = h
53+ if enabled {
54+ res, err := wlr.NewZwlrOutputConfigurationHeadV1(r.Client(), r.Version(), id, h)
55+ if err != nil {
56+ delete(c.heads, name)
57+ return
58+ }
59+ h.res = res
60+ res.OnDestroy = func() { h.res = nil }
61+ }
62+}
63+func (c *outputConfiguration) EnableHead(r *wlr.ZwlrOutputConfigurationV1, id uint32, head *wlr.ZwlrOutputHeadV1) {
64+ c.add(r, head, id, true)
65+}
66+func (c *outputConfiguration) DisableHead(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1) {
67+ c.add(r, head, 0, false)
68+}
69+func (c *outputConfiguration) Destroy(*wlr.ZwlrOutputConfigurationV1) { c.used = true }
70+func (c *outputConfiguration) Test(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, true) }
71+func (c *outputConfiguration) Apply(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, false) }
72+func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool) {
73+ if c.used {
74+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used")
75+ return
76+ }
77+ c.used = true
78+ if c.manager.s.managementSerial != c.serial {
79+ c.cancelled = true
80+ r.SendCancelled()
81+ return
82+ }
83+ if len(c.heads) != len(c.manager.heads) {
84+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
85+ return
86+ }
87+ request := ports.OutputApply{ID: c.manager.s.nextOutputApply, Test: test}
88+ c.manager.s.nextOutputApply++
89+ for _, head := range c.manager.s.outputHeads.Heads {
90+ change, ok := c.heads[head.Info.Name]
91+ if !ok {
92+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
93+ return
94+ }
95+ request.Heads = append(request.Heads, change.change)
96+ }
97+ if c.manager.s.channels.OutputApply == nil {
98+ r.SendFailed()
99+ return
100+ }
101+ c.manager.s.outputReplies[request.ID] = c
102+ select {
103+ case c.manager.s.channels.OutputApply <- request:
104+ default:
105+ delete(c.manager.s.outputReplies, request.ID)
106+ r.SendFailed()
107+ }
108+}
109+func (h *headConfiguration) once(field *bool, r *wlr.ZwlrOutputConfigurationHeadV1) bool {
110+ if *field {
111+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorAlreadySet), "property already set")
112+ return false
113+ }
114+ *field = true
115+ return true
116+}
117+func (h *headConfiguration) SetMode(r *wlr.ZwlrOutputConfigurationHeadV1, mode *wlr.ZwlrOutputModeV1) {
118+ if !h.once(&h.modeSet, r) {
119+ return
120+ }
121+ head := h.parent.manager.heads[h.change.Name]
122+ if head == nil || mode == nil || !mode.Resource.Alive() {
123+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode")
124+ return
125+ }
126+ m, ok := head.modes[mode.Resource]
127+ if !ok {
128+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode")
129+ return
130+ }
131+ h.change.Mode = &m
132+}
133+func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1, w, height, refresh int32) {
134+ if !h.once(&h.modeSet, r) {
135+ return
136+ }
137+ if w <= 0 || height <= 0 || refresh < 0 {
138+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode")
139+ return
140+ }
141+ h.change.CustomMode = true
142+ h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)}
143+}
144+func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) {
145+ if h.once(&h.positionSet, r) {
146+ h.change.Pos = &image.Point{X: int(x), Y: int(y)}
147+ }
148+}
149+func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v int32) {
150+ if !h.once(&h.transformSet, r) {
151+ return
152+ }
153+ if v < 0 || v > 7 {
154+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
155+ return
156+ }
157+ h.change.Transform = int(v)
158+}
159+func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
160+ if !h.once(&h.scaleSet, r) {
161+ return
162+ }
163+ scale := v.Float()
164+ if scale <= 0 || math.IsNaN(scale) {
165+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidScale), "invalid scale")
166+ return
167+ }
168+ h.change.Scale = scale
169+}
170+func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1, v uint32) {
171+ if !h.once(&h.syncSet, r) {
172+ return
173+ }
174+ if v > 1 {
175+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync")
176+ return
177+ }
178+ enabled := v == 1
179+ h.change.AdaptiveSync = &enabled
180+}
@@ -0,0 +1,180 @@
1+package wayland
2+
3+import (
4+ "image"
5+ "math"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+type outputConfiguration struct {
13+ manager *outputManager
14+ res *wlr.ZwlrOutputConfigurationV1
15+ heads map[string]*headConfiguration
16+ used, cancelled bool
17+ serial uint32
18+}
19+type headConfiguration struct {
20+ parent *outputConfiguration
21+ res *wlr.ZwlrOutputConfigurationHeadV1
22+ change ports.HeadChange
23+ modeSet, positionSet, transformSet, scaleSet, syncSet bool
24+}
25+
26+func (c *outputConfiguration) head(r *wlr.ZwlrOutputHeadV1) (string, bool) {
27+ if r == nil || !r.Resource.Alive() || r.Client() != c.res.Client() {
28+ return "", false
29+ }
30+ for name, h := range c.manager.heads {
31+ if h.res.Resource == r.Resource {
32+ return name, true
33+ }
34+ }
35+ return "", false
36+}
37+func (c *outputConfiguration) add(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1, id uint32, enabled bool) {
38+ if c.used {
39+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used")
40+ return
41+ }
42+ name, ok := c.head(head)
43+ if !ok {
44+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "unknown head")
45+ return
46+ }
47+ if _, exists := c.heads[name]; exists {
48+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "head configured twice")
49+ return
50+ }
51+ h := &headConfiguration{parent: c, change: ports.HeadChange{Name: name, Enabled: enabled}}
52+ c.heads[name] = h
53+ if enabled {
54+ res, err := wlr.NewZwlrOutputConfigurationHeadV1(r.Client(), r.Version(), id, h)
55+ if err != nil {
56+ delete(c.heads, name)
57+ return
58+ }
59+ h.res = res
60+ res.OnDestroy = func() { h.res = nil }
61+ }
62+}
63+func (c *outputConfiguration) EnableHead(r *wlr.ZwlrOutputConfigurationV1, id uint32, head *wlr.ZwlrOutputHeadV1) {
64+ c.add(r, head, id, true)
65+}
66+func (c *outputConfiguration) DisableHead(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1) {
67+ c.add(r, head, 0, false)
68+}
69+func (c *outputConfiguration) Destroy(*wlr.ZwlrOutputConfigurationV1) { c.used = true }
70+func (c *outputConfiguration) Test(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, true) }
71+func (c *outputConfiguration) Apply(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, false) }
72+func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool) {
73+ if c.used {
74+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used")
75+ return
76+ }
77+ c.used = true
78+ if c.manager.s.managementSerial != c.serial {
79+ c.cancelled = true
80+ r.SendCancelled()
81+ return
82+ }
83+ if len(c.heads) != len(c.manager.heads) {
84+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
85+ return
86+ }
87+ request := ports.OutputApply{ID: c.manager.s.nextOutputApply, Test: test}
88+ c.manager.s.nextOutputApply++
89+ for _, head := range c.manager.s.outputHeads.Heads {
90+ change, ok := c.heads[head.Info.Name]
91+ if !ok {
92+ r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
93+ return
94+ }
95+ request.Heads = append(request.Heads, change.change)
96+ }
97+ if c.manager.s.channels.OutputApply == nil {
98+ r.SendFailed()
99+ return
100+ }
101+ c.manager.s.outputReplies[request.ID] = c
102+ select {
103+ case c.manager.s.channels.OutputApply <- request:
104+ default:
105+ delete(c.manager.s.outputReplies, request.ID)
106+ r.SendFailed()
107+ }
108+}
109+func (h *headConfiguration) once(field *bool, r *wlr.ZwlrOutputConfigurationHeadV1) bool {
110+ if *field {
111+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorAlreadySet), "property already set")
112+ return false
113+ }
114+ *field = true
115+ return true
116+}
117+func (h *headConfiguration) SetMode(r *wlr.ZwlrOutputConfigurationHeadV1, mode *wlr.ZwlrOutputModeV1) {
118+ if !h.once(&h.modeSet, r) {
119+ return
120+ }
121+ head := h.parent.manager.heads[h.change.Name]
122+ if head == nil || mode == nil || !mode.Resource.Alive() {
123+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode")
124+ return
125+ }
126+ m, ok := head.modes[mode.Resource]
127+ if !ok {
128+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode")
129+ return
130+ }
131+ h.change.Mode = &m
132+}
133+func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1, w, height, refresh int32) {
134+ if !h.once(&h.modeSet, r) {
135+ return
136+ }
137+ if w <= 0 || height <= 0 || refresh < 0 {
138+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode")
139+ return
140+ }
141+ h.change.CustomMode = true
142+ h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)}
143+}
144+func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) {
145+ if h.once(&h.positionSet, r) {
146+ h.change.Pos = &image.Point{X: int(x), Y: int(y)}
147+ }
148+}
149+func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v int32) {
150+ if !h.once(&h.transformSet, r) {
151+ return
152+ }
153+ if v < 0 || v > 7 {
154+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
155+ return
156+ }
157+ h.change.Transform = int(v)
158+}
159+func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
160+ if !h.once(&h.scaleSet, r) {
161+ return
162+ }
163+ scale := v.Float()
164+ if scale <= 0 || math.IsNaN(scale) {
165+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidScale), "invalid scale")
166+ return
167+ }
168+ h.change.Scale = scale
169+}
170+func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1, v uint32) {
171+ if !h.once(&h.syncSet, r) {
172+ return
173+ }
174+ if v > 1 {
175+ r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync")
176+ return
177+ }
178+ enabled := v == 1
179+ h.change.AdaptiveSync = &enabled
180+}
A internal/adapters/wayland/outputmanagement.go +212 -0
@@ -0,0 +1,212 @@
1+package wayland
2+
3+import (
4+ "reflect"
5+ "strings"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+type managementHead struct {
13+ res *wlr.ZwlrOutputHeadV1
14+ modes map[*server.Resource]ports.OutputMode
15+ modeResources []*wlr.ZwlrOutputModeV1
16+ info ports.OutputHead
17+}
18+type outputManager struct {
19+ s *Server
20+ res *wlr.ZwlrOutputManagerV1
21+ heads map[string]*managementHead
22+ stopped bool
23+ places map[string]ports.OutputPlacement
24+}
25+
26+func registerOutputManagement(d *server.Display, s *Server) error {
27+ return wlr.NewZwlrOutputManagerV1Global(d, 4, func(c server.Client, v, id uint32) {
28+ m := &outputManager{s: s, heads: map[string]*managementHead{}, places: map[string]ports.OutputPlacement{}}
29+ res, err := wlr.NewZwlrOutputManagerV1(c, int32(v), id, m)
30+ if err != nil {
31+ return
32+ }
33+ m.res = res
34+ s.outputManagers = append(s.outputManagers, m)
35+ res.OnDestroy = func() { s.outputManagers = removeItem(s.outputManagers, m) }
36+ for _, h := range s.outputHeads.Heads {
37+ m.addHead(h)
38+ }
39+ m.update()
40+ res.SendDone(s.managementSerial)
41+ })
42+}
43+func (m *outputManager) Stop(_ *wlr.ZwlrOutputManagerV1) {
44+ m.stopped = true
45+ m.res.SendFinished()
46+ m.res.Destroy()
47+}
48+func (m *outputManager) CreateConfiguration(r *wlr.ZwlrOutputManagerV1, id, serial uint32) {
49+ c := &outputConfiguration{manager: m, heads: map[string]*headConfiguration{}, serial: serial}
50+ res, err := wlr.NewZwlrOutputConfigurationV1(r.Client(), r.Version(), id, c)
51+ if err != nil {
52+ return
53+ }
54+ c.res = res
55+ res.OnDestroy = func() {
56+ for _, h := range c.heads {
57+ if h.res != nil {
58+ h.res.Destroy()
59+ }
60+ }
61+ for id, pending := range m.s.outputReplies {
62+ if pending == c {
63+ delete(m.s.outputReplies, id)
64+ }
65+ }
66+ }
67+ if serial != m.s.managementSerial {
68+ c.cancelled = true
69+ c.used = true
70+ res.SendCancelled()
71+ }
72+}
73+func (m *outputManager) addHead(info ports.OutputHead) {
74+ h := &managementHead{info: info, modes: map[*server.Resource]ports.OutputMode{}}
75+ res, err := wlr.NewZwlrOutputHeadV1(m.res.Client(), m.res.Version(), 0, managementHeadHandler{})
76+ if err != nil {
77+ return
78+ }
79+ h.res = res
80+ m.heads[info.Info.Name] = h
81+ m.res.SendHead(res)
82+ res.SendName(info.Info.Name)
83+ desc := strings.Join(strings.Fields(info.Info.Make+" "+info.Info.Model+" "+info.Info.Serial+" ("+info.Info.Name+")"), " ")
84+ res.SendDescription(desc)
85+ if info.Info.PhysicalW > 0 && info.Info.PhysicalH > 0 {
86+ res.SendPhysicalSize(int32(info.Info.PhysicalW), int32(info.Info.PhysicalH))
87+ }
88+ if res.Version() >= 2 {
89+ res.SendMake(info.Info.Make)
90+ res.SendModel(info.Info.Model)
91+ res.SendSerialNumber(info.Info.Serial)
92+ }
93+ for _, mode := range info.Modes {
94+ mr, e := wlr.NewZwlrOutputModeV1(res.Client(), min(res.Version(), 3), 0, managementModeHandler{})
95+ if e != nil {
96+ continue
97+ }
98+ h.modes[mr.Resource] = mode
99+ h.modeResources = append(h.modeResources, mr)
100+ mr.OnDestroy = func() {
101+ delete(h.modes, mr.Resource)
102+ h.modeResources = removeItem(h.modeResources, mr)
103+ }
104+ res.SendMode(mr)
105+ mr.SendSize(int32(mode.Width), int32(mode.Height))
106+ mr.SendRefresh(int32(mode.RefreshMilli))
107+ if mode.Preferred {
108+ mr.SendPreferred()
109+ }
110+ }
111+ m.sendState(h, info)
112+}
113+func (m *outputManager) update() {
114+ if m.stopped {
115+ return
116+ }
117+ wanted := map[string]ports.OutputHead{}
118+ for _, h := range m.s.outputHeads.Heads {
119+ wanted[h.Info.Name] = h
120+ }
121+ for name, h := range m.heads {
122+ if _, ok := wanted[name]; !ok {
123+ h.res.SendFinished()
124+ for _, mode := range h.modeResources {
125+ mode.SendFinished()
126+ }
127+ delete(m.heads, name)
128+ delete(m.places, name)
129+ }
130+ }
131+ for _, info := range m.s.outputHeads.Heads {
132+ h := m.heads[info.Info.Name]
133+ if h != nil && (!reflect.DeepEqual(h.info.Modes, info.Modes) || h.info.Info.Make != info.Info.Make || h.info.Info.Model != info.Info.Model || h.info.Info.Serial != info.Info.Serial || h.info.Info.PhysicalW != info.Info.PhysicalW || h.info.Info.PhysicalH != info.Info.PhysicalH) {
134+ h.res.SendFinished()
135+ for _, mode := range h.modeResources {
136+ mode.SendFinished()
137+ }
138+ delete(m.heads, info.Info.Name)
139+ delete(m.places, info.Info.Name)
140+ h = nil
141+ }
142+ if h == nil {
143+ m.addHead(info)
144+ continue // addHead has already sent the initial state.
145+ }
146+ changed := !reflect.DeepEqual(h.info, info)
147+ placementChanged := m.places[info.Info.Name] != m.s.placementFor(info.Info.Name)
148+ if !changed && !placementChanged {
149+ continue
150+ }
151+ m.sendState(h, info)
152+ }
153+}
154+func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
155+ h.info = info
156+ enabled := int32(0)
157+ if info.Enabled {
158+ enabled = 1
159+ }
160+ h.res.SendEnabled(enabled)
161+ if info.Enabled {
162+ for res, mode := range h.modes {
163+ if info.Current != nil && mode.Width == info.Current.Width && mode.Height == info.Current.Height && mode.RefreshMilli == info.Current.RefreshMilli {
164+ h.res.SendCurrentMode(wlr.WrapZwlrOutputModeV1(res))
165+ break
166+ }
167+ }
168+ place := m.s.placementFor(info.Info.Name)
169+ if place.Info.Name != "" {
170+ h.res.SendPosition(int32(place.X), int32(place.Y))
171+ h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ }
173+ h.res.SendTransform(0)
174+ }
175+ m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176+}
177+func (s *Server) placementFor(name string) ports.OutputPlacement {
178+ for _, p := range s.outputPlaces {
179+ if p.Info.Name == name {
180+ return p
181+ }
182+ }
183+ return ports.OutputPlacement{}
184+}
185+func (s *Server) setOutputHeads(h ports.OutputHeads) {
186+ if reflect.DeepEqual(s.outputHeads, h) {
187+ return
188+ }
189+ s.outputHeads = h
190+ s.refreshOutputManagers()
191+}
192+func (s *Server) refreshOutputManagers() {
193+ s.managementSerial++
194+ if s.managementSerial == 0 {
195+ s.managementSerial++
196+ }
197+ for _, m := range s.outputManagers {
198+ if m.stopped {
199+ continue
200+ }
201+ m.update()
202+ m.res.SendDone(s.managementSerial)
203+ }
204+}
205+
206+type managementHeadHandler struct{}
207+
208+func (managementHeadHandler) Release(*wlr.ZwlrOutputHeadV1) {}
209+
210+type managementModeHandler struct{}
211+
212+func (managementModeHandler) Release(*wlr.ZwlrOutputModeV1) {}
@@ -0,0 +1,212 @@
1+package wayland
2+
3+import (
4+ "reflect"
5+ "strings"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+type managementHead struct {
13+ res *wlr.ZwlrOutputHeadV1
14+ modes map[*server.Resource]ports.OutputMode
15+ modeResources []*wlr.ZwlrOutputModeV1
16+ info ports.OutputHead
17+}
18+type outputManager struct {
19+ s *Server
20+ res *wlr.ZwlrOutputManagerV1
21+ heads map[string]*managementHead
22+ stopped bool
23+ places map[string]ports.OutputPlacement
24+}
25+
26+func registerOutputManagement(d *server.Display, s *Server) error {
27+ return wlr.NewZwlrOutputManagerV1Global(d, 4, func(c server.Client, v, id uint32) {
28+ m := &outputManager{s: s, heads: map[string]*managementHead{}, places: map[string]ports.OutputPlacement{}}
29+ res, err := wlr.NewZwlrOutputManagerV1(c, int32(v), id, m)
30+ if err != nil {
31+ return
32+ }
33+ m.res = res
34+ s.outputManagers = append(s.outputManagers, m)
35+ res.OnDestroy = func() { s.outputManagers = removeItem(s.outputManagers, m) }
36+ for _, h := range s.outputHeads.Heads {
37+ m.addHead(h)
38+ }
39+ m.update()
40+ res.SendDone(s.managementSerial)
41+ })
42+}
43+func (m *outputManager) Stop(_ *wlr.ZwlrOutputManagerV1) {
44+ m.stopped = true
45+ m.res.SendFinished()
46+ m.res.Destroy()
47+}
48+func (m *outputManager) CreateConfiguration(r *wlr.ZwlrOutputManagerV1, id, serial uint32) {
49+ c := &outputConfiguration{manager: m, heads: map[string]*headConfiguration{}, serial: serial}
50+ res, err := wlr.NewZwlrOutputConfigurationV1(r.Client(), r.Version(), id, c)
51+ if err != nil {
52+ return
53+ }
54+ c.res = res
55+ res.OnDestroy = func() {
56+ for _, h := range c.heads {
57+ if h.res != nil {
58+ h.res.Destroy()
59+ }
60+ }
61+ for id, pending := range m.s.outputReplies {
62+ if pending == c {
63+ delete(m.s.outputReplies, id)
64+ }
65+ }
66+ }
67+ if serial != m.s.managementSerial {
68+ c.cancelled = true
69+ c.used = true
70+ res.SendCancelled()
71+ }
72+}
73+func (m *outputManager) addHead(info ports.OutputHead) {
74+ h := &managementHead{info: info, modes: map[*server.Resource]ports.OutputMode{}}
75+ res, err := wlr.NewZwlrOutputHeadV1(m.res.Client(), m.res.Version(), 0, managementHeadHandler{})
76+ if err != nil {
77+ return
78+ }
79+ h.res = res
80+ m.heads[info.Info.Name] = h
81+ m.res.SendHead(res)
82+ res.SendName(info.Info.Name)
83+ desc := strings.Join(strings.Fields(info.Info.Make+" "+info.Info.Model+" "+info.Info.Serial+" ("+info.Info.Name+")"), " ")
84+ res.SendDescription(desc)
85+ if info.Info.PhysicalW > 0 && info.Info.PhysicalH > 0 {
86+ res.SendPhysicalSize(int32(info.Info.PhysicalW), int32(info.Info.PhysicalH))
87+ }
88+ if res.Version() >= 2 {
89+ res.SendMake(info.Info.Make)
90+ res.SendModel(info.Info.Model)
91+ res.SendSerialNumber(info.Info.Serial)
92+ }
93+ for _, mode := range info.Modes {
94+ mr, e := wlr.NewZwlrOutputModeV1(res.Client(), min(res.Version(), 3), 0, managementModeHandler{})
95+ if e != nil {
96+ continue
97+ }
98+ h.modes[mr.Resource] = mode
99+ h.modeResources = append(h.modeResources, mr)
100+ mr.OnDestroy = func() {
101+ delete(h.modes, mr.Resource)
102+ h.modeResources = removeItem(h.modeResources, mr)
103+ }
104+ res.SendMode(mr)
105+ mr.SendSize(int32(mode.Width), int32(mode.Height))
106+ mr.SendRefresh(int32(mode.RefreshMilli))
107+ if mode.Preferred {
108+ mr.SendPreferred()
109+ }
110+ }
111+ m.sendState(h, info)
112+}
113+func (m *outputManager) update() {
114+ if m.stopped {
115+ return
116+ }
117+ wanted := map[string]ports.OutputHead{}
118+ for _, h := range m.s.outputHeads.Heads {
119+ wanted[h.Info.Name] = h
120+ }
121+ for name, h := range m.heads {
122+ if _, ok := wanted[name]; !ok {
123+ h.res.SendFinished()
124+ for _, mode := range h.modeResources {
125+ mode.SendFinished()
126+ }
127+ delete(m.heads, name)
128+ delete(m.places, name)
129+ }
130+ }
131+ for _, info := range m.s.outputHeads.Heads {
132+ h := m.heads[info.Info.Name]
133+ if h != nil && (!reflect.DeepEqual(h.info.Modes, info.Modes) || h.info.Info.Make != info.Info.Make || h.info.Info.Model != info.Info.Model || h.info.Info.Serial != info.Info.Serial || h.info.Info.PhysicalW != info.Info.PhysicalW || h.info.Info.PhysicalH != info.Info.PhysicalH) {
134+ h.res.SendFinished()
135+ for _, mode := range h.modeResources {
136+ mode.SendFinished()
137+ }
138+ delete(m.heads, info.Info.Name)
139+ delete(m.places, info.Info.Name)
140+ h = nil
141+ }
142+ if h == nil {
143+ m.addHead(info)
144+ continue // addHead has already sent the initial state.
145+ }
146+ changed := !reflect.DeepEqual(h.info, info)
147+ placementChanged := m.places[info.Info.Name] != m.s.placementFor(info.Info.Name)
148+ if !changed && !placementChanged {
149+ continue
150+ }
151+ m.sendState(h, info)
152+ }
153+}
154+func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
155+ h.info = info
156+ enabled := int32(0)
157+ if info.Enabled {
158+ enabled = 1
159+ }
160+ h.res.SendEnabled(enabled)
161+ if info.Enabled {
162+ for res, mode := range h.modes {
163+ if info.Current != nil && mode.Width == info.Current.Width && mode.Height == info.Current.Height && mode.RefreshMilli == info.Current.RefreshMilli {
164+ h.res.SendCurrentMode(wlr.WrapZwlrOutputModeV1(res))
165+ break
166+ }
167+ }
168+ place := m.s.placementFor(info.Info.Name)
169+ if place.Info.Name != "" {
170+ h.res.SendPosition(int32(place.X), int32(place.Y))
171+ h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ }
173+ h.res.SendTransform(0)
174+ }
175+ m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176+}
177+func (s *Server) placementFor(name string) ports.OutputPlacement {
178+ for _, p := range s.outputPlaces {
179+ if p.Info.Name == name {
180+ return p
181+ }
182+ }
183+ return ports.OutputPlacement{}
184+}
185+func (s *Server) setOutputHeads(h ports.OutputHeads) {
186+ if reflect.DeepEqual(s.outputHeads, h) {
187+ return
188+ }
189+ s.outputHeads = h
190+ s.refreshOutputManagers()
191+}
192+func (s *Server) refreshOutputManagers() {
193+ s.managementSerial++
194+ if s.managementSerial == 0 {
195+ s.managementSerial++
196+ }
197+ for _, m := range s.outputManagers {
198+ if m.stopped {
199+ continue
200+ }
201+ m.update()
202+ m.res.SendDone(s.managementSerial)
203+ }
204+}
205+
206+type managementHeadHandler struct{}
207+
208+func (managementHeadHandler) Release(*wlr.ZwlrOutputHeadV1) {}
209+
210+type managementModeHandler struct{}
211+
212+func (managementModeHandler) Release(*wlr.ZwlrOutputModeV1) {}
A internal/adapters/wayland/outputmanagement_test.go +592 -0
@@ -0,0 +1,592 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type managementEvents struct {
15+ wlturbo.BaseProxy
16+ events chan [2]uint32
17+ client *wlturbo.Display
18+ modes chan uint32
19+ headEvents chan [2]uint32
20+}
21+
22+func (p *managementEvents) Dispatch(e *wlturbo.Event) {
23+ msg := [2]uint32{uint32(e.Opcode), 0}
24+ if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) || e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventDone) {
25+ msg[1] = e.Uint32()
26+ }
27+ if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) {
28+ h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents}
29+ h.SetID(msg[1])
30+ p.client.Context().Register(h)
31+ }
32+ p.events <- msg
33+}
34+
35+type managementHeadEvents struct {
36+ wlturbo.BaseProxy
37+ client *wlturbo.Display
38+ modes chan uint32
39+ events chan [2]uint32
40+}
41+
42+func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) {
43+ if p.events != nil {
44+ value := uint32(0)
45+ switch uint32(e.Opcode) {
46+ case wlr.ZwlrOutputHeadV1EventEnabled, wlr.ZwlrOutputHeadV1EventCurrentMode, wlr.ZwlrOutputHeadV1EventTransform:
47+ value = e.Uint32()
48+ }
49+ p.events <- [2]uint32{uint32(e.Opcode), value}
50+ }
51+ if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
52+ mode := &protocolProxy{}
53+ mode.SetID(e.Uint32())
54+ p.client.Context().Register(mode)
55+ if p.modes != nil {
56+ p.modes <- mode.ID()
57+ }
58+ }
59+}
60+func outputTestServer(t *testing.T) (*Server, chan ports.OutputHeads, chan ports.OutputApply, chan ports.OutputApplied, string) {
61+ t.Helper()
62+ dir := t.TempDir()
63+ heads := make(chan ports.OutputHeads, 8)
64+ applies := make(chan ports.OutputApply, 8)
65+ replies := make(chan ports.OutputApplied, 8)
66+ s, err := New(Options{RuntimeDir: dir}, Channels{OutputHeads: heads, OutputApply: applies, OutputApplied: replies}, logging.For(context.Background(), "wayland"))
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ ctx, cancel := context.WithCancel(context.Background())
71+ done := make(chan error, 1)
72+ go func() { done <- s.Run(ctx) }()
73+ t.Cleanup(func() {
74+ cancel()
75+ if err := <-done; err != nil {
76+ t.Error(err)
77+ }
78+ })
79+ return s, heads, applies, replies, dir
80+}
81+func testHead() ports.OutputHeads {
82+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000, Preferred: true}
83+ return ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}}}
84+}
85+func recvManagement(t *testing.T, ch <-chan [2]uint32) [2]uint32 {
86+ t.Helper()
87+ select {
88+ case v := <-ch:
89+ return v
90+ case <-time.After(2 * time.Second):
91+ t.Fatal("management event timeout")
92+ return [2]uint32{}
93+ }
94+}
95+func waitManagementEvent(t *testing.T, c *wlturbo.Display, ch <-chan [2]uint32, want uint32) {
96+ t.Helper()
97+ for i := 0; i < 12; i++ {
98+ if err := c.Roundtrip(); err != nil {
99+ t.Fatal(err)
100+ }
101+ select {
102+ case ev := <-ch:
103+ if ev[0] != want {
104+ t.Fatalf("event %v want %d", ev, want)
105+ }
106+ return
107+ default:
108+ }
109+ }
110+ t.Fatal("missing management event")
111+}
112+func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
113+ s, _, _, _, dir := outputTestServer(t)
114+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
115+ t.Fatal("display stopped")
116+ }
117+ c := protocolClient(t, s, dir)
118+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
119+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
120+ manager.SetID(id)
121+ c.Context().Register(manager)
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ var first [2]uint32
126+ for first[0] != wlr.ZwlrOutputManagerV1EventDone {
127+ first = recvManagement(t, manager.events)
128+ }
129+ if first[0] != wlr.ZwlrOutputManagerV1EventDone {
130+ t.Fatalf("initial: %v", first)
131+ }
132+ if !s.display.Do(func() {
133+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, X: 100, Width: 1920, Height: 1080, Scale: 1}}})
134+ }) {
135+ t.Fatal("display stopped")
136+ }
137+ if err := c.Roundtrip(); err != nil {
138+ t.Fatal(err)
139+ }
140+ second := recvManagement(t, manager.events)
141+ if second[0] != wlr.ZwlrOutputManagerV1EventDone || second[1] == first[1] {
142+ t.Fatalf("layout serial: %v -> %v", first, second)
143+ }
144+ configID := c.AllocateID()
145+ proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
146+ proxy.SetID(configID)
147+ c.Context().Register(proxy)
148+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1])
149+ waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled))
150+ if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
151+ t.Fatal("display stopped")
152+ }
153+ if err := c.Roundtrip(); err != nil {
154+ t.Fatal(err)
155+ }
156+ third := recvManagement(t, manager.events)
157+ if third[0] != wlr.ZwlrOutputManagerV1EventDone || third[1] == second[1] {
158+ t.Fatalf("inventory serial: %v -> %v", second, third)
159+ }
160+}
161+
162+func TestOutputManagementHeadsAndApply(t *testing.T) {
163+ s, heads, apply, replies, dir := outputTestServer(t)
164+ heads <- testHead()
165+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
166+ t.Fatal("display stopped")
167+ }
168+ c := protocolClient(t, s, dir)
169+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
170+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
171+ manager.SetID(id)
172+ c.Context().Register(manager)
173+ if err := c.Roundtrip(); err != nil {
174+ t.Fatal(err)
175+ }
176+ head := recvManagement(t, manager.events)
177+ if head[0] != wlr.ZwlrOutputManagerV1EventHead {
178+ t.Fatalf("head event: %v", head)
179+ }
180+ var serial [2]uint32
181+ for serial[0] != wlr.ZwlrOutputManagerV1EventDone {
182+ serial = recvManagement(t, manager.events)
183+ }
184+ if serial[1] == 0 {
185+ t.Fatalf("done: %v", serial)
186+ }
187+ configID := c.AllocateID()
188+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
189+ configProxy.SetID(configID)
190+ c.Context().Register(configProxy)
191+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
192+ chID := c.AllocateID()
193+ registerProtocol(t, c, chID)
194+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
195+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(100), int32(0))
196+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestTest)
197+ if err := c.Roundtrip(); err != nil {
198+ t.Fatal(err)
199+ }
200+ var req ports.OutputApply
201+ select {
202+ case req = <-apply:
203+ case <-time.After(time.Second):
204+ t.Fatal("no test request")
205+ }
206+ if !req.Test || len(req.Heads) != 1 || req.Heads[0].Pos.X != 100 {
207+ t.Fatalf("test: %+v", req)
208+ }
209+ replies <- ports.OutputApplied{ID: req.ID}
210+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
211+ configID = c.AllocateID()
212+ configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c}
213+ configProxy.SetID(configID)
214+ c.Context().Register(configProxy)
215+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
216+ chID = c.AllocateID()
217+ registerProtocol(t, c, chID)
218+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
219+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(512))
220+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ select {
225+ case req = <-apply:
226+ case <-time.After(time.Second):
227+ t.Fatal("no apply request")
228+ }
229+ if req.Test || req.Heads[0].Scale != 2 {
230+ t.Fatalf("apply: %+v", req)
231+ }
232+ replies <- ports.OutputApplied{ID: req.ID}
233+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
234+}
235+
236+// Each case uses a new connection because wl_display.error terminates that client.
237+func TestOutputManagementProtocolErrors(t *testing.T) {
238+ const (
239+ enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead
240+ disable = wlr.ZwlrOutputConfigurationV1RequestDisableHead
241+ apply = wlr.ZwlrOutputConfigurationV1RequestApply
242+ custom = wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode
243+ )
244+ cases := []struct {
245+ name string
246+ object string
247+ code uint32
248+ send func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32)
249+ }{
250+ {"already configured (enable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
251+ requestProtocol(t, c, cfg, enable, c.AllocateID(), head)
252+ }},
253+ {"already configured (disable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
254+ requestProtocol(t, c, cfg, disable, head)
255+ }},
256+ {"unconfigured head", "configuration", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
257+ requestProtocol(t, c, cfg, apply)
258+ }},
259+ {"already used (configuration)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
260+ requestProtocol(t, c, cfg, apply)
261+ requestProtocol(t, c, cfg, apply)
262+ }},
263+ {"already used (head)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
264+ requestProtocol(t, c, cfg, apply)
265+ requestProtocol(t, c, cfg, enable, c.AllocateID(), head)
266+ }},
267+ {"already set mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
268+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
269+ requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000))
270+ }},
271+ {"already set custom mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
272+ requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000))
273+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
274+ }},
275+ {"already set position", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
276+ for range 2 {
277+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(0), int32(0))
278+ }
279+ }},
280+ {"already set transform", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
281+ for range 2 {
282+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(0))
283+ }
284+ }},
285+ {"already set scale", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
286+ for range 2 {
287+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(256))
288+ }
289+ }},
290+ {"already set adaptive sync", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
291+ for range 2 {
292+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
293+ }
294+ }},
295+ {"invalid mode", "head", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
296+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
297+ }},
298+ {"invalid custom mode", "head", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
299+ requestProtocol(t, c, ch, custom, int32(0), int32(600), int32(60000))
300+ }},
301+ {"invalid transform", "head", 4, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
302+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(8))
303+ }},
304+ {"invalid scale", "head", 5, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
305+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(0))
306+ }},
307+ {"invalid adaptive sync state", "head", 6, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
308+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(2))
309+ }},
310+ }
311+ for _, tc := range cases {
312+ t.Run(tc.name, func(t *testing.T) {
313+ s, _, _, _, dir := outputTestServer(t)
314+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
315+ t.Fatal("display stopped")
316+ }
317+ c := protocolClient(t, s, dir)
318+ managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
319+ p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
320+ p.SetID(managerID)
321+ c.Context().Register(p)
322+ if err := c.Roundtrip(); err != nil {
323+ t.Fatal(err)
324+ }
325+ head := recvManagement(t, p.events)
326+ if head[0] != wlr.ZwlrOutputManagerV1EventHead {
327+ t.Fatalf("head: %v", head)
328+ }
329+ var done [2]uint32
330+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
331+ done = recvManagement(t, p.events)
332+ }
333+ mode := <-p.modes
334+ if tc.name == "invalid mode" {
335+ other := bindVersion(t, c, "zwlr_output_manager_v1", 4)
336+ otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
337+ otherEvents.SetID(other)
338+ c.Context().Register(otherEvents)
339+ if err := c.Roundtrip(); err != nil {
340+ t.Fatal(err)
341+ }
342+ mode = <-otherEvents.modes
343+ }
344+ cfg := c.AllocateID()
345+ registerProtocol(t, c, cfg)
346+ requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
347+ var ch uint32
348+ if tc.name != "unconfigured head" {
349+ ch = c.AllocateID()
350+ registerProtocol(t, c, ch)
351+ requestProtocol(t, c, cfg, enable, ch, head[1])
352+ }
353+ if err := c.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ tc.send(t, c, cfg, ch, head[1], mode)
357+ object := cfg
358+ if tc.object == "head" {
359+ object = ch
360+ }
361+ expectProtocolError(t, c, object, tc.code)
362+ })
363+ }
364+}
365+
366+func TestOutputManagementInitialHeadState(t *testing.T) {
367+ for _, enabled := range []bool{true, false} {
368+ t.Run(map[bool]string{true: "enabled", false: "disabled"}[enabled], func(t *testing.T) {
369+ s, _, _, _, dir := outputTestServer(t)
370+ heads := testHead()
371+ heads.Heads[0].Enabled = enabled
372+ if !s.display.Do(func() {
373+ s.setOutputHeads(heads)
374+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, X: 100, Scale: 2}}})
375+ }) {
376+ t.Fatal("display stopped")
377+ }
378+ c := protocolClient(t, s, dir)
379+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
380+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
381+ p.SetID(id)
382+ c.Context().Register(p)
383+ if err := c.Roundtrip(); err != nil {
384+ t.Fatal(err)
385+ }
386+ for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) {
387+ }
388+ mode := <-p.modes
389+ found := map[uint32]bool{}
390+ for len(p.headEvents) > 0 {
391+ ev := <-p.headEvents
392+ found[ev[0]] = true
393+ if ev[0] == wlr.ZwlrOutputHeadV1EventEnabled && (ev[1] != 0) != enabled {
394+ t.Fatalf("enabled event: %v", ev)
395+ }
396+ if ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode && ev[1] != mode {
397+ t.Fatalf("current mode: %v, want %d", ev, mode)
398+ }
399+ }
400+ if !found[wlr.ZwlrOutputHeadV1EventMode] || !found[wlr.ZwlrOutputHeadV1EventEnabled] || found[wlr.ZwlrOutputHeadV1EventCurrentMode] != enabled || found[wlr.ZwlrOutputHeadV1EventTransform] != enabled || found[wlr.ZwlrOutputHeadV1EventPosition] != enabled || found[wlr.ZwlrOutputHeadV1EventScale] != enabled {
401+ t.Fatalf("head state: %v", found)
402+ }
403+ })
404+ }
405+}
406+
407+func TestOutputManagementStopDestroysManager(t *testing.T) {
408+ s, _, _, _, dir := outputTestServer(t)
409+ c := protocolClient(t, s, dir)
410+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
411+ p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
412+ p.SetID(id)
413+ c.Context().Register(p)
414+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop)
415+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone)
416+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished)
417+ if !s.display.Do(func() {
418+ if len(s.outputManagers) != 0 {
419+ t.Errorf("manager retained: %d", len(s.outputManagers))
420+ }
421+ }) {
422+ t.Fatal("display stopped")
423+ }
424+}
425+
426+func TestOutputManagementReleasedModeNotReused(t *testing.T) {
427+ s, _, _, _, dir := outputTestServer(t)
428+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
429+ t.Fatal("display stopped")
430+ }
431+ c := protocolClient(t, s, dir)
432+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
433+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
434+ p.SetID(id)
435+ c.Context().Register(p)
436+ if err := c.Roundtrip(); err != nil {
437+ t.Fatal(err)
438+ }
439+ for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) {
440+ }
441+ mode := <-p.modes
442+ for len(p.headEvents) > 0 {
443+ <-p.headEvents
444+ }
445+ requestProtocol(t, c, mode, wlr.ZwlrOutputModeV1RequestRelease)
446+ if err := c.Roundtrip(); err != nil {
447+ t.Fatal(err)
448+ }
449+ if !s.display.Do(func() {
450+ m := s.outputManagers[0]
451+ h := m.heads["HEADLESS-1"]
452+ if len(h.modes) != 0 || len(h.modeResources) != 0 {
453+ t.Errorf("released mode retained: %d %d", len(h.modes), len(h.modeResources))
454+ }
455+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: testHead().Heads[0].Info, X: 100, Scale: 1}}})
456+ }) {
457+ t.Fatal("display stopped")
458+ }
459+ if err := c.Roundtrip(); err != nil {
460+ t.Fatal(err)
461+ }
462+ for len(p.headEvents) > 0 {
463+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode {
464+ t.Fatalf("dead mode referenced: %v", ev)
465+ }
466+ }
467+}
468+
469+func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, uint32, uint32) {
470+ t.Helper()
471+ s, _, _, _, dir := outputTestServer(t)
472+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
473+ t.Fatal("display stopped")
474+ }
475+ c := protocolClient(t, s, dir)
476+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
477+ p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
478+ p.SetID(id)
479+ c.Context().Register(p)
480+ if err := c.Roundtrip(); err != nil {
481+ t.Fatal(err)
482+ }
483+ head := recvManagement(t, p.events)[1]
484+ var done [2]uint32
485+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
486+ done = recvManagement(t, p.events)
487+ }
488+ return s, c, id, head, <-p.modes, done[1]
489+}
490+
491+func TestOutputManagementSetModeAfterHotplug(t *testing.T) {
492+ s, c, id, head, mode, serial := managementSetup(t)
493+ cfg := c.AllocateID()
494+ registerProtocol(t, c, cfg)
495+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
496+ ch := c.AllocateID()
497+ registerProtocol(t, c, ch)
498+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
499+ if err := c.Roundtrip(); err != nil {
500+ t.Fatal(err)
501+ }
502+ if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
503+ t.Fatal("display stopped")
504+ }
505+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
506+ expectProtocolError(t, c, ch, uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode))
507+}
508+
509+func TestOutputManagementDestroyConfigurationChildren(t *testing.T) {
510+ s, c, id, head, _, serial := managementSetup(t)
511+ cfg := c.AllocateID()
512+ registerProtocol(t, c, cfg)
513+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
514+ ch := c.AllocateID()
515+ registerProtocol(t, c, ch)
516+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
517+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
518+ if err := c.Roundtrip(); err != nil {
519+ t.Fatal(err)
520+ }
521+ var child *wlr.ZwlrOutputConfigurationHeadV1
522+ if !s.display.Do(func() {
523+ for _, pending := range s.outputReplies {
524+ child = pending.heads["HEADLESS-1"].res
525+ }
526+ }) {
527+ t.Fatal("display stopped")
528+ }
529+ if child == nil {
530+ t.Fatal("no child resource")
531+ }
532+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
533+ if err := c.Roundtrip(); err != nil {
534+ t.Fatal(err)
535+ }
536+ if !s.display.Do(func() {
537+ if child.Alive() {
538+ t.Error("configuration head survived parent destruction")
539+ }
540+ }) {
541+ t.Fatal("display stopped")
542+ }
543+}
544+
545+func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) {
546+ _, c, id, _, _, serial := managementSetup(t)
547+ cfg := c.AllocateID()
548+ p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
549+ p.SetID(cfg)
550+ c.Context().Register(p)
551+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1)
552+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled)
553+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
554+ expectProtocolError(t, c, cfg, uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed))
555+ select {
556+ case ev := <-p.events:
557+ t.Fatalf("second terminal event: %v", ev)
558+ default:
559+ }
560+}
561+
562+func TestOutputManagementDestroyDropsPendingReply(t *testing.T) {
563+ s, c, id, head, _, serial := managementSetup(t)
564+ cfg := c.AllocateID()
565+ registerProtocol(t, c, cfg)
566+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
567+ ch := c.AllocateID()
568+ registerProtocol(t, c, ch)
569+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
570+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
571+ if err := c.Roundtrip(); err != nil {
572+ t.Fatal(err)
573+ }
574+ if !s.display.Do(func() {
575+ if len(s.outputReplies) != 1 {
576+ t.Errorf("pending replies: %d", len(s.outputReplies))
577+ }
578+ }) {
579+ t.Fatal("display stopped")
580+ }
581+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
582+ if err := c.Roundtrip(); err != nil {
583+ t.Fatal(err)
584+ }
585+ if !s.display.Do(func() {
586+ if len(s.outputReplies) != 0 {
587+ t.Errorf("orphan replies: %d", len(s.outputReplies))
588+ }
589+ }) {
590+ t.Fatal("display stopped")
591+ }
592+}
@@ -0,0 +1,592 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+ wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type managementEvents struct {
15+ wlturbo.BaseProxy
16+ events chan [2]uint32
17+ client *wlturbo.Display
18+ modes chan uint32
19+ headEvents chan [2]uint32
20+}
21+
22+func (p *managementEvents) Dispatch(e *wlturbo.Event) {
23+ msg := [2]uint32{uint32(e.Opcode), 0}
24+ if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) || e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventDone) {
25+ msg[1] = e.Uint32()
26+ }
27+ if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) {
28+ h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents}
29+ h.SetID(msg[1])
30+ p.client.Context().Register(h)
31+ }
32+ p.events <- msg
33+}
34+
35+type managementHeadEvents struct {
36+ wlturbo.BaseProxy
37+ client *wlturbo.Display
38+ modes chan uint32
39+ events chan [2]uint32
40+}
41+
42+func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) {
43+ if p.events != nil {
44+ value := uint32(0)
45+ switch uint32(e.Opcode) {
46+ case wlr.ZwlrOutputHeadV1EventEnabled, wlr.ZwlrOutputHeadV1EventCurrentMode, wlr.ZwlrOutputHeadV1EventTransform:
47+ value = e.Uint32()
48+ }
49+ p.events <- [2]uint32{uint32(e.Opcode), value}
50+ }
51+ if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
52+ mode := &protocolProxy{}
53+ mode.SetID(e.Uint32())
54+ p.client.Context().Register(mode)
55+ if p.modes != nil {
56+ p.modes <- mode.ID()
57+ }
58+ }
59+}
60+func outputTestServer(t *testing.T) (*Server, chan ports.OutputHeads, chan ports.OutputApply, chan ports.OutputApplied, string) {
61+ t.Helper()
62+ dir := t.TempDir()
63+ heads := make(chan ports.OutputHeads, 8)
64+ applies := make(chan ports.OutputApply, 8)
65+ replies := make(chan ports.OutputApplied, 8)
66+ s, err := New(Options{RuntimeDir: dir}, Channels{OutputHeads: heads, OutputApply: applies, OutputApplied: replies}, logging.For(context.Background(), "wayland"))
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ ctx, cancel := context.WithCancel(context.Background())
71+ done := make(chan error, 1)
72+ go func() { done <- s.Run(ctx) }()
73+ t.Cleanup(func() {
74+ cancel()
75+ if err := <-done; err != nil {
76+ t.Error(err)
77+ }
78+ })
79+ return s, heads, applies, replies, dir
80+}
81+func testHead() ports.OutputHeads {
82+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000, Preferred: true}
83+ return ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}}}
84+}
85+func recvManagement(t *testing.T, ch <-chan [2]uint32) [2]uint32 {
86+ t.Helper()
87+ select {
88+ case v := <-ch:
89+ return v
90+ case <-time.After(2 * time.Second):
91+ t.Fatal("management event timeout")
92+ return [2]uint32{}
93+ }
94+}
95+func waitManagementEvent(t *testing.T, c *wlturbo.Display, ch <-chan [2]uint32, want uint32) {
96+ t.Helper()
97+ for i := 0; i < 12; i++ {
98+ if err := c.Roundtrip(); err != nil {
99+ t.Fatal(err)
100+ }
101+ select {
102+ case ev := <-ch:
103+ if ev[0] != want {
104+ t.Fatalf("event %v want %d", ev, want)
105+ }
106+ return
107+ default:
108+ }
109+ }
110+ t.Fatal("missing management event")
111+}
112+func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
113+ s, _, _, _, dir := outputTestServer(t)
114+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
115+ t.Fatal("display stopped")
116+ }
117+ c := protocolClient(t, s, dir)
118+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
119+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
120+ manager.SetID(id)
121+ c.Context().Register(manager)
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ var first [2]uint32
126+ for first[0] != wlr.ZwlrOutputManagerV1EventDone {
127+ first = recvManagement(t, manager.events)
128+ }
129+ if first[0] != wlr.ZwlrOutputManagerV1EventDone {
130+ t.Fatalf("initial: %v", first)
131+ }
132+ if !s.display.Do(func() {
133+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, X: 100, Width: 1920, Height: 1080, Scale: 1}}})
134+ }) {
135+ t.Fatal("display stopped")
136+ }
137+ if err := c.Roundtrip(); err != nil {
138+ t.Fatal(err)
139+ }
140+ second := recvManagement(t, manager.events)
141+ if second[0] != wlr.ZwlrOutputManagerV1EventDone || second[1] == first[1] {
142+ t.Fatalf("layout serial: %v -> %v", first, second)
143+ }
144+ configID := c.AllocateID()
145+ proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
146+ proxy.SetID(configID)
147+ c.Context().Register(proxy)
148+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1])
149+ waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled))
150+ if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
151+ t.Fatal("display stopped")
152+ }
153+ if err := c.Roundtrip(); err != nil {
154+ t.Fatal(err)
155+ }
156+ third := recvManagement(t, manager.events)
157+ if third[0] != wlr.ZwlrOutputManagerV1EventDone || third[1] == second[1] {
158+ t.Fatalf("inventory serial: %v -> %v", second, third)
159+ }
160+}
161+
162+func TestOutputManagementHeadsAndApply(t *testing.T) {
163+ s, heads, apply, replies, dir := outputTestServer(t)
164+ heads <- testHead()
165+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
166+ t.Fatal("display stopped")
167+ }
168+ c := protocolClient(t, s, dir)
169+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
170+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
171+ manager.SetID(id)
172+ c.Context().Register(manager)
173+ if err := c.Roundtrip(); err != nil {
174+ t.Fatal(err)
175+ }
176+ head := recvManagement(t, manager.events)
177+ if head[0] != wlr.ZwlrOutputManagerV1EventHead {
178+ t.Fatalf("head event: %v", head)
179+ }
180+ var serial [2]uint32
181+ for serial[0] != wlr.ZwlrOutputManagerV1EventDone {
182+ serial = recvManagement(t, manager.events)
183+ }
184+ if serial[1] == 0 {
185+ t.Fatalf("done: %v", serial)
186+ }
187+ configID := c.AllocateID()
188+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
189+ configProxy.SetID(configID)
190+ c.Context().Register(configProxy)
191+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
192+ chID := c.AllocateID()
193+ registerProtocol(t, c, chID)
194+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
195+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(100), int32(0))
196+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestTest)
197+ if err := c.Roundtrip(); err != nil {
198+ t.Fatal(err)
199+ }
200+ var req ports.OutputApply
201+ select {
202+ case req = <-apply:
203+ case <-time.After(time.Second):
204+ t.Fatal("no test request")
205+ }
206+ if !req.Test || len(req.Heads) != 1 || req.Heads[0].Pos.X != 100 {
207+ t.Fatalf("test: %+v", req)
208+ }
209+ replies <- ports.OutputApplied{ID: req.ID}
210+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
211+ configID = c.AllocateID()
212+ configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c}
213+ configProxy.SetID(configID)
214+ c.Context().Register(configProxy)
215+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
216+ chID = c.AllocateID()
217+ registerProtocol(t, c, chID)
218+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
219+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(512))
220+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ select {
225+ case req = <-apply:
226+ case <-time.After(time.Second):
227+ t.Fatal("no apply request")
228+ }
229+ if req.Test || req.Heads[0].Scale != 2 {
230+ t.Fatalf("apply: %+v", req)
231+ }
232+ replies <- ports.OutputApplied{ID: req.ID}
233+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
234+}
235+
236+// Each case uses a new connection because wl_display.error terminates that client.
237+func TestOutputManagementProtocolErrors(t *testing.T) {
238+ const (
239+ enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead
240+ disable = wlr.ZwlrOutputConfigurationV1RequestDisableHead
241+ apply = wlr.ZwlrOutputConfigurationV1RequestApply
242+ custom = wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode
243+ )
244+ cases := []struct {
245+ name string
246+ object string
247+ code uint32
248+ send func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32)
249+ }{
250+ {"already configured (enable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
251+ requestProtocol(t, c, cfg, enable, c.AllocateID(), head)
252+ }},
253+ {"already configured (disable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
254+ requestProtocol(t, c, cfg, disable, head)
255+ }},
256+ {"unconfigured head", "configuration", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
257+ requestProtocol(t, c, cfg, apply)
258+ }},
259+ {"already used (configuration)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
260+ requestProtocol(t, c, cfg, apply)
261+ requestProtocol(t, c, cfg, apply)
262+ }},
263+ {"already used (head)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
264+ requestProtocol(t, c, cfg, apply)
265+ requestProtocol(t, c, cfg, enable, c.AllocateID(), head)
266+ }},
267+ {"already set mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
268+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
269+ requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000))
270+ }},
271+ {"already set custom mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
272+ requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000))
273+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
274+ }},
275+ {"already set position", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
276+ for range 2 {
277+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(0), int32(0))
278+ }
279+ }},
280+ {"already set transform", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
281+ for range 2 {
282+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(0))
283+ }
284+ }},
285+ {"already set scale", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
286+ for range 2 {
287+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(256))
288+ }
289+ }},
290+ {"already set adaptive sync", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
291+ for range 2 {
292+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
293+ }
294+ }},
295+ {"invalid mode", "head", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
296+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
297+ }},
298+ {"invalid custom mode", "head", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
299+ requestProtocol(t, c, ch, custom, int32(0), int32(600), int32(60000))
300+ }},
301+ {"invalid transform", "head", 4, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
302+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(8))
303+ }},
304+ {"invalid scale", "head", 5, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
305+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(0))
306+ }},
307+ {"invalid adaptive sync state", "head", 6, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) {
308+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(2))
309+ }},
310+ }
311+ for _, tc := range cases {
312+ t.Run(tc.name, func(t *testing.T) {
313+ s, _, _, _, dir := outputTestServer(t)
314+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
315+ t.Fatal("display stopped")
316+ }
317+ c := protocolClient(t, s, dir)
318+ managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
319+ p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
320+ p.SetID(managerID)
321+ c.Context().Register(p)
322+ if err := c.Roundtrip(); err != nil {
323+ t.Fatal(err)
324+ }
325+ head := recvManagement(t, p.events)
326+ if head[0] != wlr.ZwlrOutputManagerV1EventHead {
327+ t.Fatalf("head: %v", head)
328+ }
329+ var done [2]uint32
330+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
331+ done = recvManagement(t, p.events)
332+ }
333+ mode := <-p.modes
334+ if tc.name == "invalid mode" {
335+ other := bindVersion(t, c, "zwlr_output_manager_v1", 4)
336+ otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
337+ otherEvents.SetID(other)
338+ c.Context().Register(otherEvents)
339+ if err := c.Roundtrip(); err != nil {
340+ t.Fatal(err)
341+ }
342+ mode = <-otherEvents.modes
343+ }
344+ cfg := c.AllocateID()
345+ registerProtocol(t, c, cfg)
346+ requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
347+ var ch uint32
348+ if tc.name != "unconfigured head" {
349+ ch = c.AllocateID()
350+ registerProtocol(t, c, ch)
351+ requestProtocol(t, c, cfg, enable, ch, head[1])
352+ }
353+ if err := c.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ tc.send(t, c, cfg, ch, head[1], mode)
357+ object := cfg
358+ if tc.object == "head" {
359+ object = ch
360+ }
361+ expectProtocolError(t, c, object, tc.code)
362+ })
363+ }
364+}
365+
366+func TestOutputManagementInitialHeadState(t *testing.T) {
367+ for _, enabled := range []bool{true, false} {
368+ t.Run(map[bool]string{true: "enabled", false: "disabled"}[enabled], func(t *testing.T) {
369+ s, _, _, _, dir := outputTestServer(t)
370+ heads := testHead()
371+ heads.Heads[0].Enabled = enabled
372+ if !s.display.Do(func() {
373+ s.setOutputHeads(heads)
374+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, X: 100, Scale: 2}}})
375+ }) {
376+ t.Fatal("display stopped")
377+ }
378+ c := protocolClient(t, s, dir)
379+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
380+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
381+ p.SetID(id)
382+ c.Context().Register(p)
383+ if err := c.Roundtrip(); err != nil {
384+ t.Fatal(err)
385+ }
386+ for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) {
387+ }
388+ mode := <-p.modes
389+ found := map[uint32]bool{}
390+ for len(p.headEvents) > 0 {
391+ ev := <-p.headEvents
392+ found[ev[0]] = true
393+ if ev[0] == wlr.ZwlrOutputHeadV1EventEnabled && (ev[1] != 0) != enabled {
394+ t.Fatalf("enabled event: %v", ev)
395+ }
396+ if ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode && ev[1] != mode {
397+ t.Fatalf("current mode: %v, want %d", ev, mode)
398+ }
399+ }
400+ if !found[wlr.ZwlrOutputHeadV1EventMode] || !found[wlr.ZwlrOutputHeadV1EventEnabled] || found[wlr.ZwlrOutputHeadV1EventCurrentMode] != enabled || found[wlr.ZwlrOutputHeadV1EventTransform] != enabled || found[wlr.ZwlrOutputHeadV1EventPosition] != enabled || found[wlr.ZwlrOutputHeadV1EventScale] != enabled {
401+ t.Fatalf("head state: %v", found)
402+ }
403+ })
404+ }
405+}
406+
407+func TestOutputManagementStopDestroysManager(t *testing.T) {
408+ s, _, _, _, dir := outputTestServer(t)
409+ c := protocolClient(t, s, dir)
410+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
411+ p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
412+ p.SetID(id)
413+ c.Context().Register(p)
414+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop)
415+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone)
416+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished)
417+ if !s.display.Do(func() {
418+ if len(s.outputManagers) != 0 {
419+ t.Errorf("manager retained: %d", len(s.outputManagers))
420+ }
421+ }) {
422+ t.Fatal("display stopped")
423+ }
424+}
425+
426+func TestOutputManagementReleasedModeNotReused(t *testing.T) {
427+ s, _, _, _, dir := outputTestServer(t)
428+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
429+ t.Fatal("display stopped")
430+ }
431+ c := protocolClient(t, s, dir)
432+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
433+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
434+ p.SetID(id)
435+ c.Context().Register(p)
436+ if err := c.Roundtrip(); err != nil {
437+ t.Fatal(err)
438+ }
439+ for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) {
440+ }
441+ mode := <-p.modes
442+ for len(p.headEvents) > 0 {
443+ <-p.headEvents
444+ }
445+ requestProtocol(t, c, mode, wlr.ZwlrOutputModeV1RequestRelease)
446+ if err := c.Roundtrip(); err != nil {
447+ t.Fatal(err)
448+ }
449+ if !s.display.Do(func() {
450+ m := s.outputManagers[0]
451+ h := m.heads["HEADLESS-1"]
452+ if len(h.modes) != 0 || len(h.modeResources) != 0 {
453+ t.Errorf("released mode retained: %d %d", len(h.modes), len(h.modeResources))
454+ }
455+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: testHead().Heads[0].Info, X: 100, Scale: 1}}})
456+ }) {
457+ t.Fatal("display stopped")
458+ }
459+ if err := c.Roundtrip(); err != nil {
460+ t.Fatal(err)
461+ }
462+ for len(p.headEvents) > 0 {
463+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode {
464+ t.Fatalf("dead mode referenced: %v", ev)
465+ }
466+ }
467+}
468+
469+func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, uint32, uint32) {
470+ t.Helper()
471+ s, _, _, _, dir := outputTestServer(t)
472+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
473+ t.Fatal("display stopped")
474+ }
475+ c := protocolClient(t, s, dir)
476+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
477+ p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
478+ p.SetID(id)
479+ c.Context().Register(p)
480+ if err := c.Roundtrip(); err != nil {
481+ t.Fatal(err)
482+ }
483+ head := recvManagement(t, p.events)[1]
484+ var done [2]uint32
485+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
486+ done = recvManagement(t, p.events)
487+ }
488+ return s, c, id, head, <-p.modes, done[1]
489+}
490+
491+func TestOutputManagementSetModeAfterHotplug(t *testing.T) {
492+ s, c, id, head, mode, serial := managementSetup(t)
493+ cfg := c.AllocateID()
494+ registerProtocol(t, c, cfg)
495+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
496+ ch := c.AllocateID()
497+ registerProtocol(t, c, ch)
498+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
499+ if err := c.Roundtrip(); err != nil {
500+ t.Fatal(err)
501+ }
502+ if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
503+ t.Fatal("display stopped")
504+ }
505+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode)
506+ expectProtocolError(t, c, ch, uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode))
507+}
508+
509+func TestOutputManagementDestroyConfigurationChildren(t *testing.T) {
510+ s, c, id, head, _, serial := managementSetup(t)
511+ cfg := c.AllocateID()
512+ registerProtocol(t, c, cfg)
513+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
514+ ch := c.AllocateID()
515+ registerProtocol(t, c, ch)
516+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
517+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
518+ if err := c.Roundtrip(); err != nil {
519+ t.Fatal(err)
520+ }
521+ var child *wlr.ZwlrOutputConfigurationHeadV1
522+ if !s.display.Do(func() {
523+ for _, pending := range s.outputReplies {
524+ child = pending.heads["HEADLESS-1"].res
525+ }
526+ }) {
527+ t.Fatal("display stopped")
528+ }
529+ if child == nil {
530+ t.Fatal("no child resource")
531+ }
532+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
533+ if err := c.Roundtrip(); err != nil {
534+ t.Fatal(err)
535+ }
536+ if !s.display.Do(func() {
537+ if child.Alive() {
538+ t.Error("configuration head survived parent destruction")
539+ }
540+ }) {
541+ t.Fatal("display stopped")
542+ }
543+}
544+
545+func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) {
546+ _, c, id, _, _, serial := managementSetup(t)
547+ cfg := c.AllocateID()
548+ p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
549+ p.SetID(cfg)
550+ c.Context().Register(p)
551+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1)
552+ waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled)
553+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
554+ expectProtocolError(t, c, cfg, uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed))
555+ select {
556+ case ev := <-p.events:
557+ t.Fatalf("second terminal event: %v", ev)
558+ default:
559+ }
560+}
561+
562+func TestOutputManagementDestroyDropsPendingReply(t *testing.T) {
563+ s, c, id, head, _, serial := managementSetup(t)
564+ cfg := c.AllocateID()
565+ registerProtocol(t, c, cfg)
566+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial)
567+ ch := c.AllocateID()
568+ registerProtocol(t, c, ch)
569+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head)
570+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
571+ if err := c.Roundtrip(); err != nil {
572+ t.Fatal(err)
573+ }
574+ if !s.display.Do(func() {
575+ if len(s.outputReplies) != 1 {
576+ t.Errorf("pending replies: %d", len(s.outputReplies))
577+ }
578+ }) {
579+ t.Fatal("display stopped")
580+ }
581+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
582+ if err := c.Roundtrip(); err != nil {
583+ t.Fatal(err)
584+ }
585+ if !s.display.Do(func() {
586+ if len(s.outputReplies) != 0 {
587+ t.Errorf("orphan replies: %d", len(s.outputReplies))
588+ }
589+ }) {
590+ t.Fatal("display stopped")
591+ }
592+}
M internal/adapters/wayland/outputs.go +8 -0
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "math"
5+ "slices"
56 "strings"
67
78 "github.com/bnema/neferwl/internal/ports"
@@ -135,6 +136,13 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
135136 // advertised, gone ones withdrawn (their layer surfaces are closed), and
136137 // changed ones resent.
137138 func (s *Server) setOutputs(c ports.SetOutputs) {
139+ changed := !slices.Equal(s.outputPlaces, c.Outputs)
140+ s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
141+ defer func() {
142+ if changed {
143+ s.refreshOutputManagers()
144+ }
145+ }()
138146 s.focusedOutput = c.Focused
139147 want := map[string]ports.OutputPlacement{}
140148 for _, p := range c.Outputs {
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "math"4 "math"
5+ "slices"
5 "strings"6 "strings"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -135,6 +136,13 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
135 // advertised, gone ones withdrawn (their layer surfaces are closed), and136 // advertised, gone ones withdrawn (their layer surfaces are closed), and
136 // changed ones resent.137 // changed ones resent.
137 func (s *Server) setOutputs(c ports.SetOutputs) {138 func (s *Server) setOutputs(c ports.SetOutputs) {
139+ changed := !slices.Equal(s.outputPlaces, c.Outputs)
140+ s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
141+ defer func() {
142+ if changed {
143+ s.refreshOutputManagers()
144+ }
145+ }()
138 s.focusedOutput = c.Focused146 s.focusedOutput = c.Focused
139 want := map[string]ports.OutputPlacement{}147 want := map[string]ports.OutputPlacement{}
140 for _, p := range c.Outputs {148 for _, p := range c.Outputs {
M internal/adapters/wayland/server.go +70 -9
@@ -50,6 +50,9 @@ type Channels struct {
5050 // OutputFormats are the outputs' direct scanout formats, offered in
5151 // dmabuf feedback to fullscreen surfaces.
5252 OutputFormats <-chan ports.OutputFormats
53+ OutputHeads <-chan ports.OutputHeads
54+ OutputApply chan<- ports.OutputApply
55+ OutputApplied <-chan ports.OutputApplied
5356 }
5457 type Server struct {
5558 nextCapture uint64
@@ -138,14 +141,20 @@ type Server struct {
138141 shortcutWindows, idleWindows map[ports.WindowID]bool
139142 // idleNotes and powers are the idle notifications and output power
140143 // objects (idle.go); outputsOff the outputs core turned off.
141- idleNotes []*idleNotification
142- powers []*outputPower
143- outputsOff map[string]bool
144- contentNotify chan struct{}
145- contentReady chan struct{}
146- outputs []*output
147- focusedOutput string
148- fractions map[*surface]*fractionalscale.WpFractionalScaleV1
144+ idleNotes []*idleNotification
145+ powers []*outputPower
146+ outputsOff map[string]bool
147+ contentNotify chan struct{}
148+ contentReady chan struct{}
149+ outputs []*output
150+ outputManagers []*outputManager
151+ outputHeads ports.OutputHeads
152+ outputPlaces ports.Layout
153+ managementSerial uint32
154+ nextOutputApply uint64
155+ outputReplies map[uint64]*outputConfiguration
156+ focusedOutput string
157+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
149158 // cursorSurface is the wl_pointer.set_cursor surface in use.
150159 cursorSurface *surface
151160 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -198,6 +207,8 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
198207 }
199208 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
200209 s.captureReplies = map[uint64]func(ports.CaptureDone){}
210+ s.outputReplies = map[uint64]*outputConfiguration{}
211+ s.managementSerial = 1
201212 s.captureSources = map[*server.Resource]*output{}
202213 s.captureSessions = map[*captureSession]struct{}{}
203214 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
@@ -263,7 +274,7 @@ func (s *Server) Run(ctx context.Context) error {
263274 s.started = time.Now()
264275 s.ctx = ctx
265276 var wg sync.WaitGroup
266- wg.Add(7)
277+ wg.Add(9)
267278 go func() { defer wg.Done(); s.forward(ctx) }()
268279 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
269280 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -301,6 +312,8 @@ func (s *Server) Run(ctx context.Context) error {
301312 }()
302313 go func() { defer wg.Done(); s.pace(ctx) }()
303314 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
315+ go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
316+ go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
304317 go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()
305318 if s.syncWait != nil {
306319 wg.Add(1)
@@ -311,6 +324,54 @@ func (s *Server) Run(ctx context.Context) error {
311324 return err
312325 }
313326
327+// forwardOutputHeads delivers backend inventory on the display owner goroutine.
328+func (s *Server) forwardOutputHeads(ctx context.Context) {
329+ for {
330+ select {
331+ case <-ctx.Done():
332+ return
333+ case <-s.display.Stopped():
334+ return
335+ case heads, ok := <-s.channels.OutputHeads:
336+ if !ok {
337+ return
338+ }
339+ if !s.display.Do(func() { s.setOutputHeads(heads) }) {
340+ return
341+ }
342+ }
343+ }
344+}
345+
346+func (s *Server) forwardOutputApplied(ctx context.Context) {
347+ for {
348+ select {
349+ case <-ctx.Done():
350+ return
351+ case <-s.display.Stopped():
352+ return
353+ case result, ok := <-s.channels.OutputApplied:
354+ if !ok {
355+ return
356+ }
357+ if !s.display.Do(func() {
358+ c := s.outputReplies[result.ID]
359+ delete(s.outputReplies, result.ID)
360+ if c == nil || !c.res.Alive() {
361+ return
362+ }
363+ if result.Err != nil {
364+ c.res.SendFailed()
365+ } else {
366+ c.res.SendSucceeded()
367+ }
368+ }) {
369+ return
370+ }
371+ }
372+ }
373+}
374+
314375 // damageHistory is how many contents a window's damage history covers:
315376 // enough for a renderer target a few frames old.
316377 const damageHistory = 4
@@ -50,6 +50,9 @@ type Channels struct {
50 // OutputFormats are the outputs' direct scanout formats, offered in50 // OutputFormats are the outputs' direct scanout formats, offered in
51 // dmabuf feedback to fullscreen surfaces.51 // dmabuf feedback to fullscreen surfaces.
52 OutputFormats <-chan ports.OutputFormats52 OutputFormats <-chan ports.OutputFormats
53+ OutputHeads <-chan ports.OutputHeads
54+ OutputApply chan<- ports.OutputApply
55+ OutputApplied <-chan ports.OutputApplied
53 }56 }
54 type Server struct {57 type Server struct {
55 nextCapture uint6458 nextCapture uint64
@@ -138,14 +141,20 @@ type Server struct {
138 shortcutWindows, idleWindows map[ports.WindowID]bool141 shortcutWindows, idleWindows map[ports.WindowID]bool
139 // idleNotes and powers are the idle notifications and output power142 // idleNotes and powers are the idle notifications and output power
140 // objects (idle.go); outputsOff the outputs core turned off.143 // objects (idle.go); outputsOff the outputs core turned off.
141- idleNotes []*idleNotification144+ idleNotes []*idleNotification
142- powers []*outputPower145+ powers []*outputPower
143- outputsOff map[string]bool146+ outputsOff map[string]bool
144- contentNotify chan struct{}147+ contentNotify chan struct{}
145- contentReady chan struct{}148+ contentReady chan struct{}
146- outputs []*output149+ outputs []*output
147- focusedOutput string150+ outputManagers []*outputManager
148- fractions map[*surface]*fractionalscale.WpFractionalScaleV1151+ outputHeads ports.OutputHeads
152+ outputPlaces ports.Layout
153+ managementSerial uint32
154+ nextOutputApply uint64
155+ outputReplies map[uint64]*outputConfiguration
156+ focusedOutput string
157+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
149 // cursorSurface is the wl_pointer.set_cursor surface in use.158 // cursorSurface is the wl_pointer.set_cursor surface in use.
150 cursorSurface *surface159 cursorSurface *surface
151 // cursorMu guards only the latest cursor change for forwardCursors.160 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -198,6 +207,8 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
198 }207 }
199 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}208 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
200 s.captureReplies = map[uint64]func(ports.CaptureDone){}209 s.captureReplies = map[uint64]func(ports.CaptureDone){}
210+ s.outputReplies = map[uint64]*outputConfiguration{}
211+ s.managementSerial = 1
201 s.captureSources = map[*server.Resource]*output{}212 s.captureSources = map[*server.Resource]*output{}
202 s.captureSessions = map[*captureSession]struct{}{}213 s.captureSessions = map[*captureSession]struct{}{}
203 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}214 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
@@ -263,7 +274,7 @@ func (s *Server) Run(ctx context.Context) error {
263 s.started = time.Now()274 s.started = time.Now()
264 s.ctx = ctx275 s.ctx = ctx
265 var wg sync.WaitGroup276 var wg sync.WaitGroup
266- wg.Add(7)277+ wg.Add(9)
267 go func() { defer wg.Done(); s.forward(ctx) }()278 go func() { defer wg.Done(); s.forward(ctx) }()
268 go func() { defer wg.Done(); s.forwardCursors(ctx) }()279 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
269 go func() { defer wg.Done(); s.forwardContents(ctx) }()280 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -301,6 +312,8 @@ func (s *Server) Run(ctx context.Context) error {
301 }()312 }()
302 go func() { defer wg.Done(); s.pace(ctx) }()313 go func() { defer wg.Done(); s.pace(ctx) }()
303 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()314 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
315+ go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
316+ go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
304 go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()317 go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()
305 if s.syncWait != nil {318 if s.syncWait != nil {
306 wg.Add(1)319 wg.Add(1)
@@ -311,6 +324,54 @@ func (s *Server) Run(ctx context.Context) error {
311 return err324 return err
312 }325 }
313 326
327+// forwardOutputHeads delivers backend inventory on the display owner goroutine.
328+func (s *Server) forwardOutputHeads(ctx context.Context) {
329+ for {
330+ select {
331+ case <-ctx.Done():
332+ return
333+ case <-s.display.Stopped():
334+ return
335+ case heads, ok := <-s.channels.OutputHeads:
336+ if !ok {
337+ return
338+ }
339+ if !s.display.Do(func() { s.setOutputHeads(heads) }) {
340+ return
341+ }
342+ }
343+ }
344+}
345+
346+func (s *Server) forwardOutputApplied(ctx context.Context) {
347+ for {
348+ select {
349+ case <-ctx.Done():
350+ return
351+ case <-s.display.Stopped():
352+ return
353+ case result, ok := <-s.channels.OutputApplied:
354+ if !ok {
355+ return
356+ }
357+ if !s.display.Do(func() {
358+ c := s.outputReplies[result.ID]
359+ delete(s.outputReplies, result.ID)
360+ if c == nil || !c.res.Alive() {
361+ return
362+ }
363+ if result.Err != nil {
364+ c.res.SendFailed()
365+ } else {
366+ c.res.SendSucceeded()
367+ }
368+ }) {
369+ return
370+ }
371+ }
372+ }
373+}
374+
314 // damageHistory is how many contents a window's damage history covers:375 // damageHistory is how many contents a window's damage history covers:
315 // enough for a renderer target a few frames old.376 // enough for a renderer target a few frames old.
316 const damageHistory = 4377 const damageHistory = 4
M internal/app/app.go +18 -6
@@ -91,6 +91,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9191 captures := make(chan ports.CaptureRequest, 32)
9292 captured := make(chan ports.CaptureDone, 64)
9393 outputFormats := make(chan ports.OutputFormats, 8)
94+ outputHeads := make(chan ports.OutputHeads, 8)
95+ inventory := make(chan ports.OutputHeads, 8)
96+ applyOutput := make(chan ports.OutputApply, 8)
97+ appliedOutput := make(chan ports.OutputApplied, 8)
98+ backendConfig := make(chan ports.Config, 8)
99+ backendApplied := make(chan error, 8)
94100 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
95101 c, err := core.New(opts.Config, ch)
96102 if err != nil {
@@ -105,7 +111,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
105111 // Every output renders on the same GPU: its formats are the clients'.
106112 dmabuf := vulkan.Probe()
107113 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
108- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
114+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
109115 if err != nil {
110116 km.Close()
111117 return err
@@ -144,10 +150,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
144150 defer workers.Done()
145151 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
146152 }()
153+ filtered := make(chan ports.ConfigChanged, 8)
147154 workers.Add(1)
148155 go func() {
149156 defer workers.Done()
150- relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
157+ relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config"))
158+ }()
159+ workers.Add(1)
160+ go func() {
161+ defer workers.Done()
162+ relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app"))
151163 }()
152164 script := make(chan string)
153165 curs := newCursors()
@@ -219,16 +231,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
219231 }
220232 if hw != nil {
221233 done <- safe("output", func() error {
222- want := func() drm.Want {
223- w := wantFromConfig(opts.Config)
234+ want := func(cfg ports.Config) drm.Want {
235+ w := wantFromConfig(cfg)
224236 w.Sampled = dmabuf.Formats
225237 return w
226238 }
227- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
239+ return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm"))
228240 })
229241 return
230242 }
231- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render"))
243+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
232244 }()
233245
234246 if hw != nil {
@@ -91,6 +91,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
91 captures := make(chan ports.CaptureRequest, 32)91 captures := make(chan ports.CaptureRequest, 32)
92 captured := make(chan ports.CaptureDone, 64)92 captured := make(chan ports.CaptureDone, 64)
93 outputFormats := make(chan ports.OutputFormats, 8)93 outputFormats := make(chan ports.OutputFormats, 8)
94+ outputHeads := make(chan ports.OutputHeads, 8)
95+ inventory := make(chan ports.OutputHeads, 8)
96+ applyOutput := make(chan ports.OutputApply, 8)
97+ appliedOutput := make(chan ports.OutputApplied, 8)
98+ backendConfig := make(chan ports.Config, 8)
99+ backendApplied := make(chan error, 8)
94 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}100 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
95 c, err := core.New(opts.Config, ch)101 c, err := core.New(opts.Config, ch)
96 if err != nil {102 if err != nil {
@@ -105,7 +111,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
105 // Every output renders on the same GPU: its formats are the clients'.111 // Every output renders on the same GPU: its formats are the clients'.
106 dmabuf := vulkan.Probe()112 dmabuf := vulkan.Probe()
107 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")113 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
108- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))114+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
109 if err != nil {115 if err != nil {
110 km.Close()116 km.Close()
111 return err117 return err
@@ -144,10 +150,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
144 defer workers.Done()150 defer workers.Done()
145 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))151 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
146 }()152 }()
153+ filtered := make(chan ports.ConfigChanged, 8)
147 workers.Add(1)154 workers.Add(1)
148 go func() {155 go func() {
149 defer workers.Done()156 defer workers.Done()
150- relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))157+ relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config"))
158+ }()
159+ workers.Add(1)
160+ go func() {
161+ defer workers.Done()
162+ relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app"))
151 }()163 }()
152 script := make(chan string)164 script := make(chan string)
153 curs := newCursors()165 curs := newCursors()
@@ -219,16 +231,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
219 }231 }
220 if hw != nil {232 if hw != nil {
221 done <- safe("output", func() error {233 done <- safe("output", func() error {
222- want := func() drm.Want {234+ want := func(cfg ports.Config) drm.Want {
223- w := wantFromConfig(opts.Config)235+ w := wantFromConfig(cfg)
224 w.Sampled = dmabuf.Formats236 w.Sampled = dmabuf.Formats
225 return w237 return w
226 }238 }
227- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))239+ return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm"))
228 })240 })
229 return241 return
230 }242 }
231- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render"))243+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
232 }()244 }()
233 245
234 if hw != nil {246 if hw != nil {
M internal/app/drm.go +171 -5
@@ -8,6 +8,7 @@ import (
88 "runtime/debug"
99 "sort"
1010 "sync"
11+ "time"
1112
1213 "github.com/bnema/neferwl/internal/adapters/config"
1314 "github.com/bnema/neferwl/internal/adapters/drm"
@@ -30,6 +31,26 @@ type drmCard struct {
3031 fd int
3132 }
3233
34+// A failed card read must not erase its heads from a multi-card inventory.
35+func retainedHeads(previous, found []ports.OutputHead, err error) []ports.OutputHead {
36+ if err != nil {
37+ return previous
38+ }
39+ return found
40+}
41+
42+// A stopped instance is no longer enabled, even if the inventory read fails.
43+func releasedHead(heads []ports.OutputHead, name string) []ports.OutputHead {
44+ heads = append([]ports.OutputHead(nil), heads...)
45+ for i := range heads {
46+ if heads[i].Info.Name == name {
47+ heads[i].Enabled = false
48+ heads[i].Current = nil
49+ }
50+ }
51+ return heads
52+}
53+
3354 // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
3455 func safe(name string, fn func() error) (err error) {
3556 defer func() {
@@ -114,7 +135,7 @@ func (b *drmBackend) close() {
114135 // one flip reader per card, and a udev watcher that rescans connectors on
115136 // hotplug. Core learns about outputs through events. It returns when ctx
116137 // ends, after every output is closed.
117-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
138+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
118139 ctx, cancel := context.WithCancel(ctx)
119140 defer cancel()
120141 var readers sync.WaitGroup
@@ -145,20 +166,71 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
145166 // stopping holds outputs asked to stop: true when they restart with a
146167 // new mode (core keeps the screen), false when they are gone.
147168 stopping := map[string]bool{}
169+ currentConfig := initial
170+ var currentHeads ports.OutputHeads
171+ lastHeads := map[*drmCard][]ports.OutputHead{}
172+ progress := &applyProgress{completed: map[<-chan error]bool{}}
173+ readySources := map[string]<-chan error{}
174+ stopReady := map[string]chan struct{}{}
175+ type readyResult struct {
176+ name string
177+ source <-chan error
178+ err error
179+ }
180+ ready := make(chan readyResult)
181+ deadline := make(chan uint64, 1)
182+ var timer *time.Timer
183+ var op uint64
184+ complete := func(d applyDecision) {
185+ if !d.reply {
186+ return
187+ }
188+ if timer != nil {
189+ timer.Stop()
190+ timer = nil
191+ }
192+ select {
193+ case applied <- d.err:
194+ case <-ctx.Done():
195+ }
196+ }
197+ publishInventory := func() {
198+ var inventory ports.OutputHeads
199+ for _, card := range b.cards {
200+ found, err := card.ConnectedHeads()
201+ if err != nil {
202+ log.Warn().Err(err).Str("card", card.Path()).Msg("inventory outputs")
203+ }
204+ lastHeads[card] = retainedHeads(lastHeads[card], found, err)
205+ inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206+ }
207+ currentHeads = inventory
208+ select {
209+ case heads <- inventory:
210+ case <-ctx.Done():
211+ }
212+ select {
213+ case report <- inventory:
214+ case <-ctx.Done():
215+ }
216+ }
148217 scan := func() {
218+ var scanErrors []error
149219 for _, c := range b.cards {
150- w := want()
220+ w := want(currentConfig)
151221 w.Device = c.Device()
152222 c.SetWant(w)
153223 added, removed, replaced, err := c.Scan()
154224 if err != nil {
155- log.Warn().Err(err).Msg("scan connectors")
225+ log.Warn().Err(err).Str("card", c.Path()).Msg("scan connectors")
226+ scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
156227 continue
157228 }
158229 stop := func(name string, restart bool) {
159230 if r := set.outs[name]; r != nil && cards[name] == c {
160231 if _, ok := stopping[name]; !ok {
161232 stopping[name] = restart
233+ progress.stopping(name)
162234 r.stop()
163235 }
164236 }
@@ -181,6 +253,25 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
181253 continue
182254 }
183255 cards[name] = c
256+ readySources[name] = o.Ready()
257+ if progress.active && !want(currentConfig).Disabled[name] {
258+ progress.required[name] = true
259+ }
260+ source := readySources[name]
261+ stopped := make(chan struct{})
262+ stopReady[name] = stopped
263+ go func() {
264+ select {
265+ case err := <-source:
266+ select {
267+ case ready <- readyResult{name, source, err}:
268+ case <-ctx.Done():
269+ }
270+ case <-stopped:
271+ case <-ctx.Done():
272+ }
273+ }()
274+ progress.started(name, source)
184275 if cur := o.Cursor(); cur != nil {
185276 curs.set(name, cur)
186277 }
@@ -195,6 +286,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
195286 send(ports.OutputAdded{Info: o.Info()})
196287 }
197288 }
289+ publishInventory()
290+ complete(progress.scanError(errors.Join(scanErrors...)))
198291 }
199292 scan()
200293 if len(set.outs) == 0 {
@@ -210,6 +303,53 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
210303 case <-hotplug:
211304 log.Info().Msg("hotplug")
212305 scan()
306+ if progress.active {
307+ running := map[string]<-chan error{}
308+ for name := range set.outs {
309+ running[name] = readySources[name]
310+ }
311+ complete(progress.scanned(running))
312+ }
313+ case currentConfig = <-configs:
314+ op++
315+ if timer != nil {
316+ timer.Stop()
317+ timer = nil
318+ }
319+ required := map[string]bool{}
320+ // Desired enabled heads, not the inventory's current mode, require a first modeset.
321+ w := want(currentConfig)
322+ for _, h := range currentHeads.Heads {
323+ if !w.Disabled[h.Info.Name] {
324+ required[h.Info.Name] = true
325+ }
326+ }
327+ for name := range set.outs {
328+ if !w.Disabled[name] {
329+ required[name] = true
330+ }
331+ }
332+ progress.start(op, required, stopping)
333+ scan()
334+ running := map[string]<-chan error{}
335+ for name := range set.outs {
336+ running[name] = readySources[name]
337+ }
338+ d := progress.scanned(running)
339+ if progress.active {
340+ id := op
341+ timer = time.AfterFunc(5*time.Second, func() {
342+ select {
343+ case deadline <- id:
344+ case <-ctx.Done():
345+ }
346+ })
347+ }
348+ complete(d)
349+ case result := <-ready:
350+ complete(progress.readyEvent(result.name, result.source, result.err))
351+ case id := <-deadline:
352+ complete(progress.timeout(id))
213353 case s := <-scenes:
214354 set.scenes(s)
215355 case c := <-contents:
@@ -226,8 +366,17 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
226366 restart := stopping[name]
227367 delete(stopping, name)
228368 curs.set(name, nil)
229- cards[name].Release(name)
369+ card := cards[name]
370+ card.Release(name)
371+ lastHeads[card] = releasedHead(lastHeads[card], name)
230372 delete(cards, name)
373+ source := readySources[name]
374+ delete(readySources, name)
375+ if stopped := stopReady[name]; stopped != nil {
376+ close(stopped)
377+ delete(stopReady, name)
378+ }
379+ stopDecision := progress.stopped(name, source, restart, err)
231380 if err != nil {
232381 // A broken output (e.g. its renderer) must not take the
233382 // session down. It stays off until the next hotplug, so a
@@ -235,15 +384,32 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
235384 log.Error().Err(err).Str("connector", name).Msg("output stopped")
236385 restart = false
237386 }
387+ // Release changes the enabled state in ConnectedHeads. Do not
388+ // restart a failed or disabled output just to refresh inventory.
389+ if restart {
390+ scan()
391+ } else {
392+ publishInventory()
393+ }
394+ complete(stopDecision)
238395 if restart {
239396 // Same connector, new mode: core keeps its screen and
240397 // workspaces; OutputAdded updates the size.
241- scan()
242398 if set.outs[name] != nil {
399+ running := map[string]<-chan error{}
400+ for n := range set.outs {
401+ running[n] = readySources[n]
402+ }
403+ complete(progress.scanned(running))
243404 continue
244405 }
245406 }
246407 send(ports.OutputRemoved{Name: name})
408+ running := map[string]<-chan error{}
409+ for n := range set.outs {
410+ running[n] = readySources[n]
411+ }
412+ complete(progress.scanned(running))
247413 }
248414 }
249415 }
@@ -8,6 +8,7 @@ import (
8 "runtime/debug"8 "runtime/debug"
9 "sort"9 "sort"
10 "sync"10 "sync"
11+ "time"
11 12
12 "github.com/bnema/neferwl/internal/adapters/config"13 "github.com/bnema/neferwl/internal/adapters/config"
13 "github.com/bnema/neferwl/internal/adapters/drm"14 "github.com/bnema/neferwl/internal/adapters/drm"
@@ -30,6 +31,26 @@ type drmCard struct {
30 fd int31 fd int
31 }32 }
32 33
34+// A failed card read must not erase its heads from a multi-card inventory.
35+func retainedHeads(previous, found []ports.OutputHead, err error) []ports.OutputHead {
36+ if err != nil {
37+ return previous
38+ }
39+ return found
40+}
41+
42+// A stopped instance is no longer enabled, even if the inventory read fails.
43+func releasedHead(heads []ports.OutputHead, name string) []ports.OutputHead {
44+ heads = append([]ports.OutputHead(nil), heads...)
45+ for i := range heads {
46+ if heads[i].Info.Name == name {
47+ heads[i].Enabled = false
48+ heads[i].Current = nil
49+ }
50+ }
51+ return heads
52+}
53+
33 // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.54 // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
34 func safe(name string, fn func() error) (err error) {55 func safe(name string, fn func() error) (err error) {
35 defer func() {56 defer func() {
@@ -114,7 +135,7 @@ func (b *drmBackend) close() {
114 // one flip reader per card, and a udev watcher that rescans connectors on135 // one flip reader per card, and a udev watcher that rescans connectors on
115 // hotplug. Core learns about outputs through events. It returns when ctx136 // hotplug. Core learns about outputs through events. It returns when ctx
116 // ends, after every output is closed.137 // ends, after every output is closed.
117-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {138+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
118 ctx, cancel := context.WithCancel(ctx)139 ctx, cancel := context.WithCancel(ctx)
119 defer cancel()140 defer cancel()
120 var readers sync.WaitGroup141 var readers sync.WaitGroup
@@ -145,20 +166,71 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
145 // stopping holds outputs asked to stop: true when they restart with a166 // stopping holds outputs asked to stop: true when they restart with a
146 // new mode (core keeps the screen), false when they are gone.167 // new mode (core keeps the screen), false when they are gone.
147 stopping := map[string]bool{}168 stopping := map[string]bool{}
169+ currentConfig := initial
170+ var currentHeads ports.OutputHeads
171+ lastHeads := map[*drmCard][]ports.OutputHead{}
172+ progress := &applyProgress{completed: map[<-chan error]bool{}}
173+ readySources := map[string]<-chan error{}
174+ stopReady := map[string]chan struct{}{}
175+ type readyResult struct {
176+ name string
177+ source <-chan error
178+ err error
179+ }
180+ ready := make(chan readyResult)
181+ deadline := make(chan uint64, 1)
182+ var timer *time.Timer
183+ var op uint64
184+ complete := func(d applyDecision) {
185+ if !d.reply {
186+ return
187+ }
188+ if timer != nil {
189+ timer.Stop()
190+ timer = nil
191+ }
192+ select {
193+ case applied <- d.err:
194+ case <-ctx.Done():
195+ }
196+ }
197+ publishInventory := func() {
198+ var inventory ports.OutputHeads
199+ for _, card := range b.cards {
200+ found, err := card.ConnectedHeads()
201+ if err != nil {
202+ log.Warn().Err(err).Str("card", card.Path()).Msg("inventory outputs")
203+ }
204+ lastHeads[card] = retainedHeads(lastHeads[card], found, err)
205+ inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206+ }
207+ currentHeads = inventory
208+ select {
209+ case heads <- inventory:
210+ case <-ctx.Done():
211+ }
212+ select {
213+ case report <- inventory:
214+ case <-ctx.Done():
215+ }
216+ }
148 scan := func() {217 scan := func() {
218+ var scanErrors []error
149 for _, c := range b.cards {219 for _, c := range b.cards {
150- w := want()220+ w := want(currentConfig)
151 w.Device = c.Device()221 w.Device = c.Device()
152 c.SetWant(w)222 c.SetWant(w)
153 added, removed, replaced, err := c.Scan()223 added, removed, replaced, err := c.Scan()
154 if err != nil {224 if err != nil {
155- log.Warn().Err(err).Msg("scan connectors")225+ log.Warn().Err(err).Str("card", c.Path()).Msg("scan connectors")
226+ scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
156 continue227 continue
157 }228 }
158 stop := func(name string, restart bool) {229 stop := func(name string, restart bool) {
159 if r := set.outs[name]; r != nil && cards[name] == c {230 if r := set.outs[name]; r != nil && cards[name] == c {
160 if _, ok := stopping[name]; !ok {231 if _, ok := stopping[name]; !ok {
161 stopping[name] = restart232 stopping[name] = restart
233+ progress.stopping(name)
162 r.stop()234 r.stop()
163 }235 }
164 }236 }
@@ -181,6 +253,25 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
181 continue253 continue
182 }254 }
183 cards[name] = c255 cards[name] = c
256+ readySources[name] = o.Ready()
257+ if progress.active && !want(currentConfig).Disabled[name] {
258+ progress.required[name] = true
259+ }
260+ source := readySources[name]
261+ stopped := make(chan struct{})
262+ stopReady[name] = stopped
263+ go func() {
264+ select {
265+ case err := <-source:
266+ select {
267+ case ready <- readyResult{name, source, err}:
268+ case <-ctx.Done():
269+ }
270+ case <-stopped:
271+ case <-ctx.Done():
272+ }
273+ }()
274+ progress.started(name, source)
184 if cur := o.Cursor(); cur != nil {275 if cur := o.Cursor(); cur != nil {
185 curs.set(name, cur)276 curs.set(name, cur)
186 }277 }
@@ -195,6 +286,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
195 send(ports.OutputAdded{Info: o.Info()})286 send(ports.OutputAdded{Info: o.Info()})
196 }287 }
197 }288 }
289+ publishInventory()
290+ complete(progress.scanError(errors.Join(scanErrors...)))
198 }291 }
199 scan()292 scan()
200 if len(set.outs) == 0 {293 if len(set.outs) == 0 {
@@ -210,6 +303,53 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
210 case <-hotplug:303 case <-hotplug:
211 log.Info().Msg("hotplug")304 log.Info().Msg("hotplug")
212 scan()305 scan()
306+ if progress.active {
307+ running := map[string]<-chan error{}
308+ for name := range set.outs {
309+ running[name] = readySources[name]
310+ }
311+ complete(progress.scanned(running))
312+ }
313+ case currentConfig = <-configs:
314+ op++
315+ if timer != nil {
316+ timer.Stop()
317+ timer = nil
318+ }
319+ required := map[string]bool{}
320+ // Desired enabled heads, not the inventory's current mode, require a first modeset.
321+ w := want(currentConfig)
322+ for _, h := range currentHeads.Heads {
323+ if !w.Disabled[h.Info.Name] {
324+ required[h.Info.Name] = true
325+ }
326+ }
327+ for name := range set.outs {
328+ if !w.Disabled[name] {
329+ required[name] = true
330+ }
331+ }
332+ progress.start(op, required, stopping)
333+ scan()
334+ running := map[string]<-chan error{}
335+ for name := range set.outs {
336+ running[name] = readySources[name]
337+ }
338+ d := progress.scanned(running)
339+ if progress.active {
340+ id := op
341+ timer = time.AfterFunc(5*time.Second, func() {
342+ select {
343+ case deadline <- id:
344+ case <-ctx.Done():
345+ }
346+ })
347+ }
348+ complete(d)
349+ case result := <-ready:
350+ complete(progress.readyEvent(result.name, result.source, result.err))
351+ case id := <-deadline:
352+ complete(progress.timeout(id))
213 case s := <-scenes:353 case s := <-scenes:
214 set.scenes(s)354 set.scenes(s)
215 case c := <-contents:355 case c := <-contents:
@@ -226,8 +366,17 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
226 restart := stopping[name]366 restart := stopping[name]
227 delete(stopping, name)367 delete(stopping, name)
228 curs.set(name, nil)368 curs.set(name, nil)
229- cards[name].Release(name)369+ card := cards[name]
370+ card.Release(name)
371+ lastHeads[card] = releasedHead(lastHeads[card], name)
230 delete(cards, name)372 delete(cards, name)
373+ source := readySources[name]
374+ delete(readySources, name)
375+ if stopped := stopReady[name]; stopped != nil {
376+ close(stopped)
377+ delete(stopReady, name)
378+ }
379+ stopDecision := progress.stopped(name, source, restart, err)
231 if err != nil {380 if err != nil {
232 // A broken output (e.g. its renderer) must not take the381 // A broken output (e.g. its renderer) must not take the
233 // session down. It stays off until the next hotplug, so a382 // session down. It stays off until the next hotplug, so a
@@ -235,15 +384,32 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
235 log.Error().Err(err).Str("connector", name).Msg("output stopped")384 log.Error().Err(err).Str("connector", name).Msg("output stopped")
236 restart = false385 restart = false
237 }386 }
387+ // Release changes the enabled state in ConnectedHeads. Do not
388+ // restart a failed or disabled output just to refresh inventory.
389+ if restart {
390+ scan()
391+ } else {
392+ publishInventory()
393+ }
394+ complete(stopDecision)
238 if restart {395 if restart {
239 // Same connector, new mode: core keeps its screen and396 // Same connector, new mode: core keeps its screen and
240 // workspaces; OutputAdded updates the size.397 // workspaces; OutputAdded updates the size.
241- scan()
242 if set.outs[name] != nil {398 if set.outs[name] != nil {
399+ running := map[string]<-chan error{}
400+ for n := range set.outs {
401+ running[n] = readySources[n]
402+ }
403+ complete(progress.scanned(running))
243 continue404 continue
244 }405 }
245 }406 }
246 send(ports.OutputRemoved{Name: name})407 send(ports.OutputRemoved{Name: name})
408+ running := map[string]<-chan error{}
409+ for n := range set.outs {
410+ running[n] = readySources[n]
411+ }
412+ complete(progress.scanned(running))
247 }413 }
248 }414 }
249 }415 }
A internal/app/drm_inventory_test.go +37 -0
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestRetainedHeadsOnCardReadError(t *testing.T) {
11+ first := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true}}
12+ other := []ports.OutputHead{{Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true}}
13+ cached := retainedHeads(nil, first, nil)
14+ cached = retainedHeads(cached, nil, errors.New("resources unavailable"))
15+ combined := append(append([]ports.OutputHead{}, cached...), other...)
16+ if len(combined) != 2 || combined[0].Info.Name != "DP-1" || combined[1].Info.Name != "HDMI-1" {
17+ t.Fatalf("failed card lost its inventory: %+v", combined)
18+ }
19+ if got := retainedHeads(cached, nil, nil); len(got) != 0 {
20+ t.Fatalf("successful empty inventory should clear cache: %+v", got)
21+ }
22+}
23+
24+func TestReleasedHeadOnInventoryError(t *testing.T) {
25+ mode := ports.OutputMode{Width: 1920, Height: 1080}
26+ previous := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}, {Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true, Current: &mode}}
27+ // Both a disabled stop and an unsuccessful restart release the old instance.
28+ for _, restart := range []bool{false, true} {
29+ t.Run(map[bool]string{false: "disabled", true: "restart"}[restart], func(t *testing.T) {
30+ heads := releasedHead(append([]ports.OutputHead(nil), previous...), "DP-1")
31+ heads = retainedHeads(heads, nil, errors.New("inventory unavailable"))
32+ if heads[0].Enabled || heads[0].Current != nil || !heads[1].Enabled {
33+ t.Fatalf("released connector inventory: %+v", heads)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestRetainedHeadsOnCardReadError(t *testing.T) {
11+ first := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true}}
12+ other := []ports.OutputHead{{Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true}}
13+ cached := retainedHeads(nil, first, nil)
14+ cached = retainedHeads(cached, nil, errors.New("resources unavailable"))
15+ combined := append(append([]ports.OutputHead{}, cached...), other...)
16+ if len(combined) != 2 || combined[0].Info.Name != "DP-1" || combined[1].Info.Name != "HDMI-1" {
17+ t.Fatalf("failed card lost its inventory: %+v", combined)
18+ }
19+ if got := retainedHeads(cached, nil, nil); len(got) != 0 {
20+ t.Fatalf("successful empty inventory should clear cache: %+v", got)
21+ }
22+}
23+
24+func TestReleasedHeadOnInventoryError(t *testing.T) {
25+ mode := ports.OutputMode{Width: 1920, Height: 1080}
26+ previous := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}, {Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true, Current: &mode}}
27+ // Both a disabled stop and an unsuccessful restart release the old instance.
28+ for _, restart := range []bool{false, true} {
29+ t.Run(map[bool]string{false: "disabled", true: "restart"}[restart], func(t *testing.T) {
30+ heads := releasedHead(append([]ports.OutputHead(nil), previous...), "DP-1")
31+ heads = retainedHeads(heads, nil, errors.New("inventory unavailable"))
32+ if heads[0].Enabled || heads[0].Current != nil || !heads[1].Enabled {
33+ t.Fatalf("released connector inventory: %+v", heads)
34+ }
35+ })
36+ }
37+}
M internal/app/headless.go +14 -1
@@ -13,10 +13,13 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
1717 set := newOutputSet(ctx, captured)
18+ inventory := ports.OutputHeads{}
1819 for i, size := range sizes {
1920 name := fmt.Sprintf("HEADLESS-%d", i+1)
21+ mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}
22+ inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})
2023 dir := shots
2124 if dir != "" && len(sizes) > 1 {
2225 dir = filepath.Join(shots, name)
@@ -37,6 +40,16 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
3740 return set.wait()
3841 }
3942 }
43+ select {
44+ case heads <- inventory:
45+ case <-ctx.Done():
46+ return set.wait()
47+ }
48+ select {
49+ case report <- inventory:
50+ case <-ctx.Done():
51+ return set.wait()
52+ }
4053 // The pointer starts centred on the first output, like libinput's.
4154 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
4255 for {
@@ -13,10 +13,13 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17 set := newOutputSet(ctx, captured)17 set := newOutputSet(ctx, captured)
18+ inventory := ports.OutputHeads{}
18 for i, size := range sizes {19 for i, size := range sizes {
19 name := fmt.Sprintf("HEADLESS-%d", i+1)20 name := fmt.Sprintf("HEADLESS-%d", i+1)
21+ mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}
22+ inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})
20 dir := shots23 dir := shots
21 if dir != "" && len(sizes) > 1 {24 if dir != "" && len(sizes) > 1 {
22 dir = filepath.Join(shots, name)25 dir = filepath.Join(shots, name)
@@ -37,6 +40,16 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
37 return set.wait()40 return set.wait()
38 }41 }
39 }42 }
43+ select {
44+ case heads <- inventory:
45+ case <-ctx.Done():
46+ return set.wait()
47+ }
48+ select {
49+ case report <- inventory:
50+ case <-ctx.Done():
51+ return set.wait()
52+ }
40 // The pointer starts centred on the first output, like libinput's.53 // The pointer starts centred on the first output, like libinput's.
41 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)54 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
42 for {55 for {
A internal/app/outputmanagement.go +241 -0
@@ -0,0 +1,241 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "image"
7+ "math"
8+ "slices"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// outputOverrides owns runtime-only output settings. It is used only by the
15+// app's config/apply relay goroutine; the file configuration is never mutated.
16+type outputOverrides struct {
17+ file ports.Config
18+ overrides map[string]ports.OutputConfig
19+ heads ports.OutputHeads
20+ headless bool
21+}
22+
23+func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
24+ return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
25+}
26+func (o *outputOverrides) effective() ports.Config {
27+ cfg := o.file
28+ cfg.Outputs = slices.Clone(o.file.Outputs)
29+ // Preserve file order and backend connection order; map iteration would
30+ // make automatic placement change unpredictably.
31+ for _, head := range o.heads.Heads {
32+ entry, ok := o.overrides[head.Info.Name]
33+ if !ok {
34+ continue
35+ }
36+ index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
37+ if index < 0 {
38+ cfg.Outputs = append(cfg.Outputs, entry)
39+ } else {
40+ cfg.Outputs[index] = entry
41+ }
42+ }
43+ return cfg
44+}
45+func (o *outputOverrides) reload(file ports.Config) ports.Config {
46+ o.file = file
47+ clear(o.overrides)
48+ return o.effective()
49+}
50+func (o *outputOverrides) validate(req ports.OutputApply) error {
51+ if len(req.Heads) != len(o.heads.Heads) {
52+ return fmt.Errorf("incomplete head configuration")
53+ }
54+ seen := map[string]bool{}
55+ for _, change := range req.Heads {
56+ if seen[change.Name] {
57+ return fmt.Errorf("duplicate head %s", change.Name)
58+ }
59+ seen[change.Name] = true
60+ index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
61+ if index < 0 {
62+ return fmt.Errorf("unknown head %s", change.Name)
63+ }
64+ current := o.heads.Heads[index]
65+ if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode {
66+ return fmt.Errorf("unsupported output setting on %s", change.Name)
67+ }
68+ if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
69+ return fmt.Errorf("invalid scale on %s", change.Name)
70+ }
71+ if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
72+ return fmt.Errorf("headless mode and enabled state are fixed")
73+ }
74+ if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
75+ return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
76+ }) {
77+ return fmt.Errorf("unknown mode on %s", change.Name)
78+ }
79+ }
80+ return nil
81+}
82+func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
83+ if err := o.validate(req); err != nil {
84+ return ports.Config{}, err
85+ }
86+ if req.Test {
87+ return o.effective(), nil
88+ }
89+ for _, h := range req.Heads {
90+ entry, ok := o.overrides[h.Name]
91+ if !ok {
92+ index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
93+ if index >= 0 {
94+ entry = o.file.Outputs[index]
95+ } else {
96+ entry.Name = h.Name
97+ }
98+ }
99+ entry.Off = !h.Enabled
100+ if h.Scale > 0 {
101+ entry.Scale = h.Scale
102+ }
103+ if h.Pos != nil {
104+ entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
105+ }
106+ if h.Mode != nil {
107+ entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
108+ }
109+ o.overrides[h.Name] = entry
110+ }
111+ return o.effective(), nil
112+}
113+
114+// relayOutputSettings serializes reloads, inventory, and protocol requests.
115+// The app owns the only writer to overrides; core receives effective settings.
116+func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) {
117+ type operation struct {
118+ cfg ports.Config
119+ req *ports.OutputApply
120+ previous map[string]ports.OutputConfig
121+ rollback bool
122+ failure error
123+ }
124+ var queue []operation
125+ var active *operation
126+ var sending chan<- ports.Config
127+ var next ports.Config
128+ var configSend chan<- ports.ConfigChanged
129+ var pendingConfig ports.ConfigChanged
130+ var pendingReplies []ports.OutputApplied
131+ publish := func(cfg ports.Config) {
132+ pendingConfig = ports.ConfigChanged{Config: cfg}
133+ configSend = configs
134+ }
135+ reply := func(id uint64, err error) {
136+ if err != nil {
137+ log.Warn().Err(err).Msg("output configuration failed")
138+ }
139+ pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err})
140+ }
141+ enqueue := func(op operation) {
142+ if state.headless {
143+ return
144+ }
145+ queue = append(queue, op)
146+ }
147+ for {
148+ if active == nil && len(queue) > 0 {
149+ op := queue[0]
150+ active = &op
151+ queue = queue[1:]
152+ next = active.cfg
153+ sending = backend
154+ }
155+ var replySend chan<- ports.OutputApplied
156+ var nextReply ports.OutputApplied
157+ if len(pendingReplies) > 0 {
158+ replySend = replies
159+ nextReply = pendingReplies[0]
160+ }
161+ select {
162+ case <-ctx.Done():
163+ return
164+ case configSend <- pendingConfig:
165+ configSend = nil
166+ case replySend <- nextReply:
167+ pendingReplies[0] = ports.OutputApplied{}
168+ pendingReplies = pendingReplies[1:]
169+ case sending <- next:
170+ sending = nil
171+ case ev := <-reloads:
172+ cfg := state.reload(ev.Config)
173+ publish(cfg)
174+ // The reload supersedes runtime overrides. Old backend completion cannot
175+ // roll back the new file configuration.
176+ if active != nil && active.req != nil {
177+ reply(active.req.ID, fmt.Errorf("output apply superseded by reload"))
178+ active.req = nil
179+ }
180+ for i := range queue {
181+ if queue[i].req != nil {
182+ reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload"))
183+ }
184+ }
185+ queue = nil // Only the latest reload needs backend work.
186+ if active != nil && sending != nil {
187+ // The backend has not accepted the active config yet.
188+ active.cfg = cfg
189+ next = cfg
190+ } else {
191+ enqueue(operation{cfg: cfg})
192+ }
193+ case heads := <-inventory:
194+ state.heads = heads
195+ case req := <-apply:
196+ if active != nil || len(queue) > 0 {
197+ reply(req.ID, fmt.Errorf("output apply already pending"))
198+ continue
199+ }
200+ previous := make(map[string]ports.OutputConfig, len(state.overrides))
201+ for name, entry := range state.overrides {
202+ previous[name] = entry
203+ }
204+ cfg, err := state.apply(req)
205+ if err != nil || req.Test {
206+ reply(req.ID, err)
207+ continue
208+ }
209+ publish(cfg)
210+ if state.headless {
211+ reply(req.ID, nil)
212+ } else {
213+ enqueue(operation{cfg: cfg, req: &req, previous: previous})
214+ }
215+ case err := <-backendDone:
216+ if active == nil || sending != nil {
217+ continue
218+ }
219+ op := active
220+ active = nil
221+ if op.req == nil {
222+ continue
223+ }
224+ if op.rollback {
225+ reply(op.req.ID, op.failure)
226+ continue
227+ }
228+ if err != nil {
229+ state.overrides = op.previous
230+ cfg := state.effective()
231+ publish(cfg)
232+ enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err})
233+ continue
234+ }
235+ for _, h := range op.req.Heads {
236+ log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
237+ }
238+ reply(op.req.ID, nil)
239+ }
240+ }
241+}
@@ -0,0 +1,241 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "image"
7+ "math"
8+ "slices"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// outputOverrides owns runtime-only output settings. It is used only by the
15+// app's config/apply relay goroutine; the file configuration is never mutated.
16+type outputOverrides struct {
17+ file ports.Config
18+ overrides map[string]ports.OutputConfig
19+ heads ports.OutputHeads
20+ headless bool
21+}
22+
23+func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
24+ return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
25+}
26+func (o *outputOverrides) effective() ports.Config {
27+ cfg := o.file
28+ cfg.Outputs = slices.Clone(o.file.Outputs)
29+ // Preserve file order and backend connection order; map iteration would
30+ // make automatic placement change unpredictably.
31+ for _, head := range o.heads.Heads {
32+ entry, ok := o.overrides[head.Info.Name]
33+ if !ok {
34+ continue
35+ }
36+ index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
37+ if index < 0 {
38+ cfg.Outputs = append(cfg.Outputs, entry)
39+ } else {
40+ cfg.Outputs[index] = entry
41+ }
42+ }
43+ return cfg
44+}
45+func (o *outputOverrides) reload(file ports.Config) ports.Config {
46+ o.file = file
47+ clear(o.overrides)
48+ return o.effective()
49+}
50+func (o *outputOverrides) validate(req ports.OutputApply) error {
51+ if len(req.Heads) != len(o.heads.Heads) {
52+ return fmt.Errorf("incomplete head configuration")
53+ }
54+ seen := map[string]bool{}
55+ for _, change := range req.Heads {
56+ if seen[change.Name] {
57+ return fmt.Errorf("duplicate head %s", change.Name)
58+ }
59+ seen[change.Name] = true
60+ index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
61+ if index < 0 {
62+ return fmt.Errorf("unknown head %s", change.Name)
63+ }
64+ current := o.heads.Heads[index]
65+ if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode {
66+ return fmt.Errorf("unsupported output setting on %s", change.Name)
67+ }
68+ if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
69+ return fmt.Errorf("invalid scale on %s", change.Name)
70+ }
71+ if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
72+ return fmt.Errorf("headless mode and enabled state are fixed")
73+ }
74+ if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
75+ return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
76+ }) {
77+ return fmt.Errorf("unknown mode on %s", change.Name)
78+ }
79+ }
80+ return nil
81+}
82+func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
83+ if err := o.validate(req); err != nil {
84+ return ports.Config{}, err
85+ }
86+ if req.Test {
87+ return o.effective(), nil
88+ }
89+ for _, h := range req.Heads {
90+ entry, ok := o.overrides[h.Name]
91+ if !ok {
92+ index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
93+ if index >= 0 {
94+ entry = o.file.Outputs[index]
95+ } else {
96+ entry.Name = h.Name
97+ }
98+ }
99+ entry.Off = !h.Enabled
100+ if h.Scale > 0 {
101+ entry.Scale = h.Scale
102+ }
103+ if h.Pos != nil {
104+ entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
105+ }
106+ if h.Mode != nil {
107+ entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
108+ }
109+ o.overrides[h.Name] = entry
110+ }
111+ return o.effective(), nil
112+}
113+
114+// relayOutputSettings serializes reloads, inventory, and protocol requests.
115+// The app owns the only writer to overrides; core receives effective settings.
116+func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) {
117+ type operation struct {
118+ cfg ports.Config
119+ req *ports.OutputApply
120+ previous map[string]ports.OutputConfig
121+ rollback bool
122+ failure error
123+ }
124+ var queue []operation
125+ var active *operation
126+ var sending chan<- ports.Config
127+ var next ports.Config
128+ var configSend chan<- ports.ConfigChanged
129+ var pendingConfig ports.ConfigChanged
130+ var pendingReplies []ports.OutputApplied
131+ publish := func(cfg ports.Config) {
132+ pendingConfig = ports.ConfigChanged{Config: cfg}
133+ configSend = configs
134+ }
135+ reply := func(id uint64, err error) {
136+ if err != nil {
137+ log.Warn().Err(err).Msg("output configuration failed")
138+ }
139+ pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err})
140+ }
141+ enqueue := func(op operation) {
142+ if state.headless {
143+ return
144+ }
145+ queue = append(queue, op)
146+ }
147+ for {
148+ if active == nil && len(queue) > 0 {
149+ op := queue[0]
150+ active = &op
151+ queue = queue[1:]
152+ next = active.cfg
153+ sending = backend
154+ }
155+ var replySend chan<- ports.OutputApplied
156+ var nextReply ports.OutputApplied
157+ if len(pendingReplies) > 0 {
158+ replySend = replies
159+ nextReply = pendingReplies[0]
160+ }
161+ select {
162+ case <-ctx.Done():
163+ return
164+ case configSend <- pendingConfig:
165+ configSend = nil
166+ case replySend <- nextReply:
167+ pendingReplies[0] = ports.OutputApplied{}
168+ pendingReplies = pendingReplies[1:]
169+ case sending <- next:
170+ sending = nil
171+ case ev := <-reloads:
172+ cfg := state.reload(ev.Config)
173+ publish(cfg)
174+ // The reload supersedes runtime overrides. Old backend completion cannot
175+ // roll back the new file configuration.
176+ if active != nil && active.req != nil {
177+ reply(active.req.ID, fmt.Errorf("output apply superseded by reload"))
178+ active.req = nil
179+ }
180+ for i := range queue {
181+ if queue[i].req != nil {
182+ reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload"))
183+ }
184+ }
185+ queue = nil // Only the latest reload needs backend work.
186+ if active != nil && sending != nil {
187+ // The backend has not accepted the active config yet.
188+ active.cfg = cfg
189+ next = cfg
190+ } else {
191+ enqueue(operation{cfg: cfg})
192+ }
193+ case heads := <-inventory:
194+ state.heads = heads
195+ case req := <-apply:
196+ if active != nil || len(queue) > 0 {
197+ reply(req.ID, fmt.Errorf("output apply already pending"))
198+ continue
199+ }
200+ previous := make(map[string]ports.OutputConfig, len(state.overrides))
201+ for name, entry := range state.overrides {
202+ previous[name] = entry
203+ }
204+ cfg, err := state.apply(req)
205+ if err != nil || req.Test {
206+ reply(req.ID, err)
207+ continue
208+ }
209+ publish(cfg)
210+ if state.headless {
211+ reply(req.ID, nil)
212+ } else {
213+ enqueue(operation{cfg: cfg, req: &req, previous: previous})
214+ }
215+ case err := <-backendDone:
216+ if active == nil || sending != nil {
217+ continue
218+ }
219+ op := active
220+ active = nil
221+ if op.req == nil {
222+ continue
223+ }
224+ if op.rollback {
225+ reply(op.req.ID, op.failure)
226+ continue
227+ }
228+ if err != nil {
229+ state.overrides = op.previous
230+ cfg := state.effective()
231+ publish(cfg)
232+ enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err})
233+ continue
234+ }
235+ for _, h := range op.req.Heads {
236+ log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
237+ }
238+ reply(op.req.ID, nil)
239+ }
240+ }
241+}
A internal/app/outputmanagement_test.go +212 -0
@@ -0,0 +1,212 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func TestOutputSettingsRelay(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ failure error
18+ }{
19+ {"ready", nil},
20+ {"ready error rollback", errors.New("modeset failed")},
21+ {"timeout rollback", errTimeout},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) {
24+ ctx, cancel := context.WithCancel(context.Background())
25+ defer cancel()
26+ state := newOutputOverrides(ports.Config{}, false)
27+ reloads := make(chan ports.ConfigChanged)
28+ inventory := make(chan ports.OutputHeads)
29+ applies := make(chan ports.OutputApply)
30+ configs := make(chan ports.ConfigChanged, 4)
31+ backend := make(chan ports.Config, 4)
32+ backendDone := make(chan error)
33+ replies := make(chan ports.OutputApplied)
34+ done := make(chan struct{})
35+ go func() {
36+ defer close(done)
37+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
38+ }()
39+ defer func() { cancel(); <-done }()
40+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
41+ inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
42+ applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
43+ next := <-configs
44+ if next.Config.Outputs[0].Pos.X != 100 {
45+ t.Fatalf("next: %+v", next)
46+ }
47+ <-backend
48+ select {
49+ case <-replies:
50+ t.Fatal("replied before backend ready")
51+ default:
52+ }
53+ if tc.failure != nil {
54+ backendDone <- tc.failure
55+ rollback := <-configs
56+ if len(rollback.Config.Outputs) != 0 {
57+ t.Fatalf("rollback: %+v", rollback)
58+ }
59+ <-backend
60+ backendDone <- nil
61+ } else {
62+ backendDone <- nil
63+ }
64+ select {
65+ case r := <-replies:
66+ if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) {
67+ t.Fatalf("result: %+v, want %v", r, tc.failure)
68+ }
69+ case <-time.After(2 * time.Second):
70+ t.Fatal("missing reply")
71+ }
72+ if tc.failure != nil && len(state.effective().Outputs) != 0 {
73+ t.Fatal("override survived failure")
74+ }
75+ reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}}
76+ reloaded := <-configs
77+ if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 {
78+ t.Fatalf("reload: %+v", reloaded)
79+ }
80+ <-backend
81+ backendDone <- nil
82+ })
83+ }
84+}
85+
86+func TestOutputSettingsRelayBlockedConsumers(t *testing.T) {
87+ ctx, cancel := context.WithCancel(context.Background())
88+ state := newOutputOverrides(ports.Config{}, false)
89+ reloads := make(chan ports.ConfigChanged)
90+ inventory := make(chan ports.OutputHeads)
91+ applies := make(chan ports.OutputApply)
92+ configs := make(chan ports.ConfigChanged)
93+ backend := make(chan ports.Config)
94+ backendDone := make(chan error)
95+ replies := make(chan ports.OutputApplied)
96+ done := make(chan struct{})
97+ go func() {
98+ defer close(done)
99+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
100+ }()
101+ defer func() { cancel(); <-done }()
102+
103+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
104+ select {
105+ case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}:
106+ case <-time.After(time.Second):
107+ t.Fatal("inventory blocked")
108+ }
109+ select {
110+ case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}:
111+ case <-time.After(time.Second):
112+ t.Fatal("apply blocked")
113+ }
114+ // Neither config consumer nor backend nor Wayland is reading. Reload must
115+ // still supersede the apply and subsequent requests must still be answered.
116+ select {
117+ case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}:
118+ case <-time.After(time.Second):
119+ t.Fatal("reload blocked by unread config or backend")
120+ }
121+ select {
122+ case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}:
123+ case <-time.After(time.Second):
124+ t.Fatal("second reload blocked by unread config or backend")
125+ }
126+ for _, id := range []uint64{2, 3} {
127+ select {
128+ case applies <- ports.OutputApply{ID: id}:
129+ case <-time.After(time.Second):
130+ t.Fatalf("request %d blocked by unread reply", id)
131+ }
132+ }
133+ for _, id := range []uint64{1, 2, 3} {
134+ select {
135+ case result := <-replies:
136+ if result.ID != id || result.Err == nil {
137+ t.Fatalf("reply %d: %+v", id, result)
138+ }
139+ case <-time.After(time.Second):
140+ t.Fatalf("missing reply %d", id)
141+ }
142+ }
143+ select {
144+ case cfg := <-configs:
145+ if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil {
146+ t.Fatalf("latest config: %+v", cfg)
147+ }
148+ case <-time.After(time.Second):
149+ t.Fatal("missing latest config")
150+ }
151+ select {
152+ case cfg := <-backend:
153+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
154+ t.Fatalf("latest backend config: %+v", cfg)
155+ }
156+ case <-time.After(time.Second):
157+ t.Fatal("missing latest backend config")
158+ }
159+}
160+
161+func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
162+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
163+ state := newOutputOverrides(ports.Config{}, true)
164+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
165+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
166+ t.Fatal("headless disable accepted")
167+ }
168+ state.headless = false
169+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
170+ if err != nil || !cfg.Outputs[0].Off {
171+ t.Fatalf("disable: %+v, %v", cfg, err)
172+ }
173+}
174+
175+func TestReloadDuringPendingApply(t *testing.T) {
176+ ctx, cancel := context.WithCancel(context.Background())
177+ defer cancel()
178+ state := newOutputOverrides(ports.Config{}, false)
179+ reloads := make(chan ports.ConfigChanged)
180+ inventory := make(chan ports.OutputHeads)
181+ applies := make(chan ports.OutputApply)
182+ configs := make(chan ports.ConfigChanged, 4)
183+ backend := make(chan ports.Config, 4)
184+ backendDone := make(chan error)
185+ replies := make(chan ports.OutputApplied, 2)
186+ done := make(chan struct{})
187+ go func() {
188+ defer close(done)
189+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
190+ }()
191+ defer func() { cancel(); <-done }()
192+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
193+ inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}
194+ applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
195+ <-configs
196+ <-backend
197+ reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}
198+ select {
199+ case cfg := <-configs:
200+ if cfg.Config.Outputs[0].Pos != nil {
201+ t.Fatal("override survived reload")
202+ }
203+ case <-time.After(time.Second):
204+ t.Fatal("reload blocked by backend")
205+ }
206+ if r := <-replies; r.ID != 1 || r.Err == nil {
207+ t.Fatalf("superseded reply: %+v", r)
208+ }
209+ backendDone <- errors.New("old apply failed")
210+ <-backend
211+ backendDone <- nil
212+}
@@ -0,0 +1,212 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func TestOutputSettingsRelay(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ failure error
18+ }{
19+ {"ready", nil},
20+ {"ready error rollback", errors.New("modeset failed")},
21+ {"timeout rollback", errTimeout},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) {
24+ ctx, cancel := context.WithCancel(context.Background())
25+ defer cancel()
26+ state := newOutputOverrides(ports.Config{}, false)
27+ reloads := make(chan ports.ConfigChanged)
28+ inventory := make(chan ports.OutputHeads)
29+ applies := make(chan ports.OutputApply)
30+ configs := make(chan ports.ConfigChanged, 4)
31+ backend := make(chan ports.Config, 4)
32+ backendDone := make(chan error)
33+ replies := make(chan ports.OutputApplied)
34+ done := make(chan struct{})
35+ go func() {
36+ defer close(done)
37+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
38+ }()
39+ defer func() { cancel(); <-done }()
40+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
41+ inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
42+ applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
43+ next := <-configs
44+ if next.Config.Outputs[0].Pos.X != 100 {
45+ t.Fatalf("next: %+v", next)
46+ }
47+ <-backend
48+ select {
49+ case <-replies:
50+ t.Fatal("replied before backend ready")
51+ default:
52+ }
53+ if tc.failure != nil {
54+ backendDone <- tc.failure
55+ rollback := <-configs
56+ if len(rollback.Config.Outputs) != 0 {
57+ t.Fatalf("rollback: %+v", rollback)
58+ }
59+ <-backend
60+ backendDone <- nil
61+ } else {
62+ backendDone <- nil
63+ }
64+ select {
65+ case r := <-replies:
66+ if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) {
67+ t.Fatalf("result: %+v, want %v", r, tc.failure)
68+ }
69+ case <-time.After(2 * time.Second):
70+ t.Fatal("missing reply")
71+ }
72+ if tc.failure != nil && len(state.effective().Outputs) != 0 {
73+ t.Fatal("override survived failure")
74+ }
75+ reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}}
76+ reloaded := <-configs
77+ if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 {
78+ t.Fatalf("reload: %+v", reloaded)
79+ }
80+ <-backend
81+ backendDone <- nil
82+ })
83+ }
84+}
85+
86+func TestOutputSettingsRelayBlockedConsumers(t *testing.T) {
87+ ctx, cancel := context.WithCancel(context.Background())
88+ state := newOutputOverrides(ports.Config{}, false)
89+ reloads := make(chan ports.ConfigChanged)
90+ inventory := make(chan ports.OutputHeads)
91+ applies := make(chan ports.OutputApply)
92+ configs := make(chan ports.ConfigChanged)
93+ backend := make(chan ports.Config)
94+ backendDone := make(chan error)
95+ replies := make(chan ports.OutputApplied)
96+ done := make(chan struct{})
97+ go func() {
98+ defer close(done)
99+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
100+ }()
101+ defer func() { cancel(); <-done }()
102+
103+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
104+ select {
105+ case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}:
106+ case <-time.After(time.Second):
107+ t.Fatal("inventory blocked")
108+ }
109+ select {
110+ case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}:
111+ case <-time.After(time.Second):
112+ t.Fatal("apply blocked")
113+ }
114+ // Neither config consumer nor backend nor Wayland is reading. Reload must
115+ // still supersede the apply and subsequent requests must still be answered.
116+ select {
117+ case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}:
118+ case <-time.After(time.Second):
119+ t.Fatal("reload blocked by unread config or backend")
120+ }
121+ select {
122+ case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}:
123+ case <-time.After(time.Second):
124+ t.Fatal("second reload blocked by unread config or backend")
125+ }
126+ for _, id := range []uint64{2, 3} {
127+ select {
128+ case applies <- ports.OutputApply{ID: id}:
129+ case <-time.After(time.Second):
130+ t.Fatalf("request %d blocked by unread reply", id)
131+ }
132+ }
133+ for _, id := range []uint64{1, 2, 3} {
134+ select {
135+ case result := <-replies:
136+ if result.ID != id || result.Err == nil {
137+ t.Fatalf("reply %d: %+v", id, result)
138+ }
139+ case <-time.After(time.Second):
140+ t.Fatalf("missing reply %d", id)
141+ }
142+ }
143+ select {
144+ case cfg := <-configs:
145+ if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil {
146+ t.Fatalf("latest config: %+v", cfg)
147+ }
148+ case <-time.After(time.Second):
149+ t.Fatal("missing latest config")
150+ }
151+ select {
152+ case cfg := <-backend:
153+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
154+ t.Fatalf("latest backend config: %+v", cfg)
155+ }
156+ case <-time.After(time.Second):
157+ t.Fatal("missing latest backend config")
158+ }
159+}
160+
161+func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
162+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
163+ state := newOutputOverrides(ports.Config{}, true)
164+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
165+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
166+ t.Fatal("headless disable accepted")
167+ }
168+ state.headless = false
169+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
170+ if err != nil || !cfg.Outputs[0].Off {
171+ t.Fatalf("disable: %+v, %v", cfg, err)
172+ }
173+}
174+
175+func TestReloadDuringPendingApply(t *testing.T) {
176+ ctx, cancel := context.WithCancel(context.Background())
177+ defer cancel()
178+ state := newOutputOverrides(ports.Config{}, false)
179+ reloads := make(chan ports.ConfigChanged)
180+ inventory := make(chan ports.OutputHeads)
181+ applies := make(chan ports.OutputApply)
182+ configs := make(chan ports.ConfigChanged, 4)
183+ backend := make(chan ports.Config, 4)
184+ backendDone := make(chan error)
185+ replies := make(chan ports.OutputApplied, 2)
186+ done := make(chan struct{})
187+ go func() {
188+ defer close(done)
189+ relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app"))
190+ }()
191+ defer func() { cancel(); <-done }()
192+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
193+ inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}
194+ applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}
195+ <-configs
196+ <-backend
197+ reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}
198+ select {
199+ case cfg := <-configs:
200+ if cfg.Config.Outputs[0].Pos != nil {
201+ t.Fatal("override survived reload")
202+ }
203+ case <-time.After(time.Second):
204+ t.Fatal("reload blocked by backend")
205+ }
206+ if r := <-replies; r.ID != 1 || r.Err == nil {
207+ t.Fatalf("superseded reply: %+v", r)
208+ }
209+ backendDone <- errors.New("old apply failed")
210+ <-backend
211+ backendDone <- nil
212+}
A internal/app/pendingapply.go +142 -0
@@ -0,0 +1,142 @@
1+package app
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+// applyProgress belongs exclusively to the output loop. An instance is identified
9+// by its Ready channel, which is distinct for each start of a connector.
10+type applyProgress struct {
11+ id uint64
12+ active bool
13+ required map[string]bool
14+ waiting map[string]bool // stopped instances awaiting replacement or removal
15+ ready map[string]<-chan error
16+ completed map[<-chan error]bool
17+ err error
18+}
19+
20+type applyDecision struct {
21+ reply bool
22+ err error
23+ rollback bool
24+}
25+
26+func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision {
27+ p.id, p.active, p.err = id, true, nil
28+ p.required = required
29+ p.waiting = map[string]bool{}
30+ p.ready = map[string]<-chan error{}
31+ for name := range stopping {
32+ p.waiting[name] = true
33+ }
34+ return applyDecision{}
35+}
36+
37+func (p *applyProgress) stopping(name string) {
38+ if p.active {
39+ p.waiting[name] = true
40+ delete(p.ready, name)
41+ }
42+}
43+
44+func (p *applyProgress) started(name string, instance <-chan error) {
45+ if p.active && p.required[name] && !p.completed[instance] && !p.waiting[name] {
46+ p.ready[name] = instance
47+ }
48+}
49+
50+func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision {
51+ if p.completed == nil {
52+ p.completed = map[<-chan error]bool{}
53+ }
54+ if err == nil {
55+ p.completed[instance] = true
56+ }
57+ if p.active && p.ready[name] == instance {
58+ delete(p.ready, name)
59+ if err != nil {
60+ return p.fail(fmt.Errorf("%s: %w", name, err))
61+ }
62+ } else if p.active && err != nil && p.required[name] && !p.waiting[name] {
63+ return p.fail(fmt.Errorf("%s: %w", name, err))
64+ }
65+ return p.complete()
66+}
67+
68+func (p *applyProgress) stopped(name string, instance <-chan error, restart bool, err error) applyDecision {
69+ delete(p.completed, instance)
70+ if !p.active {
71+ return applyDecision{}
72+ }
73+ if p.ready[name] == instance {
74+ delete(p.ready, name)
75+ }
76+ if err != nil {
77+ return p.fail(fmt.Errorf("%s stopped: %w", name, err))
78+ }
79+ if restart && p.required[name] {
80+ p.waiting[name] = false
81+ return applyDecision{}
82+ } else {
83+ delete(p.waiting, name)
84+ if p.required[name] {
85+ return p.fail(fmt.Errorf("%s stopped before ready", name))
86+ }
87+ }
88+ return p.complete()
89+}
90+
91+func (p *applyProgress) scanned(running map[string]<-chan error) applyDecision {
92+ if !p.active {
93+ return applyDecision{}
94+ }
95+ for name := range p.required {
96+ instance := running[name]
97+ if instance == nil {
98+ return p.fail(fmt.Errorf("%s failed to start", name))
99+ }
100+ if stopping, ok := p.waiting[name]; ok {
101+ if stopping {
102+ continue
103+ }
104+ delete(p.waiting, name)
105+ }
106+ if !p.completed[instance] {
107+ p.ready[name] = instance
108+ } else {
109+ delete(p.ready, name)
110+ }
111+ }
112+ return p.complete()
113+}
114+
115+// scanError fails the active operation even if an old output was already ready.
116+func (p *applyProgress) scanError(err error) applyDecision {
117+ if !p.active || err == nil {
118+ return applyDecision{}
119+ }
120+ return p.fail(err)
121+}
122+
123+func (p *applyProgress) timeout(id uint64) applyDecision {
124+ if !p.active || id != p.id {
125+ return applyDecision{}
126+ }
127+ return p.fail(errTimeout)
128+}
129+
130+func (p *applyProgress) fail(err error) applyDecision {
131+ p.err = errors.Join(p.err, err)
132+ p.active = false
133+ return applyDecision{reply: true, err: p.err, rollback: true}
134+}
135+
136+func (p *applyProgress) complete() applyDecision {
137+ if !p.active || len(p.waiting) != 0 || len(p.ready) != 0 {
138+ return applyDecision{}
139+ }
140+ p.active = false
141+ return applyDecision{reply: true, err: p.err, rollback: p.err != nil}
142+}
@@ -0,0 +1,142 @@
1+package app
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+// applyProgress belongs exclusively to the output loop. An instance is identified
9+// by its Ready channel, which is distinct for each start of a connector.
10+type applyProgress struct {
11+ id uint64
12+ active bool
13+ required map[string]bool
14+ waiting map[string]bool // stopped instances awaiting replacement or removal
15+ ready map[string]<-chan error
16+ completed map[<-chan error]bool
17+ err error
18+}
19+
20+type applyDecision struct {
21+ reply bool
22+ err error
23+ rollback bool
24+}
25+
26+func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision {
27+ p.id, p.active, p.err = id, true, nil
28+ p.required = required
29+ p.waiting = map[string]bool{}
30+ p.ready = map[string]<-chan error{}
31+ for name := range stopping {
32+ p.waiting[name] = true
33+ }
34+ return applyDecision{}
35+}
36+
37+func (p *applyProgress) stopping(name string) {
38+ if p.active {
39+ p.waiting[name] = true
40+ delete(p.ready, name)
41+ }
42+}
43+
44+func (p *applyProgress) started(name string, instance <-chan error) {
45+ if p.active && p.required[name] && !p.completed[instance] && !p.waiting[name] {
46+ p.ready[name] = instance
47+ }
48+}
49+
50+func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision {
51+ if p.completed == nil {
52+ p.completed = map[<-chan error]bool{}
53+ }
54+ if err == nil {
55+ p.completed[instance] = true
56+ }
57+ if p.active && p.ready[name] == instance {
58+ delete(p.ready, name)
59+ if err != nil {
60+ return p.fail(fmt.Errorf("%s: %w", name, err))
61+ }
62+ } else if p.active && err != nil && p.required[name] && !p.waiting[name] {
63+ return p.fail(fmt.Errorf("%s: %w", name, err))
64+ }
65+ return p.complete()
66+}
67+
68+func (p *applyProgress) stopped(name string, instance <-chan error, restart bool, err error) applyDecision {
69+ delete(p.completed, instance)
70+ if !p.active {
71+ return applyDecision{}
72+ }
73+ if p.ready[name] == instance {
74+ delete(p.ready, name)
75+ }
76+ if err != nil {
77+ return p.fail(fmt.Errorf("%s stopped: %w", name, err))
78+ }
79+ if restart && p.required[name] {
80+ p.waiting[name] = false
81+ return applyDecision{}
82+ } else {
83+ delete(p.waiting, name)
84+ if p.required[name] {
85+ return p.fail(fmt.Errorf("%s stopped before ready", name))
86+ }
87+ }
88+ return p.complete()
89+}
90+
91+func (p *applyProgress) scanned(running map[string]<-chan error) applyDecision {
92+ if !p.active {
93+ return applyDecision{}
94+ }
95+ for name := range p.required {
96+ instance := running[name]
97+ if instance == nil {
98+ return p.fail(fmt.Errorf("%s failed to start", name))
99+ }
100+ if stopping, ok := p.waiting[name]; ok {
101+ if stopping {
102+ continue
103+ }
104+ delete(p.waiting, name)
105+ }
106+ if !p.completed[instance] {
107+ p.ready[name] = instance
108+ } else {
109+ delete(p.ready, name)
110+ }
111+ }
112+ return p.complete()
113+}
114+
115+// scanError fails the active operation even if an old output was already ready.
116+func (p *applyProgress) scanError(err error) applyDecision {
117+ if !p.active || err == nil {
118+ return applyDecision{}
119+ }
120+ return p.fail(err)
121+}
122+
123+func (p *applyProgress) timeout(id uint64) applyDecision {
124+ if !p.active || id != p.id {
125+ return applyDecision{}
126+ }
127+ return p.fail(errTimeout)
128+}
129+
130+func (p *applyProgress) fail(err error) applyDecision {
131+ p.err = errors.Join(p.err, err)
132+ p.active = false
133+ return applyDecision{reply: true, err: p.err, rollback: true}
134+}
135+
136+func (p *applyProgress) complete() applyDecision {
137+ if !p.active || len(p.waiting) != 0 || len(p.ready) != 0 {
138+ return applyDecision{}
139+ }
140+ p.active = false
141+ return applyDecision{reply: true, err: p.err, rollback: p.err != nil}
142+}
A internal/app/pendingapply_test.go +84 -0
@@ -0,0 +1,84 @@
1+package app
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestApplyProgress(t *testing.T) {
9+ boom := errors.New("modeset")
10+ for _, tc := range []struct {
11+ name string
12+ run func(*applyProgress, <-chan error, <-chan error) applyDecision
13+ failure bool
14+ }{
15+ {"success", func(p *applyProgress, a, _ <-chan error) applyDecision {
16+ p.start(1, map[string]bool{"A": true}, nil)
17+ p.scanned(map[string]<-chan error{"A": a})
18+ return p.readyEvent("A", a, nil)
19+ }, false},
20+ {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision {
21+ p.completed = map[<-chan error]bool{a: true}
22+ p.start(1, map[string]bool{"A": true}, nil)
23+ d := p.scanError(boom)
24+ if later := p.scanned(map[string]<-chan error{"A": a}); later.reply {
25+ t.Fatalf("old ready output completed failed apply: %+v", later)
26+ }
27+ return d
28+ }, true},
29+ {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision {
30+ p.start(1, map[string]bool{"A": true}, nil)
31+ p.scanned(map[string]<-chan error{"A": a})
32+ return p.readyEvent("A", a, boom)
33+ }, true},
34+ {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
35+ p.start(1, map[string]bool{"A": true}, nil)
36+ p.scanned(map[string]<-chan error{"A": a})
37+ return p.timeout(1)
38+ }, true},
39+ {"restart replacement missing", func(p *applyProgress, a, _ <-chan error) applyDecision {
40+ p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true})
41+ p.stopped("A", a, true, nil)
42+ return p.scanned(nil)
43+ }, true},
44+ {"unchanged not ready", func(p *applyProgress, a, _ <-chan error) applyDecision {
45+ p.start(1, map[string]bool{"A": true}, nil)
46+ return p.scanned(map[string]<-chan error{"A": a})
47+ }, false},
48+ {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision {
49+ p.start(1, map[string]bool{"A": true, "B": true}, nil)
50+ p.scanned(map[string]<-chan error{"A": a, "B": b})
51+ p.readyEvent("A", a, nil)
52+ return p.readyEvent("B", b, nil)
53+ }, false},
54+ {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
55+ p.start(1, map[string]bool{"A": true}, nil)
56+ p.start(2, map[string]bool{"A": true}, nil)
57+ p.scanned(map[string]<-chan error{"A": a})
58+ p.timeout(1)
59+ return p.readyEvent("A", a, nil)
60+ }, false},
61+ {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision {
62+ p.start(1, nil, map[string]bool{"A": false})
63+ return p.stopped("A", a, false, nil)
64+ }, false},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ p := &applyProgress{}
68+ a, b := make(chan error), make(chan error)
69+ d := tc.run(p, a, b)
70+ if tc.name == "unchanged not ready" {
71+ if d.reply || !p.active {
72+ t.Fatalf("premature: %+v", d)
73+ }
74+ return
75+ }
76+ if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure {
77+ t.Fatalf("decision: %+v", d)
78+ }
79+ if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) {
80+ t.Fatalf("scan error lost: %v", d.err)
81+ }
82+ })
83+ }
84+}
@@ -0,0 +1,84 @@
1+package app
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestApplyProgress(t *testing.T) {
9+ boom := errors.New("modeset")
10+ for _, tc := range []struct {
11+ name string
12+ run func(*applyProgress, <-chan error, <-chan error) applyDecision
13+ failure bool
14+ }{
15+ {"success", func(p *applyProgress, a, _ <-chan error) applyDecision {
16+ p.start(1, map[string]bool{"A": true}, nil)
17+ p.scanned(map[string]<-chan error{"A": a})
18+ return p.readyEvent("A", a, nil)
19+ }, false},
20+ {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision {
21+ p.completed = map[<-chan error]bool{a: true}
22+ p.start(1, map[string]bool{"A": true}, nil)
23+ d := p.scanError(boom)
24+ if later := p.scanned(map[string]<-chan error{"A": a}); later.reply {
25+ t.Fatalf("old ready output completed failed apply: %+v", later)
26+ }
27+ return d
28+ }, true},
29+ {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision {
30+ p.start(1, map[string]bool{"A": true}, nil)
31+ p.scanned(map[string]<-chan error{"A": a})
32+ return p.readyEvent("A", a, boom)
33+ }, true},
34+ {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
35+ p.start(1, map[string]bool{"A": true}, nil)
36+ p.scanned(map[string]<-chan error{"A": a})
37+ return p.timeout(1)
38+ }, true},
39+ {"restart replacement missing", func(p *applyProgress, a, _ <-chan error) applyDecision {
40+ p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true})
41+ p.stopped("A", a, true, nil)
42+ return p.scanned(nil)
43+ }, true},
44+ {"unchanged not ready", func(p *applyProgress, a, _ <-chan error) applyDecision {
45+ p.start(1, map[string]bool{"A": true}, nil)
46+ return p.scanned(map[string]<-chan error{"A": a})
47+ }, false},
48+ {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision {
49+ p.start(1, map[string]bool{"A": true, "B": true}, nil)
50+ p.scanned(map[string]<-chan error{"A": a, "B": b})
51+ p.readyEvent("A", a, nil)
52+ return p.readyEvent("B", b, nil)
53+ }, false},
54+ {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision {
55+ p.start(1, map[string]bool{"A": true}, nil)
56+ p.start(2, map[string]bool{"A": true}, nil)
57+ p.scanned(map[string]<-chan error{"A": a})
58+ p.timeout(1)
59+ return p.readyEvent("A", a, nil)
60+ }, false},
61+ {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision {
62+ p.start(1, nil, map[string]bool{"A": false})
63+ return p.stopped("A", a, false, nil)
64+ }, false},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ p := &applyProgress{}
68+ a, b := make(chan error), make(chan error)
69+ d := tc.run(p, a, b)
70+ if tc.name == "unchanged not ready" {
71+ if d.reply || !p.active {
72+ t.Fatalf("premature: %+v", d)
73+ }
74+ return
75+ }
76+ if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure {
77+ t.Fatalf("decision: %+v", d)
78+ }
79+ if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) {
80+ t.Fatalf("scan error lost: %v", d.err)
81+ }
82+ })
83+ }
84+}
M internal/core/core.go +1 -0
@@ -508,6 +508,7 @@ func (c *Core) resolveConstraint() {
508508 }
509509 r := c.clientRect(p)
510510 r.X += s.x
511+ r.Y += s.y
511512 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
512513 // The region is clipped to the window.
513514 x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)
@@ -508,6 +508,7 @@ func (c *Core) resolveConstraint() {
508 }508 }
509 r := c.clientRect(p)509 r := c.clientRect(p)
510 r.X += s.x510 r.X += s.x
511+ r.Y += s.y
511 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {512 if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
512 // The region is clipped to the window.513 // The region is clipped to the window.
513 x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)514 x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)
M internal/core/screens.go +24 -4
@@ -14,7 +14,7 @@ type screen struct {
1414 mon *Monitor
1515 scale, cfgScale float64
1616 primary bool
17- x int // logical left edge in the global layout
17+ x, y int // logical origin in the global layout
1818 off bool // turned off by a client (output power management)
1919 layers []ports.LayerSurface
2020 placed []ports.SceneLayer
@@ -24,7 +24,7 @@ func (s *screen) name() string { return s.info.Name }
2424
2525 func (s *screen) placement() ports.OutputPlacement {
2626 o := s.mon.Output()
27- return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}
27+ return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}
2828 }
2929
3030 // setScale resizes the logical output; layer placement follows.
@@ -55,10 +55,30 @@ func (c *Core) order() {
5555 focused := c.cur()
5656 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
5757 c.focusScreen = slices.Index(c.screens, focused)
58+ // Explicitly placed outputs reserve their position first; auto-placed
59+ // outputs follow the rightmost explicit edge in configuration order.
5860 x := 0
5961 for _, s := range c.screens {
60- s.x = x
61- x += s.mon.Output().W
62+ for _, o := range c.cfg.Outputs {
63+ if o.Name == s.name() && o.Pos != nil {
64+ s.x, s.y = o.Pos.X, o.Pos.Y
65+ x = max(x, s.x+s.mon.Output().W)
66+ break
67+ }
68+ }
69+ }
70+ for _, s := range c.screens {
71+ explicit := false
72+ for _, o := range c.cfg.Outputs {
73+ if o.Name == s.name() && o.Pos != nil {
74+ explicit = true
75+ break
76+ }
77+ }
78+ if !explicit {
79+ s.x, s.y = x, 0
80+ x += s.mon.Output().W
81+ }
6282 }
6383 }
6484
@@ -14,7 +14,7 @@ type screen struct {
14 mon *Monitor14 mon *Monitor
15 scale, cfgScale float6415 scale, cfgScale float64
16 primary bool16 primary bool
17- x int // logical left edge in the global layout17+ x, y int // logical origin in the global layout
18 off bool // turned off by a client (output power management)18 off bool // turned off by a client (output power management)
19 layers []ports.LayerSurface19 layers []ports.LayerSurface
20 placed []ports.SceneLayer20 placed []ports.SceneLayer
@@ -24,7 +24,7 @@ func (s *screen) name() string { return s.info.Name }
24 24
25 func (s *screen) placement() ports.OutputPlacement {25 func (s *screen) placement() ports.OutputPlacement {
26 o := s.mon.Output()26 o := s.mon.Output()
27- return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}27+ return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}
28 }28 }
29 29
30 // setScale resizes the logical output; layer placement follows.30 // setScale resizes the logical output; layer placement follows.
@@ -55,10 +55,30 @@ func (c *Core) order() {
55 focused := c.cur()55 focused := c.cur()
56 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })56 slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
57 c.focusScreen = slices.Index(c.screens, focused)57 c.focusScreen = slices.Index(c.screens, focused)
58+ // Explicitly placed outputs reserve their position first; auto-placed
59+ // outputs follow the rightmost explicit edge in configuration order.
58 x := 060 x := 0
59 for _, s := range c.screens {61 for _, s := range c.screens {
60- s.x = x62+ for _, o := range c.cfg.Outputs {
61- x += s.mon.Output().W63+ if o.Name == s.name() && o.Pos != nil {
64+ s.x, s.y = o.Pos.X, o.Pos.Y
65+ x = max(x, s.x+s.mon.Output().W)
66+ break
67+ }
68+ }
69+ }
70+ for _, s := range c.screens {
71+ explicit := false
72+ for _, o := range c.cfg.Outputs {
73+ if o.Name == s.name() && o.Pos != nil {
74+ explicit = true
75+ break
76+ }
77+ }
78+ if !explicit {
79+ s.x, s.y = x, 0
80+ x += s.mon.Output().W
81+ }
62 }82 }
63 }83 }
64 84
M internal/core/screens_test.go +33 -0
@@ -2,6 +2,7 @@ package core_test
22
33 import (
44 "context"
5+ "image"
56 "reflect"
67 "slices"
78 "testing"
@@ -189,6 +190,23 @@ func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
189190 }
190191 }
191192
193+func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
194+ r := startMulti(t, func(c *ports.Config) {
195+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}}
196+ }, left, right)
197+ out := lastOutputs(t, r.commands)
198+ if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 {
199+ t.Fatalf("unexpected positions: %+v", out.Outputs)
200+ }
201+ r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12}
202+ r.reload <- ports.ConfigChanged{Config: r.cfg}
203+ _ = receive(t, r.scenes)
204+ out = lastOutputs(t, r.commands)
205+ if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 {
206+ t.Fatalf("unexpected positions after reload: %+v", out.Outputs)
207+ }
208+}
209+
192210 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
193211 r := startMulti(t, nil, left, right)
194212 r.mapWindow(t, 1)
@@ -665,3 +683,18 @@ func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
665683 t.Fatal(got, focused)
666684 }
667685 }
686+
687+func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
688+ r := startMulti(t, func(c *ports.Config) {
689+ c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}}
690+ }, left, right)
691+ out := lastOutputs(t, r.commands)
692+ if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" {
693+ t.Fatalf("ordering: %+v", out.Outputs)
694+ }
695+ for _, o := range out.Outputs {
696+ if o.X != 20 || o.Y != -120 {
697+ t.Fatalf("explicit position: %+v", o)
698+ }
699+ }
700+}
@@ -2,6 +2,7 @@ package core_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "image"
5 "reflect"6 "reflect"
6 "slices"7 "slices"
7 "testing"8 "testing"
@@ -189,6 +190,23 @@ func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
189 }190 }
190 }191 }
191 192
193+func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
194+ r := startMulti(t, func(c *ports.Config) {
195+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}}
196+ }, left, right)
197+ out := lastOutputs(t, r.commands)
198+ if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 {
199+ t.Fatalf("unexpected positions: %+v", out.Outputs)
200+ }
201+ r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12}
202+ r.reload <- ports.ConfigChanged{Config: r.cfg}
203+ _ = receive(t, r.scenes)
204+ out = lastOutputs(t, r.commands)
205+ if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 {
206+ t.Fatalf("unexpected positions after reload: %+v", out.Outputs)
207+ }
208+}
209+
192 func TestWindowsOpenOnFocusedOutput(t *testing.T) {210 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
193 r := startMulti(t, nil, left, right)211 r := startMulti(t, nil, left, right)
194 r.mapWindow(t, 1)212 r.mapWindow(t, 1)
@@ -665,3 +683,18 @@ func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
665 t.Fatal(got, focused)683 t.Fatal(got, focused)
666 }684 }
667 }685 }
686+
687+func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
688+ r := startMulti(t, func(c *ports.Config) {
689+ c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}}
690+ }, left, right)
691+ out := lastOutputs(t, r.commands)
692+ if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" {
693+ t.Fatalf("ordering: %+v", out.Outputs)
694+ }
695+ for _, o := range out.Outputs {
696+ if o.X != 20 || o.Y != -120 {
697+ t.Fatalf("explicit position: %+v", o)
698+ }
699+ }
700+}
M internal/ports/config.go +8 -4
@@ -1,5 +1,7 @@
11 package ports
22
3+import "image"
4+
35 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
46 const DefaultSDRBrightness = 203
57
@@ -14,10 +16,12 @@ const DefaultSDRBrightness = 203
1416 // ScaleOnly marks an entry set only by output.<name> subkeys: it
1517 // does not select the connector.
1618 type OutputConfig struct {
17- Name string
18- Mode string
19- Off bool
20- Scale float64
19+ Name string
20+ Mode string
21+ Off bool
22+ Scale float64
23+ // Pos is an explicit logical placement set at runtime; nil uses automatic layout.
24+ Pos *image.Point
2125 Primary bool
2226 HDR bool // opt-in to HDR10 on capable outputs
2327 SDRBrightness int // nits; default DefaultSDRBrightness
@@ -1,5 +1,7 @@
1 package ports1 package ports
2 2
3+import "image"
4+
3 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.5 // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
4 const DefaultSDRBrightness = 2036 const DefaultSDRBrightness = 203
5 7
@@ -14,10 +16,12 @@ const DefaultSDRBrightness = 203
14 // ScaleOnly marks an entry set only by output.<name> subkeys: it16 // ScaleOnly marks an entry set only by output.<name> subkeys: it
15 // does not select the connector.17 // does not select the connector.
16 type OutputConfig struct {18 type OutputConfig struct {
17- Name string19+ Name string
18- Mode string20+ Mode string
19- Off bool21+ Off bool
20- Scale float6422+ Scale float64
23+ // Pos is an explicit logical placement set at runtime; nil uses automatic layout.
24+ Pos *image.Point
21 Primary bool25 Primary bool
22 HDR bool // opt-in to HDR10 on capable outputs26 HDR bool // opt-in to HDR10 on capable outputs
23 SDRBrightness int // nits; default DefaultSDRBrightness27 SDRBrightness int // nits; default DefaultSDRBrightness
M internal/ports/messages.go +43 -0
@@ -358,6 +358,49 @@ func (i OutputInfo) Key() string {
358358 return i.Make + " " + i.Model + " " + i.Serial
359359 }
360360
361+// OutputMode describes a connected connector's physical mode.
362+type OutputMode struct {
363+ Width, Height, RefreshMilli int
364+ Preferred bool
365+}
366+
367+// OutputHead describes a connected connector even when it is disabled.
368+// Current is the active mode; it is nil for disabled or not-yet-running heads.
369+type OutputHead struct {
370+ Info OutputInfo
371+ Modes []OutputMode
372+ Current *OutputMode
373+ Enabled bool
374+}
375+
376+// OutputHeads is a complete backend inventory, delivered to Wayland after a scan.
377+type OutputHeads struct{ Heads []OutputHead }
378+
379+// HeadChange is the complete desired state of one head in a configuration.
380+type HeadChange struct {
381+ Name string
382+ Enabled bool
383+ Mode *OutputMode
384+ CustomMode bool
385+ Pos *image.Point
386+ Scale float64
387+ Transform int
388+ AdaptiveSync *bool
389+}
390+
391+// OutputApply asks the app owner to validate or apply a runtime configuration.
392+type OutputApply struct {
393+ ID uint64
394+ Test bool
395+ Heads []HeadChange
396+}
397+
398+// OutputApplied reports completion (or failure) of a runtime configuration.
399+type OutputApplied struct {
400+ ID uint64
401+ Err error
402+}
403+
361404 // OutputAdded carries output → core a new display, or a new mode for a known
362405 // connector.
363406 type OutputAdded struct{ Info OutputInfo }
@@ -358,6 +358,49 @@ func (i OutputInfo) Key() string {
358 return i.Make + " " + i.Model + " " + i.Serial358 return i.Make + " " + i.Model + " " + i.Serial
359 }359 }
360 360
361+// OutputMode describes a connected connector's physical mode.
362+type OutputMode struct {
363+ Width, Height, RefreshMilli int
364+ Preferred bool
365+}
366+
367+// OutputHead describes a connected connector even when it is disabled.
368+// Current is the active mode; it is nil for disabled or not-yet-running heads.
369+type OutputHead struct {
370+ Info OutputInfo
371+ Modes []OutputMode
372+ Current *OutputMode
373+ Enabled bool
374+}
375+
376+// OutputHeads is a complete backend inventory, delivered to Wayland after a scan.
377+type OutputHeads struct{ Heads []OutputHead }
378+
379+// HeadChange is the complete desired state of one head in a configuration.
380+type HeadChange struct {
381+ Name string
382+ Enabled bool
383+ Mode *OutputMode
384+ CustomMode bool
385+ Pos *image.Point
386+ Scale float64
387+ Transform int
388+ AdaptiveSync *bool
389+}
390+
391+// OutputApply asks the app owner to validate or apply a runtime configuration.
392+type OutputApply struct {
393+ ID uint64
394+ Test bool
395+ Heads []HeadChange
396+}
397+
398+// OutputApplied reports completion (or failure) of a runtime configuration.
399+type OutputApplied struct {
400+ ID uint64
401+ Err error
402+}
403+
361 // OutputAdded carries output → core a new display, or a new mode for a known404 // OutputAdded carries output → core a new display, or a new mode for a known
362 // connector.405 // connector.
363 type OutputAdded struct{ Info OutputInfo }406 type OutputAdded struct{ Info OutputInfo }

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