detached 8fd5205

fix(wayland): track popup role objects and reset buffers on role destroy

commit
8fd5205ff6419e58d423d7323ed2f57d30483f01
author
brice · · 2026-09-23 12:33 UTC
parent
78a0c3a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +141 -8
M internal/adapters/wayland/protocol_test.go +124 -4
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "errors"
5+ "github.com/bnema/nefertty/internal/ports"
56 "path/filepath"
67 "testing"
78 "time"
@@ -103,10 +104,6 @@ func TestXDGProtocolErrors(t *testing.T) {
103104 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
104105 switch scenario {
105106 case "bogus ack":
106- requestProtocol(t, c, surface, wayland.SurfaceRequestCommit)
107- if err := c.Roundtrip(); err != nil {
108- t.Fatal(err)
109- }
110107 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, uint32(0))
111108 expectProtocolError(t, c, xdg, uint32(xdgshell.SurfaceErrorInvalidSerial))
112109 case "buffer before ack":
@@ -138,3 +135,126 @@ func TestXDGProtocolErrors(t *testing.T) {
138135 })
139136 }
140137 }
138+
139+func TestPopupBlocksSecondRole(t *testing.T) {
140+ s, _, _, dir := lifecycleServer(t)
141+ c := protocolClient(t, s, dir)
142+ comp := bindProtocol(t, c, "wl_compositor")
143+ wm := bindProtocol(t, c, "xdg_wm_base")
144+ surf, xdg := c.AllocateID(), c.AllocateID()
145+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
146+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
147+ registerProtocol(t, c, xdg)
148+ registerProtocol(t, c, surf)
149+ positioner := c.AllocateID()
150+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner)
151+ if err := c.Roundtrip(); err != nil {
152+ t.Fatal(err)
153+ }
154+ popup := c.AllocateID()
155+ registerProtocol(t, c, popup)
156+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner)
157+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, c.AllocateID())
158+ expectProtocolError(t, c, xdg, uint32(xdgshell.SurfaceErrorAlreadyConstructed))
159+}
160+
161+type configureProxy struct {
162+ wlturbo.BaseProxy
163+ serial chan uint32
164+}
165+
166+func (p *configureProxy) Dispatch(e *wlturbo.Event) {
167+ if e.Opcode == uint16(xdgshell.SurfaceEventConfigure) {
168+ p.serial <- e.Uint32()
169+ }
170+}
171+
172+func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
173+ s, events, _, dir := lifecycleServer(t)
174+ c := protocolClient(t, s, dir)
175+ comp := bindProtocol(t, c, "wl_compositor")
176+ wm := bindProtocol(t, c, "xdg_wm_base")
177+ shm := bindProtocol(t, c, "wl_shm")
178+ surf, xdg := c.AllocateID(), c.AllocateID()
179+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
180+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
181+ serials := make(chan uint32, 4)
182+ proxy := &configureProxy{serial: serials}
183+ proxy.SetID(xdg)
184+ c.Context().Register(proxy)
185+ fd, err := unix.MemfdCreate("recreated-buffer", 0)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ defer unix.Close(fd)
190+ if err := unix.Ftruncate(fd, 4); err != nil {
191+ t.Fatal(err)
192+ }
193+ registerProtocol(t, c, shm)
194+ pool, buffer := c.AllocateID(), c.AllocateID()
195+ registerProtocol(t, c, pool)
196+ registerProtocol(t, c, buffer)
197+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, uint32(0), int32(4)); err != nil {
198+ t.Fatal(err)
199+ }
200+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
201+ top := c.AllocateID()
202+ registerProtocol(t, c, top)
203+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
204+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ select {
209+ case serial := <-serials:
210+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
211+ case <-time.After(2 * time.Second):
212+ t.Fatal("initial configure timeout")
213+ }
214+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
215+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ mapped(t, events, 2*time.Second)
220+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ select {
225+ case ev := <-events:
226+ if _, ok := ev.(ports.WindowUnmapped); !ok {
227+ t.Fatalf("expected unmap, got %T", ev)
228+ }
229+ case <-time.After(2 * time.Second):
230+ t.Fatal("unmap timeout")
231+ }
232+ top = c.AllocateID()
233+ registerProtocol(t, c, top)
234+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
235+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
236+ if err := c.Roundtrip(); err != nil {
237+ t.Fatal(err)
238+ }
239+ select {
240+ case serial := <-serials:
241+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
242+ case <-time.After(2 * time.Second):
243+ t.Fatal("recreated configure timeout")
244+ }
245+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
246+ if err := c.Roundtrip(); err != nil {
247+ t.Fatal(err)
248+ }
249+ select {
250+ case ev := <-events:
251+ t.Fatalf("mapped without new buffer: %T", ev)
252+ case <-time.After(300 * time.Millisecond):
253+ }
254+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
255+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
256+ if err := c.Roundtrip(); err != nil {
257+ t.Fatal(err)
258+ }
259+ mapped(t, events, 2*time.Second)
260+}
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "github.com/bnema/nefertty/internal/ports"
5 "path/filepath"6 "path/filepath"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -103,10 +104,6 @@ func TestXDGProtocolErrors(t *testing.T) {
103 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)104 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
104 switch scenario {105 switch scenario {
105 case "bogus ack":106 case "bogus ack":
106- requestProtocol(t, c, surface, wayland.SurfaceRequestCommit)
107- if err := c.Roundtrip(); err != nil {
108- t.Fatal(err)
109- }
110 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, uint32(0))107 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, uint32(0))
111 expectProtocolError(t, c, xdg, uint32(xdgshell.SurfaceErrorInvalidSerial))108 expectProtocolError(t, c, xdg, uint32(xdgshell.SurfaceErrorInvalidSerial))
112 case "buffer before ack":109 case "buffer before ack":
@@ -138,3 +135,126 @@ func TestXDGProtocolErrors(t *testing.T) {
138 })135 })
139 }136 }
140 }137 }
138+
139+func TestPopupBlocksSecondRole(t *testing.T) {
140+ s, _, _, dir := lifecycleServer(t)
141+ c := protocolClient(t, s, dir)
142+ comp := bindProtocol(t, c, "wl_compositor")
143+ wm := bindProtocol(t, c, "xdg_wm_base")
144+ surf, xdg := c.AllocateID(), c.AllocateID()
145+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
146+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
147+ registerProtocol(t, c, xdg)
148+ registerProtocol(t, c, surf)
149+ positioner := c.AllocateID()
150+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner)
151+ if err := c.Roundtrip(); err != nil {
152+ t.Fatal(err)
153+ }
154+ popup := c.AllocateID()
155+ registerProtocol(t, c, popup)
156+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner)
157+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, c.AllocateID())
158+ expectProtocolError(t, c, xdg, uint32(xdgshell.SurfaceErrorAlreadyConstructed))
159+}
160+
161+type configureProxy struct {
162+ wlturbo.BaseProxy
163+ serial chan uint32
164+}
165+
166+func (p *configureProxy) Dispatch(e *wlturbo.Event) {
167+ if e.Opcode == uint16(xdgshell.SurfaceEventConfigure) {
168+ p.serial <- e.Uint32()
169+ }
170+}
171+
172+func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
173+ s, events, _, dir := lifecycleServer(t)
174+ c := protocolClient(t, s, dir)
175+ comp := bindProtocol(t, c, "wl_compositor")
176+ wm := bindProtocol(t, c, "xdg_wm_base")
177+ shm := bindProtocol(t, c, "wl_shm")
178+ surf, xdg := c.AllocateID(), c.AllocateID()
179+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
180+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
181+ serials := make(chan uint32, 4)
182+ proxy := &configureProxy{serial: serials}
183+ proxy.SetID(xdg)
184+ c.Context().Register(proxy)
185+ fd, err := unix.MemfdCreate("recreated-buffer", 0)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ defer unix.Close(fd)
190+ if err := unix.Ftruncate(fd, 4); err != nil {
191+ t.Fatal(err)
192+ }
193+ registerProtocol(t, c, shm)
194+ pool, buffer := c.AllocateID(), c.AllocateID()
195+ registerProtocol(t, c, pool)
196+ registerProtocol(t, c, buffer)
197+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, uint32(0), int32(4)); err != nil {
198+ t.Fatal(err)
199+ }
200+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
201+ top := c.AllocateID()
202+ registerProtocol(t, c, top)
203+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
204+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ select {
209+ case serial := <-serials:
210+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
211+ case <-time.After(2 * time.Second):
212+ t.Fatal("initial configure timeout")
213+ }
214+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
215+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ mapped(t, events, 2*time.Second)
220+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ select {
225+ case ev := <-events:
226+ if _, ok := ev.(ports.WindowUnmapped); !ok {
227+ t.Fatalf("expected unmap, got %T", ev)
228+ }
229+ case <-time.After(2 * time.Second):
230+ t.Fatal("unmap timeout")
231+ }
232+ top = c.AllocateID()
233+ registerProtocol(t, c, top)
234+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
235+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
236+ if err := c.Roundtrip(); err != nil {
237+ t.Fatal(err)
238+ }
239+ select {
240+ case serial := <-serials:
241+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
242+ case <-time.After(2 * time.Second):
243+ t.Fatal("recreated configure timeout")
244+ }
245+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
246+ if err := c.Roundtrip(); err != nil {
247+ t.Fatal(err)
248+ }
249+ select {
250+ case ev := <-events:
251+ t.Fatalf("mapped without new buffer: %T", ev)
252+ case <-time.After(300 * time.Millisecond):
253+ }
254+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
255+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
256+ if err := c.Roundtrip(); err != nil {
257+ t.Fatal(err)
258+ }
259+ mapped(t, events, 2*time.Second)
260+}
M internal/adapters/wayland/server.go +4 -0
@@ -42,6 +42,10 @@ type Server struct {
4242 nextWindow ports.WindowID
4343 focused ports.WindowID
4444 // eventMu protects only the notification queue, not display-owned window state.
45+ // The event queue is unbounded by design: it grows only if core stops draining
46+ // Events, which happens only at shutdown (core owns the receiving end). A
47+ // stalled core is a bug surfaced by ctx cancellation, not a reason to block
48+ // the display goroutine.
4549 eventMu sync.Mutex
4650 events []ports.ClientEvent
4751 eventReady chan struct{}
@@ -42,6 +42,10 @@ type Server struct {
42 nextWindow ports.WindowID42 nextWindow ports.WindowID
43 focused ports.WindowID43 focused ports.WindowID
44 // eventMu protects only the notification queue, not display-owned window state.44 // eventMu protects only the notification queue, not display-owned window state.
45+ // The event queue is unbounded by design: it grows only if core stops draining
46+ // Events, which happens only at shutdown (core owns the receiving end). A
47+ // stalled core is a bug surfaced by ctx cancellation, not a reason to block
48+ // the display goroutine.
45 eventMu sync.Mutex49 eventMu sync.Mutex
46 events []ports.ClientEvent50 events []ports.ClientEvent
47 eventReady chan struct{}51 eventReady chan struct{}
M internal/adapters/wayland/xdg.go +13 -4
@@ -61,12 +61,13 @@ type xdgSurface struct {
6161 configured bool
6262 acked bool
6363 window *window
64+ popup *xdgshell.Popup
6465 serials []uint32
6566 }
6667
6768 func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
68- if x.window != nil {
69- r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "toplevel still exists")
69+ if x.window != nil || x.popup != nil {
70+ r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists")
7071 }
7172 }
7273
@@ -89,8 +90,8 @@ func (w *window) unmap() {
8990 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
9091 }
9192 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
92- if x.window != nil {
93- r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "toplevel already constructed")
93+ if x.window != nil || x.popup != nil {
94+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
9495 return
9596 }
9697 w := &window{id: x.server.nextWindow, xdg: x}
@@ -123,6 +124,8 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
123124 }
124125 t.OnDestroy = func() {
125126 w.unmap()
127+ x.surface.current, x.surface.pending = nil, nil
128+ x.surface.attached = false
126129 delete(x.server.windows, w.id)
127130 x.window = nil
128131 x.surface.role = nil
@@ -132,7 +135,13 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
132135 }
133136 }
134137 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {
138+ if x.window != nil || x.popup != nil {
139+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
140+ return
141+ }
135142 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {
143+ x.popup = p
144+ p.OnDestroy = func() { x.popup = nil }
136145 p.SendPopupDone()
137146 }
138147 }
@@ -61,12 +61,13 @@ type xdgSurface struct {
61 configured bool61 configured bool
62 acked bool62 acked bool
63 window *window63 window *window
64+ popup *xdgshell.Popup
64 serials []uint3265 serials []uint32
65 }66 }
66 67
67 func (x *xdgSurface) Destroy(r *xdgshell.Surface) {68 func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
68- if x.window != nil {69+ if x.window != nil || x.popup != nil {
69- r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "toplevel still exists")70+ r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists")
70 }71 }
71 }72 }
72 73
@@ -89,8 +90,8 @@ func (w *window) unmap() {
89 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})90 w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
90 }91 }
91 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {92 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
92- if x.window != nil {93+ if x.window != nil || x.popup != nil {
93- r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "toplevel already constructed")94+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
94 return95 return
95 }96 }
96 w := &window{id: x.server.nextWindow, xdg: x}97 w := &window{id: x.server.nextWindow, xdg: x}
@@ -123,6 +124,8 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
123 }124 }
124 t.OnDestroy = func() {125 t.OnDestroy = func() {
125 w.unmap()126 w.unmap()
127+ x.surface.current, x.surface.pending = nil, nil
128+ x.surface.attached = false
126 delete(x.server.windows, w.id)129 delete(x.server.windows, w.id)
127 x.window = nil130 x.window = nil
128 x.surface.role = nil131 x.surface.role = nil
@@ -132,7 +135,13 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
132 }135 }
133 }136 }
134 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {137 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {
138+ if x.window != nil || x.popup != nil {
139+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
140+ return
141+ }
135 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {142 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {
143+ x.popup = p
144+ p.OnDestroy = func() { x.popup = nil }
136 p.SendPopupDone()145 p.SendPopupDone()
137 }146 }
138 }147 }

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