detached 1032255

fix(wayland): ack layer configure atomically with its size

Carry extent and serial in one owner-loop event, keeping xdg configure handling unchanged. Add a post-startup wire-order regression and clarify layer sizing and exclusive-zone documentation.
commit
1032255de3f8cd955db8c5696bf5463d6f0dc7be
author
brice · · 2026-09-30 14:38 UTC
committer
brice · · 2026-09-30 14:46 UTC
parent
d9b2d87
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +123 -10
M internal/platform/wayland/events.go +11 -6
@@ -35,7 +35,8 @@ const (
3535 FeedbackFormats
3636 ClipboardInput
3737 IMEInput
38- // ConfigureLayer carries a layer-surface extent; zero axes are unchanged.
38+ // ConfigureLayer carries a layer-surface extent and its configure serial;
39+ // Apply resizes and acks together. Zero axes are unchanged.
3940 ConfigureLayer
4041 )
4142
@@ -119,12 +120,9 @@ func (w *Window) Apply(ev Event) error {
119120 }
120121 case ConfigureSerial:
121122 var err error
122- switch {
123- case w.LayerSurface != nil:
124- err = w.LayerSurface.AckConfigure(ev.Serial)
125- case w.XdgSurface != nil:
123+ if w.XdgSurface != nil {
126124 err = w.XdgSurface.AckConfigure(ev.Serial)
127- default:
125+ } else {
128126 err = fmt.Errorf("configure without a shell surface")
129127 }
130128 if err != nil {
@@ -133,6 +131,13 @@ func (w *Window) Apply(ev Event) error {
133131 w.Configured, w.FrameReady = true, true
134132 case ConfigureLayer:
135133 w.applyLayerSize(ev.Width, ev.Height)
134+ if w.LayerSurface == nil {
135+ return fmt.Errorf("configure without a layer surface")
136+ }
137+ if err := w.LayerSurface.AckConfigure(ev.Serial); err != nil {
138+ return err
139+ }
140+ w.Configured, w.FrameReady = true, true
136141 case PreferredScale:
137142 if ev.Scale == 0 {
138143 return fmt.Errorf("invalid preferred scale zero")
@@ -35,7 +35,8 @@ const (
35 FeedbackFormats35 FeedbackFormats
36 ClipboardInput36 ClipboardInput
37 IMEInput37 IMEInput
38- // ConfigureLayer carries a layer-surface extent; zero axes are unchanged.38+ // ConfigureLayer carries a layer-surface extent and its configure serial;
39+ // Apply resizes and acks together. Zero axes are unchanged.
39 ConfigureLayer40 ConfigureLayer
40 )41 )
41 42
@@ -119,12 +120,9 @@ func (w *Window) Apply(ev Event) error {
119 }120 }
120 case ConfigureSerial:121 case ConfigureSerial:
121 var err error122 var err error
122- switch {123+ if w.XdgSurface != nil {
123- case w.LayerSurface != nil:
124- err = w.LayerSurface.AckConfigure(ev.Serial)
125- case w.XdgSurface != nil:
126 err = w.XdgSurface.AckConfigure(ev.Serial)124 err = w.XdgSurface.AckConfigure(ev.Serial)
127- default:125+ } else {
128 err = fmt.Errorf("configure without a shell surface")126 err = fmt.Errorf("configure without a shell surface")
129 }127 }
130 if err != nil {128 if err != nil {
@@ -133,6 +131,13 @@ func (w *Window) Apply(ev Event) error {
133 w.Configured, w.FrameReady = true, true131 w.Configured, w.FrameReady = true, true
134 case ConfigureLayer:132 case ConfigureLayer:
135 w.applyLayerSize(ev.Width, ev.Height)133 w.applyLayerSize(ev.Width, ev.Height)
134+ if w.LayerSurface == nil {
135+ return fmt.Errorf("configure without a layer surface")
136+ }
137+ if err := w.LayerSurface.AckConfigure(ev.Serial); err != nil {
138+ return err
139+ }
140+ w.Configured, w.FrameReady = true, true
136 case PreferredScale:141 case PreferredScale:
137 if ev.Scale == 0 {142 if ev.Scale == 0 {
138 return fmt.Errorf("invalid preferred scale zero")143 return fmt.Errorf("invalid preferred scale zero")
M internal/platform/wayland/layer.go +1 -2
@@ -333,8 +333,7 @@ func (w *Window) createLayerSurface(o LayerOptions) error {
333333 return
334334 }
335335 if w.readerStarted.Load() {
336- w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch})
337- w.post(Event{Kind: ConfigureSerial, Serial: serial})
336+ w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch, Serial: serial})
338337 return
339338 }
340339 w.applyLayerSize(cw, ch)
@@ -333,8 +333,7 @@ func (w *Window) createLayerSurface(o LayerOptions) error {
333 return333 return
334 }334 }
335 if w.readerStarted.Load() {335 if w.readerStarted.Load() {
336- w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch})336+ w.post(Event{Kind: ConfigureLayer, Width: cw, Height: ch, Serial: serial})
337- w.post(Event{Kind: ConfigureSerial, Serial: serial})
338 return337 return
339 }338 }
340 w.applyLayerSize(cw, ch)339 w.applyLayerSize(cw, ch)
M internal/platform/wayland/layer_test.go +105 -0
@@ -75,6 +75,111 @@ func TestLayerConfigureSizeBounds(t *testing.T) {
7575 }
7676 }
7777
78+// A post-startup configure must carry size and serial together, even when the
79+// previous frame is ready. Applying it must ack before the owner can commit.
80+func TestLayerConfigureAfterReaderStart(t *testing.T) {
81+ srv, accept := newWireServer(t)
82+ type dialed struct {
83+ d *wlturbo.Display
84+ err error
85+ }
86+ ch := make(chan dialed, 1)
87+ go func() {
88+ d, err := wlturbo.Connect(srv.path)
89+ ch <- dialed{d, err}
90+ }()
91+ conn := accept()
92+ r := <-ch
93+ if r.err != nil {
94+ t.Fatal(r.err)
95+ }
96+ d := r.d
97+ defer d.Close()
98+ comp := core.NewCompositor(d.Context())
99+ if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
100+ t.Fatal(err)
101+ }
102+ surface, err := comp.CreateSurface()
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ w := &Window{Display: d, Compositor: comp, Surface: surface, Width: 100, Height: 40,
107+ Configured: true, FrameReady: true, events: make(chan Event, 8), readerStop: make(chan struct{})}
108+ if err := w.bindLayerShell(func(iface string, max, min uint32, p wl.Proxy) error {
109+ return d.Registry().Bind(2, iface, max, p)
110+ }); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := w.createLayerSurface(LayerOptions{Layer: LayerTop}); err != nil {
114+ t.Fatal(err)
115+ }
116+ w.StartReader()
117+ defer func() {
118+ close(w.readerStop)
119+ _ = d.Close()
120+ <-w.readerDone
121+ }()
122+ for _, tc := range []struct {
123+ serial, width, height uint32
124+ wantW, wantH int32
125+ ready bool
126+ }{{7, 200, 50, 200, 50, true}, {8, 0, 60, 200, 60, false}} {
127+ w.FrameReady, w.Dirty = tc.ready, false
128+ if _, err := conn.Write(frame(w.LayerSurface.ID(), 0, tc.serial, tc.width, tc.height)); err != nil {
129+ t.Fatal(err)
130+ }
131+ var ev Event
132+ select {
133+ case ev = <-w.Events():
134+ case <-time.After(5 * time.Second):
135+ t.Fatal("no configure event")
136+ }
137+ if ev.Kind != ConfigureLayer || ev.Width != int32(tc.width) || ev.Height != int32(tc.height) || ev.Serial != tc.serial {
138+ t.Fatalf("configure must be one size+serial event: %+v", ev)
139+ }
140+ if err := w.Apply(ev); err != nil {
141+ t.Fatal(err)
142+ }
143+ if w.Width != tc.wantW || w.Height != tc.wantH || !w.Dirty || !w.Configured || !w.FrameReady {
144+ t.Fatalf("configure state: size=%dx%d dirty=%v configured=%v ready=%v", w.Width, w.Height, w.Dirty, w.Configured, w.FrameReady)
145+ }
146+ if err := surface.Commit(); err != nil {
147+ t.Fatal(err)
148+ }
149+ // Ignore setup requests, but require exactly one ack of this configure
150+ // before the first subsequent surface commit.
151+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
152+ acks := 0
153+ for {
154+ var hdr [8]byte
155+ if _, err := io.ReadFull(conn, hdr[:]); err != nil {
156+ t.Fatal(err)
157+ }
158+ obj, word := binary.NativeEndian.Uint32(hdr[:]), binary.NativeEndian.Uint32(hdr[4:])
159+ size, op := int(word>>16), uint16(word)
160+ if size < 8 {
161+ t.Fatalf("bad frame size %d", size)
162+ }
163+ args := make([]byte, size-8)
164+ if _, err := io.ReadFull(conn, args); err != nil {
165+ t.Fatal(err)
166+ }
167+ if obj == w.LayerSurface.ID() && op == 6 {
168+ if len(args) != 4 || binary.NativeEndian.Uint32(args) != tc.serial {
169+ t.Fatalf("wrong ack payload %x, want serial %d", args, tc.serial)
170+ }
171+ acks++
172+ }
173+ if obj == surface.ID() && op == 6 {
174+ if acks != 1 {
175+ t.Fatalf("commit preceded by %d configure acks, want 1", acks)
176+ }
177+ break
178+ }
179+ }
180+ }
181+}
182+
78183 // Harness acceptance: a real NeferWL compositor and the real client code.
79184 func runLayerHarness(t *testing.T, child string) (string, string) {
80185 t.Helper()
@@ -75,6 +75,111 @@ func TestLayerConfigureSizeBounds(t *testing.T) {
75 }75 }
76 }76 }
77 77
78+// A post-startup configure must carry size and serial together, even when the
79+// previous frame is ready. Applying it must ack before the owner can commit.
80+func TestLayerConfigureAfterReaderStart(t *testing.T) {
81+ srv, accept := newWireServer(t)
82+ type dialed struct {
83+ d *wlturbo.Display
84+ err error
85+ }
86+ ch := make(chan dialed, 1)
87+ go func() {
88+ d, err := wlturbo.Connect(srv.path)
89+ ch <- dialed{d, err}
90+ }()
91+ conn := accept()
92+ r := <-ch
93+ if r.err != nil {
94+ t.Fatal(r.err)
95+ }
96+ d := r.d
97+ defer d.Close()
98+ comp := core.NewCompositor(d.Context())
99+ if err := d.Registry().Bind(1, core.CompositorInterface, 6, comp); err != nil {
100+ t.Fatal(err)
101+ }
102+ surface, err := comp.CreateSurface()
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ w := &Window{Display: d, Compositor: comp, Surface: surface, Width: 100, Height: 40,
107+ Configured: true, FrameReady: true, events: make(chan Event, 8), readerStop: make(chan struct{})}
108+ if err := w.bindLayerShell(func(iface string, max, min uint32, p wl.Proxy) error {
109+ return d.Registry().Bind(2, iface, max, p)
110+ }); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := w.createLayerSurface(LayerOptions{Layer: LayerTop}); err != nil {
114+ t.Fatal(err)
115+ }
116+ w.StartReader()
117+ defer func() {
118+ close(w.readerStop)
119+ _ = d.Close()
120+ <-w.readerDone
121+ }()
122+ for _, tc := range []struct {
123+ serial, width, height uint32
124+ wantW, wantH int32
125+ ready bool
126+ }{{7, 200, 50, 200, 50, true}, {8, 0, 60, 200, 60, false}} {
127+ w.FrameReady, w.Dirty = tc.ready, false
128+ if _, err := conn.Write(frame(w.LayerSurface.ID(), 0, tc.serial, tc.width, tc.height)); err != nil {
129+ t.Fatal(err)
130+ }
131+ var ev Event
132+ select {
133+ case ev = <-w.Events():
134+ case <-time.After(5 * time.Second):
135+ t.Fatal("no configure event")
136+ }
137+ if ev.Kind != ConfigureLayer || ev.Width != int32(tc.width) || ev.Height != int32(tc.height) || ev.Serial != tc.serial {
138+ t.Fatalf("configure must be one size+serial event: %+v", ev)
139+ }
140+ if err := w.Apply(ev); err != nil {
141+ t.Fatal(err)
142+ }
143+ if w.Width != tc.wantW || w.Height != tc.wantH || !w.Dirty || !w.Configured || !w.FrameReady {
144+ t.Fatalf("configure state: size=%dx%d dirty=%v configured=%v ready=%v", w.Width, w.Height, w.Dirty, w.Configured, w.FrameReady)
145+ }
146+ if err := surface.Commit(); err != nil {
147+ t.Fatal(err)
148+ }
149+ // Ignore setup requests, but require exactly one ack of this configure
150+ // before the first subsequent surface commit.
151+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
152+ acks := 0
153+ for {
154+ var hdr [8]byte
155+ if _, err := io.ReadFull(conn, hdr[:]); err != nil {
156+ t.Fatal(err)
157+ }
158+ obj, word := binary.NativeEndian.Uint32(hdr[:]), binary.NativeEndian.Uint32(hdr[4:])
159+ size, op := int(word>>16), uint16(word)
160+ if size < 8 {
161+ t.Fatalf("bad frame size %d", size)
162+ }
163+ args := make([]byte, size-8)
164+ if _, err := io.ReadFull(conn, args); err != nil {
165+ t.Fatal(err)
166+ }
167+ if obj == w.LayerSurface.ID() && op == 6 {
168+ if len(args) != 4 || binary.NativeEndian.Uint32(args) != tc.serial {
169+ t.Fatalf("wrong ack payload %x, want serial %d", args, tc.serial)
170+ }
171+ acks++
172+ }
173+ if obj == surface.ID() && op == 6 {
174+ if acks != 1 {
175+ t.Fatalf("commit preceded by %d configure acks, want 1", acks)
176+ }
177+ break
178+ }
179+ }
180+ }
181+}
182+
78 // Harness acceptance: a real NeferWL compositor and the real client code.183 // Harness acceptance: a real NeferWL compositor and the real client code.
79 func runLayerHarness(t *testing.T, child string) (string, string) {184 func runLayerHarness(t *testing.T, child string) (string, string) {
80 t.Helper()185 t.Helper()
M internal/ui/layer.go +6 -2
@@ -45,8 +45,12 @@ const (
4545 type Rect struct{ X, Y, Width, Height int32 }
4646
4747 // LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.
48-// Size still gives the initial logical size; an anchored axis is sized by the
49-// compositor and reported through OnResize and Frame.Size.
48+// Size still gives the initial logical size; an axis anchored to both opposite
49+// edges is sized by the compositor and reported through OnResize and Frame.Size.
50+// ExclusiveZone > 0 asks the compositor to reserve that many logical pixels
51+// from an anchored edge (only for one edge, or one edge plus both perpendicular
52+// edges); 0 reserves nothing and asks to avoid other surfaces' positive zones;
53+// -1 ignores other exclusive zones and extends to the anchored output edges.
5054 type LayerConfig struct {
5155 Output string // output name; empty lets the compositor choose
5256 Namespace string // defaults to "nefergui"
@@ -45,8 +45,12 @@ const (
45 type Rect struct{ X, Y, Width, Height int32 }45 type Rect struct{ X, Y, Width, Height int32 }
46 46
47 // LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.47 // LayerConfig requests a wlr-layer-shell surface instead of an xdg toplevel.
48-// Size still gives the initial logical size; an anchored axis is sized by the48+// Size still gives the initial logical size; an axis anchored to both opposite
49-// compositor and reported through OnResize and Frame.Size.49+// edges is sized by the compositor and reported through OnResize and Frame.Size.
50+// ExclusiveZone > 0 asks the compositor to reserve that many logical pixels
51+// from an anchored edge (only for one edge, or one edge plus both perpendicular
52+// edges); 0 reserves nothing and asks to avoid other surfaces' positive zones;
53+// -1 ignores other exclusive zones and extends to the anchored output edges.
50 type LayerConfig struct {54 type LayerConfig struct {
51 Output string // output name; empty lets the compositor choose55 Output string // output name; empty lets the compositor choose
52 Namespace string // defaults to "nefergui"56 Namespace string // defaults to "nefergui"

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 1

Tags 12

↑↓ moveenter openesc close