detached 7e0d12d

fix: harden surface present, input region, lock close and fd ownership

Transfer the feedback table fd before reading it (no double close), reject
inconsistent Present requests (nil, size mismatch, point order), reset the
buffer scale to 1, stage input regions until the surface is mapped and refuse
them on lock surfaces, bound lock configure sizes, allow one lock surface per
output, make Lock.Close notice an in-flight locked event, close duplicated
plane fds exactly once and join params.Destroy errors. Remove dead code.

Tests: one regression test per finding, the feedback spy replaced by the
Mockery Handler mock, and a skipped TestCloseWithPendingFrame documenting the
wlturbo delete_id limitation.
commit
7e0d12d63ffdab57842efc111dd9ef5486572151
author
brice · · 2026-10-01 17:51 UTC
parent
71357f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +908 -67
M .mockery.yml +7 -0
@@ -13,6 +13,13 @@ packages:
1313 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
1414 interfaces:
1515 Handler:
16+ configs:
17+ - dir: mocks
18+ pkgname: neferclientmocks
19+ filename: handler_mock.go
20+ - dir: .
21+ pkgname: neferclient
22+ filename: handler_mock_test.go
1623 surfaceRequests:
1724 config:
1825 dir: .
@@ -13,6 +13,13 @@ packages:
13 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'13 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
14 interfaces:14 interfaces:
15 Handler:15 Handler:
16+ configs:
17+ - dir: mocks
18+ pkgname: neferclientmocks
19+ filename: handler_mock.go
20+ - dir: .
21+ pkgname: neferclient
22+ filename: handler_mock_test.go
16 surfaceRequests:23 surfaceRequests:
17 config:24 config:
18 dir: .25 dir: .
M export_test.go +6 -0
@@ -33,3 +33,9 @@ func (s *Surface) ResetFrame() { s.frameReady = true }
3333
3434 // UseWireRequests restores the full request path.
3535 func (s *Surface) UseWireRequests() { s.req = wireRequests{s: s} }
36+
37+// MarkPresented stands in for a committed Present.
38+func (s *Surface) MarkPresented() { s.presented = true }
39+
40+// DropFrameCallback marks the pending frame callback as answered.
41+func (s *Surface) PendingFrame() bool { return !s.frameReady }
@@ -33,3 +33,9 @@ func (s *Surface) ResetFrame() { s.frameReady = true }
33 33
34 // UseWireRequests restores the full request path.34 // UseWireRequests restores the full request path.
35 func (s *Surface) UseWireRequests() { s.req = wireRequests{s: s} }35 func (s *Surface) UseWireRequests() { s.req = wireRequests{s: s} }
36+
37+// MarkPresented stands in for a committed Present.
38+func (s *Surface) MarkPresented() { s.presented = true }
39+
40+// DropFrameCallback marks the pending frame callback as answered.
41+func (s *Surface) PendingFrame() bool { return !s.frameReady }
M globals.go +0 -1
@@ -51,7 +51,6 @@ func (c *Conn) require[P wl.Proxy](iface string, supported, minimum uint32, newP
5151 return zero, 0, &CapabilityError{Name: iface, Cause: err}
5252 }
5353 if v < minimum {
54- _ = c.wlctx // the proxy stays bound; it is unusable below minimum
5554 var zero P
5655 return zero, 0, &CapabilityError{Name: iface, Cause: fmt.Errorf("requires version %d, negotiated %d", minimum, v)}
5756 }
@@ -51,7 +51,6 @@ func (c *Conn) require[P wl.Proxy](iface string, supported, minimum uint32, newP
51 return zero, 0, &CapabilityError{Name: iface, Cause: err}51 return zero, 0, &CapabilityError{Name: iface, Cause: err}
52 }52 }
53 if v < minimum {53 if v < minimum {
54- _ = c.wlctx // the proxy stays bound; it is unusable below minimum
55 var zero P54 var zero P
56 return zero, 0, &CapabilityError{Name: iface, Cause: fmt.Errorf("requires version %d, negotiated %d", minimum, v)}55 return zero, 0, &CapabilityError{Name: iface, Cause: fmt.Errorf("requires version %d, negotiated %d", minimum, v)}
57 }56 }
A handler_mock_test.go +489 -0
@@ -0,0 +1,489 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package neferclient
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockHandler creates a new instance of MockHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockHandler(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockHandler {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockHandler{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockHandler is an autogenerated mock type for the Handler type
35+type MockHandler struct {
36+ mock.Mock
37+}
38+
39+type MockHandler_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockHandler) EXPECT() *MockHandler_Expecter {
44+ return &MockHandler_Expecter{mock: &_m.Mock}
45+}
46+
47+// Closed provides a mock function for the type MockHandler
48+func (_mock *MockHandler) Closed(id SurfaceID) {
49+ _mock.Called(id)
50+ return
51+}
52+
53+// MockHandler_Closed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Closed'
54+type MockHandler_Closed_Call struct {
55+ *mock.Call
56+}
57+
58+// Closed is a helper method to define mock.On call
59+// - id SurfaceID
60+func (_e *MockHandler_Expecter) Closed(id any) *MockHandler_Closed_Call {
61+ return &MockHandler_Closed_Call{Call: _e.mock.On("Closed", id)}
62+}
63+
64+func (_c *MockHandler_Closed_Call) Run(run func(id SurfaceID)) *MockHandler_Closed_Call {
65+ _c.Call.Run(func(args mock.Arguments) {
66+ var arg0 SurfaceID
67+ if args[0] != nil {
68+ arg0 = args[0].(SurfaceID)
69+ }
70+ run(
71+ arg0,
72+ )
73+ })
74+ return _c
75+}
76+
77+func (_c *MockHandler_Closed_Call) Return() *MockHandler_Closed_Call {
78+ _c.Call.Return()
79+ return _c
80+}
81+
82+func (_c *MockHandler_Closed_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_Closed_Call {
83+ _c.Run(run)
84+ return _c
85+}
86+
87+// Configure provides a mock function for the type MockHandler
88+func (_mock *MockHandler) Configure(id SurfaceID, width int32, height int32) {
89+ _mock.Called(id, width, height)
90+ return
91+}
92+
93+// MockHandler_Configure_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Configure'
94+type MockHandler_Configure_Call struct {
95+ *mock.Call
96+}
97+
98+// Configure is a helper method to define mock.On call
99+// - id SurfaceID
100+// - width int32
101+// - height int32
102+func (_e *MockHandler_Expecter) Configure(id any, width any, height any) *MockHandler_Configure_Call {
103+ return &MockHandler_Configure_Call{Call: _e.mock.On("Configure", id, width, height)}
104+}
105+
106+func (_c *MockHandler_Configure_Call) Run(run func(id SurfaceID, width int32, height int32)) *MockHandler_Configure_Call {
107+ _c.Call.Run(func(args mock.Arguments) {
108+ var arg0 SurfaceID
109+ if args[0] != nil {
110+ arg0 = args[0].(SurfaceID)
111+ }
112+ var arg1 int32
113+ if args[1] != nil {
114+ arg1 = args[1].(int32)
115+ }
116+ var arg2 int32
117+ if args[2] != nil {
118+ arg2 = args[2].(int32)
119+ }
120+ run(
121+ arg0,
122+ arg1,
123+ arg2,
124+ )
125+ })
126+ return _c
127+}
128+
129+func (_c *MockHandler_Configure_Call) Return() *MockHandler_Configure_Call {
130+ _c.Call.Return()
131+ return _c
132+}
133+
134+func (_c *MockHandler_Configure_Call) RunAndReturn(run func(id SurfaceID, width int32, height int32)) *MockHandler_Configure_Call {
135+ _c.Run(run)
136+ return _c
137+}
138+
139+// Error provides a mock function for the type MockHandler
140+func (_mock *MockHandler) Error(err error) {
141+ _mock.Called(err)
142+ return
143+}
144+
145+// MockHandler_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error'
146+type MockHandler_Error_Call struct {
147+ *mock.Call
148+}
149+
150+// Error is a helper method to define mock.On call
151+// - err error
152+func (_e *MockHandler_Expecter) Error(err any) *MockHandler_Error_Call {
153+ return &MockHandler_Error_Call{Call: _e.mock.On("Error", err)}
154+}
155+
156+func (_c *MockHandler_Error_Call) Run(run func(err error)) *MockHandler_Error_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 error
159+ if args[0] != nil {
160+ arg0 = args[0].(error)
161+ }
162+ run(
163+ arg0,
164+ )
165+ })
166+ return _c
167+}
168+
169+func (_c *MockHandler_Error_Call) Return() *MockHandler_Error_Call {
170+ _c.Call.Return()
171+ return _c
172+}
173+
174+func (_c *MockHandler_Error_Call) RunAndReturn(run func(err error)) *MockHandler_Error_Call {
175+ _c.Run(run)
176+ return _c
177+}
178+
179+// FDReady provides a mock function for the type MockHandler
180+func (_mock *MockHandler) FDReady(id uint64) {
181+ _mock.Called(id)
182+ return
183+}
184+
185+// MockHandler_FDReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FDReady'
186+type MockHandler_FDReady_Call struct {
187+ *mock.Call
188+}
189+
190+// FDReady is a helper method to define mock.On call
191+// - id uint64
192+func (_e *MockHandler_Expecter) FDReady(id any) *MockHandler_FDReady_Call {
193+ return &MockHandler_FDReady_Call{Call: _e.mock.On("FDReady", id)}
194+}
195+
196+func (_c *MockHandler_FDReady_Call) Run(run func(id uint64)) *MockHandler_FDReady_Call {
197+ _c.Call.Run(func(args mock.Arguments) {
198+ var arg0 uint64
199+ if args[0] != nil {
200+ arg0 = args[0].(uint64)
201+ }
202+ run(
203+ arg0,
204+ )
205+ })
206+ return _c
207+}
208+
209+func (_c *MockHandler_FDReady_Call) Return() *MockHandler_FDReady_Call {
210+ _c.Call.Return()
211+ return _c
212+}
213+
214+func (_c *MockHandler_FDReady_Call) RunAndReturn(run func(id uint64)) *MockHandler_FDReady_Call {
215+ _c.Run(run)
216+ return _c
217+}
218+
219+// FeedbackDone provides a mock function for the type MockHandler
220+func (_mock *MockHandler) FeedbackDone(id SurfaceID) {
221+ _mock.Called(id)
222+ return
223+}
224+
225+// MockHandler_FeedbackDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbackDone'
226+type MockHandler_FeedbackDone_Call struct {
227+ *mock.Call
228+}
229+
230+// FeedbackDone is a helper method to define mock.On call
231+// - id SurfaceID
232+func (_e *MockHandler_Expecter) FeedbackDone(id any) *MockHandler_FeedbackDone_Call {
233+ return &MockHandler_FeedbackDone_Call{Call: _e.mock.On("FeedbackDone", id)}
234+}
235+
236+func (_c *MockHandler_FeedbackDone_Call) Run(run func(id SurfaceID)) *MockHandler_FeedbackDone_Call {
237+ _c.Call.Run(func(args mock.Arguments) {
238+ var arg0 SurfaceID
239+ if args[0] != nil {
240+ arg0 = args[0].(SurfaceID)
241+ }
242+ run(
243+ arg0,
244+ )
245+ })
246+ return _c
247+}
248+
249+func (_c *MockHandler_FeedbackDone_Call) Return() *MockHandler_FeedbackDone_Call {
250+ _c.Call.Return()
251+ return _c
252+}
253+
254+func (_c *MockHandler_FeedbackDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_FeedbackDone_Call {
255+ _c.Run(run)
256+ return _c
257+}
258+
259+// Frame provides a mock function for the type MockHandler
260+func (_mock *MockHandler) Frame(id SurfaceID) {
261+ _mock.Called(id)
262+ return
263+}
264+
265+// MockHandler_Frame_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Frame'
266+type MockHandler_Frame_Call struct {
267+ *mock.Call
268+}
269+
270+// Frame is a helper method to define mock.On call
271+// - id SurfaceID
272+func (_e *MockHandler_Expecter) Frame(id any) *MockHandler_Frame_Call {
273+ return &MockHandler_Frame_Call{Call: _e.mock.On("Frame", id)}
274+}
275+
276+func (_c *MockHandler_Frame_Call) Run(run func(id SurfaceID)) *MockHandler_Frame_Call {
277+ _c.Call.Run(func(args mock.Arguments) {
278+ var arg0 SurfaceID
279+ if args[0] != nil {
280+ arg0 = args[0].(SurfaceID)
281+ }
282+ run(
283+ arg0,
284+ )
285+ })
286+ return _c
287+}
288+
289+func (_c *MockHandler_Frame_Call) Return() *MockHandler_Frame_Call {
290+ _c.Call.Return()
291+ return _c
292+}
293+
294+func (_c *MockHandler_Frame_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_Frame_Call {
295+ _c.Run(run)
296+ return _c
297+}
298+
299+// LockFinished provides a mock function for the type MockHandler
300+func (_mock *MockHandler) LockFinished() {
301+ _mock.Called()
302+ return
303+}
304+
305+// MockHandler_LockFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LockFinished'
306+type MockHandler_LockFinished_Call struct {
307+ *mock.Call
308+}
309+
310+// LockFinished is a helper method to define mock.On call
311+func (_e *MockHandler_Expecter) LockFinished() *MockHandler_LockFinished_Call {
312+ return &MockHandler_LockFinished_Call{Call: _e.mock.On("LockFinished")}
313+}
314+
315+func (_c *MockHandler_LockFinished_Call) Run(run func()) *MockHandler_LockFinished_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ run()
318+ })
319+ return _c
320+}
321+
322+func (_c *MockHandler_LockFinished_Call) Return() *MockHandler_LockFinished_Call {
323+ _c.Call.Return()
324+ return _c
325+}
326+
327+func (_c *MockHandler_LockFinished_Call) RunAndReturn(run func()) *MockHandler_LockFinished_Call {
328+ _c.Run(run)
329+ return _c
330+}
331+
332+// Locked provides a mock function for the type MockHandler
333+func (_mock *MockHandler) Locked() {
334+ _mock.Called()
335+ return
336+}
337+
338+// MockHandler_Locked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Locked'
339+type MockHandler_Locked_Call struct {
340+ *mock.Call
341+}
342+
343+// Locked is a helper method to define mock.On call
344+func (_e *MockHandler_Expecter) Locked() *MockHandler_Locked_Call {
345+ return &MockHandler_Locked_Call{Call: _e.mock.On("Locked")}
346+}
347+
348+func (_c *MockHandler_Locked_Call) Run(run func()) *MockHandler_Locked_Call {
349+ _c.Call.Run(func(args mock.Arguments) {
350+ run()
351+ })
352+ return _c
353+}
354+
355+func (_c *MockHandler_Locked_Call) Return() *MockHandler_Locked_Call {
356+ _c.Call.Return()
357+ return _c
358+}
359+
360+func (_c *MockHandler_Locked_Call) RunAndReturn(run func()) *MockHandler_Locked_Call {
361+ _c.Run(run)
362+ return _c
363+}
364+
365+// OutputAdded provides a mock function for the type MockHandler
366+func (_mock *MockHandler) OutputAdded(out *Output) {
367+ _mock.Called(out)
368+ return
369+}
370+
371+// MockHandler_OutputAdded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputAdded'
372+type MockHandler_OutputAdded_Call struct {
373+ *mock.Call
374+}
375+
376+// OutputAdded is a helper method to define mock.On call
377+// - out *Output
378+func (_e *MockHandler_Expecter) OutputAdded(out any) *MockHandler_OutputAdded_Call {
379+ return &MockHandler_OutputAdded_Call{Call: _e.mock.On("OutputAdded", out)}
380+}
381+
382+func (_c *MockHandler_OutputAdded_Call) Run(run func(out *Output)) *MockHandler_OutputAdded_Call {
383+ _c.Call.Run(func(args mock.Arguments) {
384+ var arg0 *Output
385+ if args[0] != nil {
386+ arg0 = args[0].(*Output)
387+ }
388+ run(
389+ arg0,
390+ )
391+ })
392+ return _c
393+}
394+
395+func (_c *MockHandler_OutputAdded_Call) Return() *MockHandler_OutputAdded_Call {
396+ _c.Call.Return()
397+ return _c
398+}
399+
400+func (_c *MockHandler_OutputAdded_Call) RunAndReturn(run func(out *Output)) *MockHandler_OutputAdded_Call {
401+ _c.Run(run)
402+ return _c
403+}
404+
405+// OutputRemoved provides a mock function for the type MockHandler
406+func (_mock *MockHandler) OutputRemoved(global uint32) {
407+ _mock.Called(global)
408+ return
409+}
410+
411+// MockHandler_OutputRemoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputRemoved'
412+type MockHandler_OutputRemoved_Call struct {
413+ *mock.Call
414+}
415+
416+// OutputRemoved is a helper method to define mock.On call
417+// - global uint32
418+func (_e *MockHandler_Expecter) OutputRemoved(global any) *MockHandler_OutputRemoved_Call {
419+ return &MockHandler_OutputRemoved_Call{Call: _e.mock.On("OutputRemoved", global)}
420+}
421+
422+func (_c *MockHandler_OutputRemoved_Call) Run(run func(global uint32)) *MockHandler_OutputRemoved_Call {
423+ _c.Call.Run(func(args mock.Arguments) {
424+ var arg0 uint32
425+ if args[0] != nil {
426+ arg0 = args[0].(uint32)
427+ }
428+ run(
429+ arg0,
430+ )
431+ })
432+ return _c
433+}
434+
435+func (_c *MockHandler_OutputRemoved_Call) Return() *MockHandler_OutputRemoved_Call {
436+ _c.Call.Return()
437+ return _c
438+}
439+
440+func (_c *MockHandler_OutputRemoved_Call) RunAndReturn(run func(global uint32)) *MockHandler_OutputRemoved_Call {
441+ _c.Run(run)
442+ return _c
443+}
444+
445+// Scale provides a mock function for the type MockHandler
446+func (_mock *MockHandler) Scale(id SurfaceID, scale float64) {
447+ _mock.Called(id, scale)
448+ return
449+}
450+
451+// MockHandler_Scale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Scale'
452+type MockHandler_Scale_Call struct {
453+ *mock.Call
454+}
455+
456+// Scale is a helper method to define mock.On call
457+// - id SurfaceID
458+// - scale float64
459+func (_e *MockHandler_Expecter) Scale(id any, scale any) *MockHandler_Scale_Call {
460+ return &MockHandler_Scale_Call{Call: _e.mock.On("Scale", id, scale)}
461+}
462+
463+func (_c *MockHandler_Scale_Call) Run(run func(id SurfaceID, scale float64)) *MockHandler_Scale_Call {
464+ _c.Call.Run(func(args mock.Arguments) {
465+ var arg0 SurfaceID
466+ if args[0] != nil {
467+ arg0 = args[0].(SurfaceID)
468+ }
469+ var arg1 float64
470+ if args[1] != nil {
471+ arg1 = args[1].(float64)
472+ }
473+ run(
474+ arg0,
475+ arg1,
476+ )
477+ })
478+ return _c
479+}
480+
481+func (_c *MockHandler_Scale_Call) Return() *MockHandler_Scale_Call {
482+ _c.Call.Return()
483+ return _c
484+}
485+
486+func (_c *MockHandler_Scale_Call) RunAndReturn(run func(id SurfaceID, scale float64)) *MockHandler_Scale_Call {
487+ _c.Run(run)
488+ return _c
489+}
@@ -0,0 +1,489 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package neferclient
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockHandler creates a new instance of MockHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockHandler(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockHandler {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockHandler{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockHandler is an autogenerated mock type for the Handler type
35+type MockHandler struct {
36+ mock.Mock
37+}
38+
39+type MockHandler_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockHandler) EXPECT() *MockHandler_Expecter {
44+ return &MockHandler_Expecter{mock: &_m.Mock}
45+}
46+
47+// Closed provides a mock function for the type MockHandler
48+func (_mock *MockHandler) Closed(id SurfaceID) {
49+ _mock.Called(id)
50+ return
51+}
52+
53+// MockHandler_Closed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Closed'
54+type MockHandler_Closed_Call struct {
55+ *mock.Call
56+}
57+
58+// Closed is a helper method to define mock.On call
59+// - id SurfaceID
60+func (_e *MockHandler_Expecter) Closed(id any) *MockHandler_Closed_Call {
61+ return &MockHandler_Closed_Call{Call: _e.mock.On("Closed", id)}
62+}
63+
64+func (_c *MockHandler_Closed_Call) Run(run func(id SurfaceID)) *MockHandler_Closed_Call {
65+ _c.Call.Run(func(args mock.Arguments) {
66+ var arg0 SurfaceID
67+ if args[0] != nil {
68+ arg0 = args[0].(SurfaceID)
69+ }
70+ run(
71+ arg0,
72+ )
73+ })
74+ return _c
75+}
76+
77+func (_c *MockHandler_Closed_Call) Return() *MockHandler_Closed_Call {
78+ _c.Call.Return()
79+ return _c
80+}
81+
82+func (_c *MockHandler_Closed_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_Closed_Call {
83+ _c.Run(run)
84+ return _c
85+}
86+
87+// Configure provides a mock function for the type MockHandler
88+func (_mock *MockHandler) Configure(id SurfaceID, width int32, height int32) {
89+ _mock.Called(id, width, height)
90+ return
91+}
92+
93+// MockHandler_Configure_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Configure'
94+type MockHandler_Configure_Call struct {
95+ *mock.Call
96+}
97+
98+// Configure is a helper method to define mock.On call
99+// - id SurfaceID
100+// - width int32
101+// - height int32
102+func (_e *MockHandler_Expecter) Configure(id any, width any, height any) *MockHandler_Configure_Call {
103+ return &MockHandler_Configure_Call{Call: _e.mock.On("Configure", id, width, height)}
104+}
105+
106+func (_c *MockHandler_Configure_Call) Run(run func(id SurfaceID, width int32, height int32)) *MockHandler_Configure_Call {
107+ _c.Call.Run(func(args mock.Arguments) {
108+ var arg0 SurfaceID
109+ if args[0] != nil {
110+ arg0 = args[0].(SurfaceID)
111+ }
112+ var arg1 int32
113+ if args[1] != nil {
114+ arg1 = args[1].(int32)
115+ }
116+ var arg2 int32
117+ if args[2] != nil {
118+ arg2 = args[2].(int32)
119+ }
120+ run(
121+ arg0,
122+ arg1,
123+ arg2,
124+ )
125+ })
126+ return _c
127+}
128+
129+func (_c *MockHandler_Configure_Call) Return() *MockHandler_Configure_Call {
130+ _c.Call.Return()
131+ return _c
132+}
133+
134+func (_c *MockHandler_Configure_Call) RunAndReturn(run func(id SurfaceID, width int32, height int32)) *MockHandler_Configure_Call {
135+ _c.Run(run)
136+ return _c
137+}
138+
139+// Error provides a mock function for the type MockHandler
140+func (_mock *MockHandler) Error(err error) {
141+ _mock.Called(err)
142+ return
143+}
144+
145+// MockHandler_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error'
146+type MockHandler_Error_Call struct {
147+ *mock.Call
148+}
149+
150+// Error is a helper method to define mock.On call
151+// - err error
152+func (_e *MockHandler_Expecter) Error(err any) *MockHandler_Error_Call {
153+ return &MockHandler_Error_Call{Call: _e.mock.On("Error", err)}
154+}
155+
156+func (_c *MockHandler_Error_Call) Run(run func(err error)) *MockHandler_Error_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 error
159+ if args[0] != nil {
160+ arg0 = args[0].(error)
161+ }
162+ run(
163+ arg0,
164+ )
165+ })
166+ return _c
167+}
168+
169+func (_c *MockHandler_Error_Call) Return() *MockHandler_Error_Call {
170+ _c.Call.Return()
171+ return _c
172+}
173+
174+func (_c *MockHandler_Error_Call) RunAndReturn(run func(err error)) *MockHandler_Error_Call {
175+ _c.Run(run)
176+ return _c
177+}
178+
179+// FDReady provides a mock function for the type MockHandler
180+func (_mock *MockHandler) FDReady(id uint64) {
181+ _mock.Called(id)
182+ return
183+}
184+
185+// MockHandler_FDReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FDReady'
186+type MockHandler_FDReady_Call struct {
187+ *mock.Call
188+}
189+
190+// FDReady is a helper method to define mock.On call
191+// - id uint64
192+func (_e *MockHandler_Expecter) FDReady(id any) *MockHandler_FDReady_Call {
193+ return &MockHandler_FDReady_Call{Call: _e.mock.On("FDReady", id)}
194+}
195+
196+func (_c *MockHandler_FDReady_Call) Run(run func(id uint64)) *MockHandler_FDReady_Call {
197+ _c.Call.Run(func(args mock.Arguments) {
198+ var arg0 uint64
199+ if args[0] != nil {
200+ arg0 = args[0].(uint64)
201+ }
202+ run(
203+ arg0,
204+ )
205+ })
206+ return _c
207+}
208+
209+func (_c *MockHandler_FDReady_Call) Return() *MockHandler_FDReady_Call {
210+ _c.Call.Return()
211+ return _c
212+}
213+
214+func (_c *MockHandler_FDReady_Call) RunAndReturn(run func(id uint64)) *MockHandler_FDReady_Call {
215+ _c.Run(run)
216+ return _c
217+}
218+
219+// FeedbackDone provides a mock function for the type MockHandler
220+func (_mock *MockHandler) FeedbackDone(id SurfaceID) {
221+ _mock.Called(id)
222+ return
223+}
224+
225+// MockHandler_FeedbackDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbackDone'
226+type MockHandler_FeedbackDone_Call struct {
227+ *mock.Call
228+}
229+
230+// FeedbackDone is a helper method to define mock.On call
231+// - id SurfaceID
232+func (_e *MockHandler_Expecter) FeedbackDone(id any) *MockHandler_FeedbackDone_Call {
233+ return &MockHandler_FeedbackDone_Call{Call: _e.mock.On("FeedbackDone", id)}
234+}
235+
236+func (_c *MockHandler_FeedbackDone_Call) Run(run func(id SurfaceID)) *MockHandler_FeedbackDone_Call {
237+ _c.Call.Run(func(args mock.Arguments) {
238+ var arg0 SurfaceID
239+ if args[0] != nil {
240+ arg0 = args[0].(SurfaceID)
241+ }
242+ run(
243+ arg0,
244+ )
245+ })
246+ return _c
247+}
248+
249+func (_c *MockHandler_FeedbackDone_Call) Return() *MockHandler_FeedbackDone_Call {
250+ _c.Call.Return()
251+ return _c
252+}
253+
254+func (_c *MockHandler_FeedbackDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_FeedbackDone_Call {
255+ _c.Run(run)
256+ return _c
257+}
258+
259+// Frame provides a mock function for the type MockHandler
260+func (_mock *MockHandler) Frame(id SurfaceID) {
261+ _mock.Called(id)
262+ return
263+}
264+
265+// MockHandler_Frame_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Frame'
266+type MockHandler_Frame_Call struct {
267+ *mock.Call
268+}
269+
270+// Frame is a helper method to define mock.On call
271+// - id SurfaceID
272+func (_e *MockHandler_Expecter) Frame(id any) *MockHandler_Frame_Call {
273+ return &MockHandler_Frame_Call{Call: _e.mock.On("Frame", id)}
274+}
275+
276+func (_c *MockHandler_Frame_Call) Run(run func(id SurfaceID)) *MockHandler_Frame_Call {
277+ _c.Call.Run(func(args mock.Arguments) {
278+ var arg0 SurfaceID
279+ if args[0] != nil {
280+ arg0 = args[0].(SurfaceID)
281+ }
282+ run(
283+ arg0,
284+ )
285+ })
286+ return _c
287+}
288+
289+func (_c *MockHandler_Frame_Call) Return() *MockHandler_Frame_Call {
290+ _c.Call.Return()
291+ return _c
292+}
293+
294+func (_c *MockHandler_Frame_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_Frame_Call {
295+ _c.Run(run)
296+ return _c
297+}
298+
299+// LockFinished provides a mock function for the type MockHandler
300+func (_mock *MockHandler) LockFinished() {
301+ _mock.Called()
302+ return
303+}
304+
305+// MockHandler_LockFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LockFinished'
306+type MockHandler_LockFinished_Call struct {
307+ *mock.Call
308+}
309+
310+// LockFinished is a helper method to define mock.On call
311+func (_e *MockHandler_Expecter) LockFinished() *MockHandler_LockFinished_Call {
312+ return &MockHandler_LockFinished_Call{Call: _e.mock.On("LockFinished")}
313+}
314+
315+func (_c *MockHandler_LockFinished_Call) Run(run func()) *MockHandler_LockFinished_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ run()
318+ })
319+ return _c
320+}
321+
322+func (_c *MockHandler_LockFinished_Call) Return() *MockHandler_LockFinished_Call {
323+ _c.Call.Return()
324+ return _c
325+}
326+
327+func (_c *MockHandler_LockFinished_Call) RunAndReturn(run func()) *MockHandler_LockFinished_Call {
328+ _c.Run(run)
329+ return _c
330+}
331+
332+// Locked provides a mock function for the type MockHandler
333+func (_mock *MockHandler) Locked() {
334+ _mock.Called()
335+ return
336+}
337+
338+// MockHandler_Locked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Locked'
339+type MockHandler_Locked_Call struct {
340+ *mock.Call
341+}
342+
343+// Locked is a helper method to define mock.On call
344+func (_e *MockHandler_Expecter) Locked() *MockHandler_Locked_Call {
345+ return &MockHandler_Locked_Call{Call: _e.mock.On("Locked")}
346+}
347+
348+func (_c *MockHandler_Locked_Call) Run(run func()) *MockHandler_Locked_Call {
349+ _c.Call.Run(func(args mock.Arguments) {
350+ run()
351+ })
352+ return _c
353+}
354+
355+func (_c *MockHandler_Locked_Call) Return() *MockHandler_Locked_Call {
356+ _c.Call.Return()
357+ return _c
358+}
359+
360+func (_c *MockHandler_Locked_Call) RunAndReturn(run func()) *MockHandler_Locked_Call {
361+ _c.Run(run)
362+ return _c
363+}
364+
365+// OutputAdded provides a mock function for the type MockHandler
366+func (_mock *MockHandler) OutputAdded(out *Output) {
367+ _mock.Called(out)
368+ return
369+}
370+
371+// MockHandler_OutputAdded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputAdded'
372+type MockHandler_OutputAdded_Call struct {
373+ *mock.Call
374+}
375+
376+// OutputAdded is a helper method to define mock.On call
377+// - out *Output
378+func (_e *MockHandler_Expecter) OutputAdded(out any) *MockHandler_OutputAdded_Call {
379+ return &MockHandler_OutputAdded_Call{Call: _e.mock.On("OutputAdded", out)}
380+}
381+
382+func (_c *MockHandler_OutputAdded_Call) Run(run func(out *Output)) *MockHandler_OutputAdded_Call {
383+ _c.Call.Run(func(args mock.Arguments) {
384+ var arg0 *Output
385+ if args[0] != nil {
386+ arg0 = args[0].(*Output)
387+ }
388+ run(
389+ arg0,
390+ )
391+ })
392+ return _c
393+}
394+
395+func (_c *MockHandler_OutputAdded_Call) Return() *MockHandler_OutputAdded_Call {
396+ _c.Call.Return()
397+ return _c
398+}
399+
400+func (_c *MockHandler_OutputAdded_Call) RunAndReturn(run func(out *Output)) *MockHandler_OutputAdded_Call {
401+ _c.Run(run)
402+ return _c
403+}
404+
405+// OutputRemoved provides a mock function for the type MockHandler
406+func (_mock *MockHandler) OutputRemoved(global uint32) {
407+ _mock.Called(global)
408+ return
409+}
410+
411+// MockHandler_OutputRemoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OutputRemoved'
412+type MockHandler_OutputRemoved_Call struct {
413+ *mock.Call
414+}
415+
416+// OutputRemoved is a helper method to define mock.On call
417+// - global uint32
418+func (_e *MockHandler_Expecter) OutputRemoved(global any) *MockHandler_OutputRemoved_Call {
419+ return &MockHandler_OutputRemoved_Call{Call: _e.mock.On("OutputRemoved", global)}
420+}
421+
422+func (_c *MockHandler_OutputRemoved_Call) Run(run func(global uint32)) *MockHandler_OutputRemoved_Call {
423+ _c.Call.Run(func(args mock.Arguments) {
424+ var arg0 uint32
425+ if args[0] != nil {
426+ arg0 = args[0].(uint32)
427+ }
428+ run(
429+ arg0,
430+ )
431+ })
432+ return _c
433+}
434+
435+func (_c *MockHandler_OutputRemoved_Call) Return() *MockHandler_OutputRemoved_Call {
436+ _c.Call.Return()
437+ return _c
438+}
439+
440+func (_c *MockHandler_OutputRemoved_Call) RunAndReturn(run func(global uint32)) *MockHandler_OutputRemoved_Call {
441+ _c.Run(run)
442+ return _c
443+}
444+
445+// Scale provides a mock function for the type MockHandler
446+func (_mock *MockHandler) Scale(id SurfaceID, scale float64) {
447+ _mock.Called(id, scale)
448+ return
449+}
450+
451+// MockHandler_Scale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Scale'
452+type MockHandler_Scale_Call struct {
453+ *mock.Call
454+}
455+
456+// Scale is a helper method to define mock.On call
457+// - id SurfaceID
458+// - scale float64
459+func (_e *MockHandler_Expecter) Scale(id any, scale any) *MockHandler_Scale_Call {
460+ return &MockHandler_Scale_Call{Call: _e.mock.On("Scale", id, scale)}
461+}
462+
463+func (_c *MockHandler_Scale_Call) Run(run func(id SurfaceID, scale float64)) *MockHandler_Scale_Call {
464+ _c.Call.Run(func(args mock.Arguments) {
465+ var arg0 SurfaceID
466+ if args[0] != nil {
467+ arg0 = args[0].(SurfaceID)
468+ }
469+ var arg1 float64
470+ if args[1] != nil {
471+ arg1 = args[1].(float64)
472+ }
473+ run(
474+ arg0,
475+ arg1,
476+ )
477+ })
478+ return _c
479+}
480+
481+func (_c *MockHandler_Scale_Call) Return() *MockHandler_Scale_Call {
482+ _c.Call.Return()
483+ return _c
484+}
485+
486+func (_c *MockHandler_Scale_Call) RunAndReturn(run func(id SurfaceID, scale float64)) *MockHandler_Scale_Call {
487+ _c.Run(run)
488+ return _c
489+}
M lock.go +20 -5
@@ -77,6 +77,11 @@ func (l *Lock) NewSurface(output uint32) (*Surface, error) {
7777 if e == nil || !e.done {
7878 return nil, fmt.Errorf("neferclient: lock output %d unavailable", output)
7979 }
80+ for _, other := range c.surfaces {
81+ if other.lock == l && other.output == output {
82+ return nil, fmt.Errorf("neferclient: output %d already has a lock surface", output)
83+ }
84+ }
8085 c.pause()
8186 defer c.resume()
8287 s, err := c.newSurface(roleLock, 0, 0)
@@ -92,10 +97,10 @@ func (l *Lock) NewSurface(output uint32) (*Surface, error) {
9297 if s.lockSurf, err = l.obj.GetLockSurface(s.surf, e.proxy); err != nil {
9398 return nil, err
9499 }
95- s.lock = l
100+ s.lock, s.output = l, output
96101 q, sid := c.q, uint32(s.id)
97102 s.lockSurf.OnConfigure(func(serial, w, h uint32) {
98- ev := event{kind: evLockConfigure, id: sid, serial: serial, a: int32(w), b: int32(h)}
103+ ev := configureEvent(evLockConfigure, sid, serial, w, h)
99104 q.post(&ev)
100105 })
101106 // ext-session-lock forbids the initial empty commit: the configure comes
@@ -125,13 +130,23 @@ func (l *Lock) Unlock() error {
125130 return l.c.Roundtrip()
126131 }
127132
128-// Close destroys a lock that never reached Locked (refused or abandoned). It
129-// is an error after Locked: a locked session is only ended by Unlock, and
130-// leaving the connection closed keeps the session locked.
133+// Close destroys a lock that never reached Locked (refused or abandoned). A
134+// locked session is only ended by Unlock, so Close fails once Locked was
135+// applied. Because the compositor may have sent locked before it processes the
136+// destroy request (which would then be a protocol error), Close first
137+// roundtrips and looks through the queued events: if a locked event for this
138+// lock is already queued it is treated as applied (Handler.Locked is still
139+// delivered by the next Dispatch) and Close fails with the same error.
131140 func (l *Lock) Close() error {
132141 if l.done {
133142 return nil
134143 }
144+ if !l.locked && !l.finished {
145+ if err := l.c.Roundtrip(); err != nil {
146+ return err
147+ }
148+ l.locked = l.locked || l.c.q.hasLocked(l.gen)
149+ }
135150 if l.locked && !l.finished {
136151 return errors.New("neferclient: lock is locked: use Unlock")
137152 }
@@ -77,6 +77,11 @@ func (l *Lock) NewSurface(output uint32) (*Surface, error) {
77 if e == nil || !e.done {77 if e == nil || !e.done {
78 return nil, fmt.Errorf("neferclient: lock output %d unavailable", output)78 return nil, fmt.Errorf("neferclient: lock output %d unavailable", output)
79 }79 }
80+ for _, other := range c.surfaces {
81+ if other.lock == l && other.output == output {
82+ return nil, fmt.Errorf("neferclient: output %d already has a lock surface", output)
83+ }
84+ }
80 c.pause()85 c.pause()
81 defer c.resume()86 defer c.resume()
82 s, err := c.newSurface(roleLock, 0, 0)87 s, err := c.newSurface(roleLock, 0, 0)
@@ -92,10 +97,10 @@ func (l *Lock) NewSurface(output uint32) (*Surface, error) {
92 if s.lockSurf, err = l.obj.GetLockSurface(s.surf, e.proxy); err != nil {97 if s.lockSurf, err = l.obj.GetLockSurface(s.surf, e.proxy); err != nil {
93 return nil, err98 return nil, err
94 }99 }
95- s.lock = l100+ s.lock, s.output = l, output
96 q, sid := c.q, uint32(s.id)101 q, sid := c.q, uint32(s.id)
97 s.lockSurf.OnConfigure(func(serial, w, h uint32) {102 s.lockSurf.OnConfigure(func(serial, w, h uint32) {
98- ev := event{kind: evLockConfigure, id: sid, serial: serial, a: int32(w), b: int32(h)}103+ ev := configureEvent(evLockConfigure, sid, serial, w, h)
99 q.post(&ev)104 q.post(&ev)
100 })105 })
101 // ext-session-lock forbids the initial empty commit: the configure comes106 // ext-session-lock forbids the initial empty commit: the configure comes
@@ -125,13 +130,23 @@ func (l *Lock) Unlock() error {
125 return l.c.Roundtrip()130 return l.c.Roundtrip()
126 }131 }
127 132
128-// Close destroys a lock that never reached Locked (refused or abandoned). It133+// Close destroys a lock that never reached Locked (refused or abandoned). A
129-// is an error after Locked: a locked session is only ended by Unlock, and134+// locked session is only ended by Unlock, so Close fails once Locked was
130-// leaving the connection closed keeps the session locked.135+// applied. Because the compositor may have sent locked before it processes the
136+// destroy request (which would then be a protocol error), Close first
137+// roundtrips and looks through the queued events: if a locked event for this
138+// lock is already queued it is treated as applied (Handler.Locked is still
139+// delivered by the next Dispatch) and Close fails with the same error.
131 func (l *Lock) Close() error {140 func (l *Lock) Close() error {
132 if l.done {141 if l.done {
133 return nil142 return nil
134 }143 }
144+ if !l.locked && !l.finished {
145+ if err := l.c.Roundtrip(); err != nil {
146+ return err
147+ }
148+ l.locked = l.locked || l.c.q.hasLocked(l.gen)
149+ }
135 if l.locked && !l.finished {150 if l.locked && !l.finished {
136 return errors.New("neferclient: lock is locked: use Unlock")151 return errors.New("neferclient: lock is locked: use Unlock")
137 }152 }
M present.go +31 -10
@@ -4,10 +4,17 @@ import (
44 "errors"
55 "fmt"
66
7+ "github.com/bnema/go-wayland-bindings/client/wayland"
78 "github.com/bnema/wlturbo/wl"
89 "golang.org/x/sys/unix"
910 )
1011
12+// importedBuffer is a wl_buffer with the size it was imported at.
13+type importedBuffer struct {
14+ buf *wayland.Buffer
15+ w, h int32
16+}
17+
1118 // Plane is one DMA-BUF plane of a [Buffer]. FD stays owned by the caller.
1219 type Plane struct {
1320 FD int
@@ -38,7 +45,7 @@ type Present struct {
3845
3946 // ImportBuffer creates a wl_buffer from b under the caller's id. The plane
4047 // descriptors are duplicated internally; the caller keeps ownership of its own.
41-func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
48+func (s *Surface) ImportBuffer(id uint64, b *Buffer) (err error) {
4249 if s.closed {
4350 return ErrClosed
4451 }
@@ -48,11 +55,11 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
4855 if b == nil || b.Width <= 0 || b.Height <= 0 || b.PlaneCount < 1 || b.PlaneCount > len(b.Planes) {
4956 return errors.New("neferclient: invalid buffer description")
5057 }
51- var dups [4]int
58+ dups := [4]int{-1, -1, -1, -1}
5259 sent := 0
5360 defer func() {
5461 for _, fd := range dups[sent:b.PlaneCount] { // not consumed by a request
55- if fd > 0 {
62+ if fd >= 0 {
5663 _ = unix.Close(fd)
5764 }
5865 }
@@ -68,7 +75,7 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
6875 if err != nil {
6976 return err
7077 }
71- defer params.Destroy()
78+ defer func() { err = errors.Join(err, params.Destroy()) }()
7279 hi, lo := uint32(b.Modifier>>32), uint32(b.Modifier)
7380 for i := range b.PlaneCount {
7481 // On success the request closes the duplicate; on failure it stays ours.
@@ -81,7 +88,7 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
8188 if err != nil {
8289 return err
8390 }
84- s.buffers[id] = buf
91+ s.buffers[id] = importedBuffer{buf: buf, w: b.Width, h: b.Height}
8592 return nil
8693 }
8794
@@ -93,7 +100,7 @@ func (s *Surface) DestroyBuffer(id uint64) error {
93100 return fmt.Errorf("neferclient: unknown buffer %d", id)
94101 }
95102 delete(s.buffers, id)
96- return buf.Destroy()
103+ return buf.buf.Destroy()
97104 }
98105
99106 // ImportTimeline imports a DRM syncobj timeline. fd is duplicated internally.
@@ -124,6 +131,9 @@ func (s *Surface) Present(p *Present) error {
124131 if s.closed {
125132 return ErrClosed
126133 }
134+ if p == nil {
135+ return errors.New("neferclient: nil present")
136+ }
127137 if !s.configured {
128138 return errors.New("neferclient: present before configure")
129139 }
@@ -133,9 +143,13 @@ func (s *Surface) Present(p *Present) error {
133143 if !s.frameReady {
134144 return errors.New("neferclient: frame callback pending")
135145 }
136- if _, ok := s.buffers[p.Buffer]; !ok {
146+ buf, ok := s.buffers[p.Buffer]
147+ if !ok {
137148 return fmt.Errorf("neferclient: unknown buffer %d", p.Buffer)
138149 }
150+ if p.AcquireTimeline == p.ReleaseTimeline && p.AcquirePoint >= p.ReleasePoint {
151+ return fmt.Errorf("neferclient: acquire point %d must precede release point %d on one timeline", p.AcquirePoint, p.ReleasePoint)
152+ }
139153 for _, id := range [...]uint64{p.AcquireTimeline, p.ReleaseTimeline} {
140154 if _, ok := s.timelines[id]; !ok {
141155 return fmt.Errorf("neferclient: unknown timeline %d", id)
@@ -145,6 +159,11 @@ func (s *Surface) Present(p *Present) error {
145159 if err != nil {
146160 return err
147161 }
162+ // Without a viewport (and always on lock surfaces) the compositor
163+ // requires the buffer to match the surface exactly.
164+ if (s.viewport == nil || s.role == roleLock) && (buf.w != pw || buf.h != ph) {
165+ return fmt.Errorf("neferclient: buffer %dx%d does not match surface %dx%d", buf.w, buf.h, pw, ph)
166+ }
148167 r := s.req
149168 if s.viewport != nil {
150169 if s.sentDestW != s.width || s.sentDestH != s.height {
@@ -153,7 +172,7 @@ func (s *Surface) Present(p *Present) error {
153172 }
154173 s.sentDestW, s.sentDestH = s.width, s.height
155174 }
156- } else if s.scale > 1 && s.sentBufScale != int32(s.scale) {
175+ } else if s.sentBufScale != int32(s.scale) {
157176 if err = r.setBufferScale(int32(s.scale)); err != nil {
158177 return err
159178 }
@@ -199,7 +218,7 @@ func (s *Surface) Present(p *Present) error {
199218 if err = r.commit(); err != nil {
200219 return err
201220 }
202- s.frameReady = false
221+ s.frameReady, s.presented = false, true
203222 return nil
204223 }
205224
@@ -251,7 +270,9 @@ func (r wireRequests) setRelease(timeline, point uint64) error {
251270 return r.s.sync.SetReleasePoint(r.s.timelines[timeline], uint32(point>>32), uint32(point))
252271 }
253272
254-func (r wireRequests) attach(buffer uint64) error { return r.s.surf.Attach(r.s.buffers[buffer], 0, 0) }
273+func (r wireRequests) attach(buffer uint64) error {
274+ return r.s.surf.Attach(r.s.buffers[buffer].buf, 0, 0)
275+}
255276
256277 func (r wireRequests) damageBuffer(d Rect) error {
257278 return r.s.surf.DamageBuffer(d.X, d.Y, d.Width, d.Height)
@@ -4,10 +4,17 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/go-wayland-bindings/client/wayland"
7 "github.com/bnema/wlturbo/wl"8 "github.com/bnema/wlturbo/wl"
8 "golang.org/x/sys/unix"9 "golang.org/x/sys/unix"
9 )10 )
10 11
12+// importedBuffer is a wl_buffer with the size it was imported at.
13+type importedBuffer struct {
14+ buf *wayland.Buffer
15+ w, h int32
16+}
17+
11 // Plane is one DMA-BUF plane of a [Buffer]. FD stays owned by the caller.18 // Plane is one DMA-BUF plane of a [Buffer]. FD stays owned by the caller.
12 type Plane struct {19 type Plane struct {
13 FD int20 FD int
@@ -38,7 +45,7 @@ type Present struct {
38 45
39 // ImportBuffer creates a wl_buffer from b under the caller's id. The plane46 // ImportBuffer creates a wl_buffer from b under the caller's id. The plane
40 // descriptors are duplicated internally; the caller keeps ownership of its own.47 // descriptors are duplicated internally; the caller keeps ownership of its own.
41-func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {48+func (s *Surface) ImportBuffer(id uint64, b *Buffer) (err error) {
42 if s.closed {49 if s.closed {
43 return ErrClosed50 return ErrClosed
44 }51 }
@@ -48,11 +55,11 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
48 if b == nil || b.Width <= 0 || b.Height <= 0 || b.PlaneCount < 1 || b.PlaneCount > len(b.Planes) {55 if b == nil || b.Width <= 0 || b.Height <= 0 || b.PlaneCount < 1 || b.PlaneCount > len(b.Planes) {
49 return errors.New("neferclient: invalid buffer description")56 return errors.New("neferclient: invalid buffer description")
50 }57 }
51- var dups [4]int58+ dups := [4]int{-1, -1, -1, -1}
52 sent := 059 sent := 0
53 defer func() {60 defer func() {
54 for _, fd := range dups[sent:b.PlaneCount] { // not consumed by a request61 for _, fd := range dups[sent:b.PlaneCount] { // not consumed by a request
55- if fd > 0 {62+ if fd >= 0 {
56 _ = unix.Close(fd)63 _ = unix.Close(fd)
57 }64 }
58 }65 }
@@ -68,7 +75,7 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
68 if err != nil {75 if err != nil {
69 return err76 return err
70 }77 }
71- defer params.Destroy()78+ defer func() { err = errors.Join(err, params.Destroy()) }()
72 hi, lo := uint32(b.Modifier>>32), uint32(b.Modifier)79 hi, lo := uint32(b.Modifier>>32), uint32(b.Modifier)
73 for i := range b.PlaneCount {80 for i := range b.PlaneCount {
74 // On success the request closes the duplicate; on failure it stays ours.81 // On success the request closes the duplicate; on failure it stays ours.
@@ -81,7 +88,7 @@ func (s *Surface) ImportBuffer(id uint64, b *Buffer) error {
81 if err != nil {88 if err != nil {
82 return err89 return err
83 }90 }
84- s.buffers[id] = buf91+ s.buffers[id] = importedBuffer{buf: buf, w: b.Width, h: b.Height}
85 return nil92 return nil
86 }93 }
87 94
@@ -93,7 +100,7 @@ func (s *Surface) DestroyBuffer(id uint64) error {
93 return fmt.Errorf("neferclient: unknown buffer %d", id)100 return fmt.Errorf("neferclient: unknown buffer %d", id)
94 }101 }
95 delete(s.buffers, id)102 delete(s.buffers, id)
96- return buf.Destroy()103+ return buf.buf.Destroy()
97 }104 }
98 105
99 // ImportTimeline imports a DRM syncobj timeline. fd is duplicated internally.106 // ImportTimeline imports a DRM syncobj timeline. fd is duplicated internally.
@@ -124,6 +131,9 @@ func (s *Surface) Present(p *Present) error {
124 if s.closed {131 if s.closed {
125 return ErrClosed132 return ErrClosed
126 }133 }
134+ if p == nil {
135+ return errors.New("neferclient: nil present")
136+ }
127 if !s.configured {137 if !s.configured {
128 return errors.New("neferclient: present before configure")138 return errors.New("neferclient: present before configure")
129 }139 }
@@ -133,9 +143,13 @@ func (s *Surface) Present(p *Present) error {
133 if !s.frameReady {143 if !s.frameReady {
134 return errors.New("neferclient: frame callback pending")144 return errors.New("neferclient: frame callback pending")
135 }145 }
136- if _, ok := s.buffers[p.Buffer]; !ok {146+ buf, ok := s.buffers[p.Buffer]
147+ if !ok {
137 return fmt.Errorf("neferclient: unknown buffer %d", p.Buffer)148 return fmt.Errorf("neferclient: unknown buffer %d", p.Buffer)
138 }149 }
150+ if p.AcquireTimeline == p.ReleaseTimeline && p.AcquirePoint >= p.ReleasePoint {
151+ return fmt.Errorf("neferclient: acquire point %d must precede release point %d on one timeline", p.AcquirePoint, p.ReleasePoint)
152+ }
139 for _, id := range [...]uint64{p.AcquireTimeline, p.ReleaseTimeline} {153 for _, id := range [...]uint64{p.AcquireTimeline, p.ReleaseTimeline} {
140 if _, ok := s.timelines[id]; !ok {154 if _, ok := s.timelines[id]; !ok {
141 return fmt.Errorf("neferclient: unknown timeline %d", id)155 return fmt.Errorf("neferclient: unknown timeline %d", id)
@@ -145,6 +159,11 @@ func (s *Surface) Present(p *Present) error {
145 if err != nil {159 if err != nil {
146 return err160 return err
147 }161 }
162+ // Without a viewport (and always on lock surfaces) the compositor
163+ // requires the buffer to match the surface exactly.
164+ if (s.viewport == nil || s.role == roleLock) && (buf.w != pw || buf.h != ph) {
165+ return fmt.Errorf("neferclient: buffer %dx%d does not match surface %dx%d", buf.w, buf.h, pw, ph)
166+ }
148 r := s.req167 r := s.req
149 if s.viewport != nil {168 if s.viewport != nil {
150 if s.sentDestW != s.width || s.sentDestH != s.height {169 if s.sentDestW != s.width || s.sentDestH != s.height {
@@ -153,7 +172,7 @@ func (s *Surface) Present(p *Present) error {
153 }172 }
154 s.sentDestW, s.sentDestH = s.width, s.height173 s.sentDestW, s.sentDestH = s.width, s.height
155 }174 }
156- } else if s.scale > 1 && s.sentBufScale != int32(s.scale) {175+ } else if s.sentBufScale != int32(s.scale) {
157 if err = r.setBufferScale(int32(s.scale)); err != nil {176 if err = r.setBufferScale(int32(s.scale)); err != nil {
158 return err177 return err
159 }178 }
@@ -199,7 +218,7 @@ func (s *Surface) Present(p *Present) error {
199 if err = r.commit(); err != nil {218 if err = r.commit(); err != nil {
200 return err219 return err
201 }220 }
202- s.frameReady = false221+ s.frameReady, s.presented = false, true
203 return nil222 return nil
204 }223 }
205 224
@@ -251,7 +270,9 @@ func (r wireRequests) setRelease(timeline, point uint64) error {
251 return r.s.sync.SetReleasePoint(r.s.timelines[timeline], uint32(point>>32), uint32(point))270 return r.s.sync.SetReleasePoint(r.s.timelines[timeline], uint32(point>>32), uint32(point))
252 }271 }
253 272
254-func (r wireRequests) attach(buffer uint64) error { return r.s.surf.Attach(r.s.buffers[buffer], 0, 0) }273+func (r wireRequests) attach(buffer uint64) error {
274+ return r.s.surf.Attach(r.s.buffers[buffer].buf, 0, 0)
275+}
255 276
256 func (r wireRequests) damageBuffer(d Rect) error {277 func (r wireRequests) damageBuffer(d Rect) error {
257 return r.s.surf.DamageBuffer(d.X, d.Y, d.Width, d.Height)278 return r.s.surf.DamageBuffer(d.X, d.Y, d.Width, d.Height)
M queue.go +18 -0
@@ -280,6 +280,24 @@ func (q *queue) takeHeld() []event {
280280 return held
281281 }
282282
283+// hasLocked reports whether an evLocked for lock generation gen is queued or
284+// parked.
285+func (q *queue) hasLocked(gen uint32) bool {
286+ q.mu.Lock()
287+ defer q.mu.Unlock()
288+ for i := range q.n {
289+ if e := &q.ring[(q.head+i)%ringSize]; e.kind == evLocked && e.id == gen {
290+ return true
291+ }
292+ }
293+ for i := range q.held {
294+ if q.held[i].kind == evLocked && q.held[i].id == gen {
295+ return true
296+ }
297+ }
298+ return false
299+}
300+
283301 // closeFDs closes the descriptors owned by events nobody will drain. Only
284302 // valid once both producers have stopped.
285303 func (q *queue) closeFDs() {
@@ -280,6 +280,24 @@ func (q *queue) takeHeld() []event {
280 return held280 return held
281 }281 }
282 282
283+// hasLocked reports whether an evLocked for lock generation gen is queued or
284+// parked.
285+func (q *queue) hasLocked(gen uint32) bool {
286+ q.mu.Lock()
287+ defer q.mu.Unlock()
288+ for i := range q.n {
289+ if e := &q.ring[(q.head+i)%ringSize]; e.kind == evLocked && e.id == gen {
290+ return true
291+ }
292+ }
293+ for i := range q.held {
294+ if q.held[i].kind == evLocked && q.held[i].id == gen {
295+ return true
296+ }
297+ }
298+ return false
299+}
300+
283 // closeFDs closes the descriptors owned by events nobody will drain. Only301 // closeFDs closes the descriptors owned by events nobody will drain. Only
284 // valid once both producers have stopped.302 // valid once both producers have stopped.
285 func (q *queue) closeFDs() {303 func (q *queue) closeFDs() {
M surface.go +37 -23
@@ -113,9 +113,10 @@ type Surface struct {
113113 // destination/opaque state last sent; they are double-buffered surface
114114 // state that persists across commits, so unchanged values are not resent.
115115 sentDestW, sentDestH int32
116- sentBufScale int32
116+ sentBufScale int32 // starts at 1, the protocol default
117+ presented bool // a Present committed: the surface is mapped
118+ output uint32 // lock surfaces: the covered wl_output global
117119 opaqueSet bool
118- opaque *wayland.Region
119120 opaqueW, opaqueH int32
120121
121122 cb *frameCallback
@@ -124,7 +125,7 @@ type Surface struct {
124125 inputRects []Rect
125126 inputCustom bool
126127
127- buffers map[uint64]*wayland.Buffer
128+ buffers map[uint64]importedBuffer
128129 timelines map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline
129130
130131 fb Feedback
@@ -161,8 +162,8 @@ func (f *frameCallback) Dispatch(ev *wl.Event) {
161162 // ID returns the surface's identifier used in Handler callbacks.
162163 func (s *Surface) ID() SurfaceID { return s.id }
163164
164-// Size returns the current logical size and scale (1 until announced). It is
165-// zero until the first configure.
165+// Size returns the current logical size and scale (1 until announced). Before
166+// the first configure the size is the requested one (zero for lock surfaces).
166167 func (s *Surface) Size() (w, h int32, scale float64) { return s.width, s.height, s.scale }
167168
168169 // Feedback returns the latest complete dmabuf feedback, or nil before the
@@ -237,8 +238,8 @@ func (c *Conn) newSurface(role surfaceRole, w, h int32) (*Surface, error) {
237238 }
238239 g := &c.g
239240 c.nextSurf++
240- s := &Surface{c: c, id: c.nextSurf, role: role, width: w, height: h, scale: 1,
241- buffers: map[uint64]*wayland.Buffer{}, timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{}}
241+ s := &Surface{c: c, id: c.nextSurf, role: role, width: w, height: h, scale: 1, sentBufScale: 1,
242+ buffers: map[uint64]importedBuffer{}, timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{}}
242243 ok := false
243244 defer func() {
244245 if !ok {
@@ -345,7 +346,16 @@ var badEventErrors = [...]string{
345346 badFeedbackFD: "neferclient: dmabuf format table descriptor unavailable",
346347 badMainDevice: "neferclient: invalid dmabuf main_device length",
347348 badTranche: "neferclient: odd dmabuf tranche format list",
348- badConfigure: "neferclient: layer configure size out of range",
349+ badConfigure: "neferclient: configure size out of range",
350+}
351+
352+// configureEvent builds a layer or lock configure event, or a badConfigure
353+// event when the compositor announced an out-of-range size.
354+func configureEvent(kind eventKind, sid, serial, w, h uint32) event {
355+ if w > maxLayerDimension || h > maxLayerDimension {
356+ return event{kind: evBadEvent, id: sid, a: badConfigure}
357+ }
358+ return event{kind: kind, id: sid, serial: serial, a: int32(w), b: int32(h)}
349359 }
350360
351361 // NewLayerSurface creates a wlr-layer-shell surface. It becomes presentable
@@ -402,12 +412,7 @@ func (c *Conn) NewLayerSurface(cfg LayerConfig) (*Surface, error) {
402412 q.post(&e)
403413 })
404414 s.layer.OnConfigure(func(serial, w, h uint32) {
405- if w > maxLayerDimension || h > maxLayerDimension {
406- e := event{kind: evBadEvent, id: sid, a: badConfigure}
407- q.post(&e)
408- return
409- }
410- e := event{kind: evLayerConfigure, id: sid, serial: serial, a: int32(w), b: int32(h)}
415+ e := configureEvent(evLayerConfigure, sid, serial, w, h)
411416 q.post(&e)
412417 })
413418 // Opposite anchors let the compositor assign that axis (size 0).
@@ -574,7 +579,9 @@ func (c *Conn) applySurface(ev *event, h Handler) {
574579 s.setScale(float64(ev.a), h)
575580 }
576581 case evFeedbackTable:
577- if err := s.readTable(int(ev.fd), ev.flags); err != nil {
582+ fd := int(ev.fd)
583+ ev.fd = -1 // readTable consumes fd; the deferred closeFD must not touch the number again
584+ if err := s.readTable(fd, ev.flags); err != nil {
578585 h.Error(err)
579586 }
580587 case evFeedbackMainDevice:
@@ -645,15 +652,22 @@ func (s *Surface) readTable(fd int, size uint32) error {
645652 return nil
646653 }
647654
648-// SetInputRegion sets the input region (logical pixels) and commits when it
649-// changed. nil restores the whole surface; a non-nil empty slice passes all
650-// input through. Before the first configure the next commit applies it.
655+// SetInputRegion sets the input region (logical pixels). nil restores the
656+// whole surface; a non-nil empty slice passes all input through. The region is
657+// double-buffered state: it is committed immediately only once the surface
658+// presented a buffer; before that the next Present applies it (committing
659+// earlier would map a surface without a buffer, which lock surfaces forbid and
660+// layer surfaces answer with an error). Lock surfaces always take all input
661+// and refuse it.
651662 func (s *Surface) SetInputRegion(rects []Rect) error {
652663 if s.closed {
653664 return ErrClosed
654665 }
666+ if s.role == roleLock {
667+ return errors.New("neferclient: lock surfaces take all input")
668+ }
655669 changed, err := s.stageInputRects(rects)
656- if err != nil || !changed || !s.configured {
670+ if err != nil || !changed || !s.presented {
657671 return err
658672 }
659673 return s.surf.Commit()
@@ -709,6 +723,9 @@ func (s *Surface) Close() error {
709723 }
710724
711725 func (s *Surface) destroy() error {
726+ // TODO(wlturbo): a frame callback pending here is unregistered locally, so
727+ // its later done/delete_id reaches an unknown object. Fixing it needs
728+ // server-side delete_id handling in wlturbo (see TestCloseWithPendingFrame).
712729 s.closed = true
713730 delete(s.c.surfaces, s.id)
714731 var errs []error
@@ -718,16 +735,13 @@ func (s *Surface) destroy() error {
718735 }
719736 }
720737 for id, b := range s.buffers {
721- add(b.Destroy())
738+ add(b.buf.Destroy())
722739 delete(s.buffers, id)
723740 }
724741 for id, t := range s.timelines {
725742 add(t.Destroy())
726743 delete(s.timelines, id)
727744 }
728- if s.opaque != nil {
729- add(s.opaque.Destroy())
730- }
731745 if s.feedback != nil {
732746 add(s.feedback.Destroy())
733747 }
@@ -113,9 +113,10 @@ type Surface struct {
113 // destination/opaque state last sent; they are double-buffered surface113 // destination/opaque state last sent; they are double-buffered surface
114 // state that persists across commits, so unchanged values are not resent.114 // state that persists across commits, so unchanged values are not resent.
115 sentDestW, sentDestH int32115 sentDestW, sentDestH int32
116- sentBufScale int32116+ sentBufScale int32 // starts at 1, the protocol default
117+ presented bool // a Present committed: the surface is mapped
118+ output uint32 // lock surfaces: the covered wl_output global
117 opaqueSet bool119 opaqueSet bool
118- opaque *wayland.Region
119 opaqueW, opaqueH int32120 opaqueW, opaqueH int32
120 121
121 cb *frameCallback122 cb *frameCallback
@@ -124,7 +125,7 @@ type Surface struct {
124 inputRects []Rect125 inputRects []Rect
125 inputCustom bool126 inputCustom bool
126 127
127- buffers map[uint64]*wayland.Buffer128+ buffers map[uint64]importedBuffer
128 timelines map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline129 timelines map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline
129 130
130 fb Feedback131 fb Feedback
@@ -161,8 +162,8 @@ func (f *frameCallback) Dispatch(ev *wl.Event) {
161 // ID returns the surface's identifier used in Handler callbacks.162 // ID returns the surface's identifier used in Handler callbacks.
162 func (s *Surface) ID() SurfaceID { return s.id }163 func (s *Surface) ID() SurfaceID { return s.id }
163 164
164-// Size returns the current logical size and scale (1 until announced). It is165+// Size returns the current logical size and scale (1 until announced). Before
165-// zero until the first configure.166+// the first configure the size is the requested one (zero for lock surfaces).
166 func (s *Surface) Size() (w, h int32, scale float64) { return s.width, s.height, s.scale }167 func (s *Surface) Size() (w, h int32, scale float64) { return s.width, s.height, s.scale }
167 168
168 // Feedback returns the latest complete dmabuf feedback, or nil before the169 // Feedback returns the latest complete dmabuf feedback, or nil before the
@@ -237,8 +238,8 @@ func (c *Conn) newSurface(role surfaceRole, w, h int32) (*Surface, error) {
237 }238 }
238 g := &c.g239 g := &c.g
239 c.nextSurf++240 c.nextSurf++
240- s := &Surface{c: c, id: c.nextSurf, role: role, width: w, height: h, scale: 1,241+ s := &Surface{c: c, id: c.nextSurf, role: role, width: w, height: h, scale: 1, sentBufScale: 1,
241- buffers: map[uint64]*wayland.Buffer{}, timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{}}242+ buffers: map[uint64]importedBuffer{}, timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{}}
242 ok := false243 ok := false
243 defer func() {244 defer func() {
244 if !ok {245 if !ok {
@@ -345,7 +346,16 @@ var badEventErrors = [...]string{
345 badFeedbackFD: "neferclient: dmabuf format table descriptor unavailable",346 badFeedbackFD: "neferclient: dmabuf format table descriptor unavailable",
346 badMainDevice: "neferclient: invalid dmabuf main_device length",347 badMainDevice: "neferclient: invalid dmabuf main_device length",
347 badTranche: "neferclient: odd dmabuf tranche format list",348 badTranche: "neferclient: odd dmabuf tranche format list",
348- badConfigure: "neferclient: layer configure size out of range",349+ badConfigure: "neferclient: configure size out of range",
350+}
351+
352+// configureEvent builds a layer or lock configure event, or a badConfigure
353+// event when the compositor announced an out-of-range size.
354+func configureEvent(kind eventKind, sid, serial, w, h uint32) event {
355+ if w > maxLayerDimension || h > maxLayerDimension {
356+ return event{kind: evBadEvent, id: sid, a: badConfigure}
357+ }
358+ return event{kind: kind, id: sid, serial: serial, a: int32(w), b: int32(h)}
349 }359 }
350 360
351 // NewLayerSurface creates a wlr-layer-shell surface. It becomes presentable361 // NewLayerSurface creates a wlr-layer-shell surface. It becomes presentable
@@ -402,12 +412,7 @@ func (c *Conn) NewLayerSurface(cfg LayerConfig) (*Surface, error) {
402 q.post(&e)412 q.post(&e)
403 })413 })
404 s.layer.OnConfigure(func(serial, w, h uint32) {414 s.layer.OnConfigure(func(serial, w, h uint32) {
405- if w > maxLayerDimension || h > maxLayerDimension {415+ e := configureEvent(evLayerConfigure, sid, serial, w, h)
406- e := event{kind: evBadEvent, id: sid, a: badConfigure}
407- q.post(&e)
408- return
409- }
410- e := event{kind: evLayerConfigure, id: sid, serial: serial, a: int32(w), b: int32(h)}
411 q.post(&e)416 q.post(&e)
412 })417 })
413 // Opposite anchors let the compositor assign that axis (size 0).418 // Opposite anchors let the compositor assign that axis (size 0).
@@ -574,7 +579,9 @@ func (c *Conn) applySurface(ev *event, h Handler) {
574 s.setScale(float64(ev.a), h)579 s.setScale(float64(ev.a), h)
575 }580 }
576 case evFeedbackTable:581 case evFeedbackTable:
577- if err := s.readTable(int(ev.fd), ev.flags); err != nil {582+ fd := int(ev.fd)
583+ ev.fd = -1 // readTable consumes fd; the deferred closeFD must not touch the number again
584+ if err := s.readTable(fd, ev.flags); err != nil {
578 h.Error(err)585 h.Error(err)
579 }586 }
580 case evFeedbackMainDevice:587 case evFeedbackMainDevice:
@@ -645,15 +652,22 @@ func (s *Surface) readTable(fd int, size uint32) error {
645 return nil652 return nil
646 }653 }
647 654
648-// SetInputRegion sets the input region (logical pixels) and commits when it655+// SetInputRegion sets the input region (logical pixels). nil restores the
649-// changed. nil restores the whole surface; a non-nil empty slice passes all656+// whole surface; a non-nil empty slice passes all input through. The region is
650-// input through. Before the first configure the next commit applies it.657+// double-buffered state: it is committed immediately only once the surface
658+// presented a buffer; before that the next Present applies it (committing
659+// earlier would map a surface without a buffer, which lock surfaces forbid and
660+// layer surfaces answer with an error). Lock surfaces always take all input
661+// and refuse it.
651 func (s *Surface) SetInputRegion(rects []Rect) error {662 func (s *Surface) SetInputRegion(rects []Rect) error {
652 if s.closed {663 if s.closed {
653 return ErrClosed664 return ErrClosed
654 }665 }
666+ if s.role == roleLock {
667+ return errors.New("neferclient: lock surfaces take all input")
668+ }
655 changed, err := s.stageInputRects(rects)669 changed, err := s.stageInputRects(rects)
656- if err != nil || !changed || !s.configured {670+ if err != nil || !changed || !s.presented {
657 return err671 return err
658 }672 }
659 return s.surf.Commit()673 return s.surf.Commit()
@@ -709,6 +723,9 @@ func (s *Surface) Close() error {
709 }723 }
710 724
711 func (s *Surface) destroy() error {725 func (s *Surface) destroy() error {
726+ // TODO(wlturbo): a frame callback pending here is unregistered locally, so
727+ // its later done/delete_id reaches an unknown object. Fixing it needs
728+ // server-side delete_id handling in wlturbo (see TestCloseWithPendingFrame).
712 s.closed = true729 s.closed = true
713 delete(s.c.surfaces, s.id)730 delete(s.c.surfaces, s.id)
714 var errs []error731 var errs []error
@@ -718,16 +735,13 @@ func (s *Surface) destroy() error {
718 }735 }
719 }736 }
720 for id, b := range s.buffers {737 for id, b := range s.buffers {
721- add(b.Destroy())738+ add(b.buf.Destroy())
722 delete(s.buffers, id)739 delete(s.buffers, id)
723 }740 }
724 for id, t := range s.timelines {741 for id, t := range s.timelines {
725 add(t.Destroy())742 add(t.Destroy())
726 delete(s.timelines, id)743 delete(s.timelines, id)
727 }744 }
728- if s.opaque != nil {
729- add(s.opaque.Destroy())
730- }
731 if s.feedback != nil {745 if s.feedback != nil {
732 add(s.feedback.Destroy())746 add(s.feedback.Destroy())
733 }747 }
M surface_internal_test.go +96 -21
@@ -2,11 +2,11 @@ package neferclient
22
33 import (
44 "encoding/binary"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/go-wayland-bindings/client/linuxdrmsyncobj"
89 "github.com/bnema/go-wayland-bindings/client/viewporter"
9- "github.com/bnema/go-wayland-bindings/client/wayland"
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212 "golang.org/x/sys/unix"
@@ -14,8 +14,9 @@ import (
1414
1515 // presentable returns a configured surface that sends through req.
1616 func presentable(req surfaceRequests, scale float64) *Surface {
17- return &Surface{id: 1, req: req, width: 100, height: 50, scale: scale, configured: true, frameReady: true, fbDone: true,
18- buffers: map[uint64]*wayland.Buffer{1: nil},
17+ return &Surface{id: 1, req: req, width: 100, height: 50, scale: scale, sentBufScale: 1, role: roleToplevel,
18+ configured: true, frameReady: true, fbDone: true,
19+ buffers: map[uint64]importedBuffer{1: {w: 100, h: 50}},
1920 timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{1: nil, 2: nil},
2021 viewport: &viewporter.WpViewport{}}
2122 }
@@ -84,17 +85,6 @@ func TestPhysicalSize(t *testing.T) {
8485 require.Equal(t, [2]int32{125, 63}, [2]int32{w, h}) // 62.5 rounds up
8586 }
8687
87-// feedbackHandler is the measured input, not a double: it keeps what the
88-// owner-side feedback parsing produced.
89-type feedbackHandler struct {
90- NopHandler
91- done int
92- errs []error
93-}
94-
95-func (h *feedbackHandler) FeedbackDone(SurfaceID) { h.done++ }
96-func (h *feedbackHandler) Error(err error) { h.errs = append(h.errs, err) }
97-
9888 func tableFD(t *testing.T, entries []Format) int {
9989 t.Helper()
10090 fd, err := unix.MemfdCreate("table", unix.MFD_CLOEXEC)
@@ -134,16 +124,16 @@ func TestFeedbackParsedOnOwner(t *testing.T) {
134124 c, s := newOwnerConn()
135125 table := []Format{{0x34325258, 0}, {0x34325241, 7}, {0x34325258, 1 << 40}}
136126 postRound(c, s, tableFD(t, table), 16*3, 0xe280, 2, 0)
137- h := &feedbackHandler{}
127+ h := NewMockHandler(t)
128+ h.EXPECT().FeedbackDone(s.id).Return().Once()
138129 require.NoError(t, c.Dispatch(h))
139- require.Empty(t, h.errs)
140- require.Equal(t, 1, h.done)
141130 fb := s.Feedback()
142131 require.NotNil(t, fb)
143132 require.Equal(t, uint64(0xe280), fb.MainDevice)
144133 require.Equal(t, []Format{table[2], table[0]}, fb.Formats, "tranche order")
145134
146135 // A second round replaces the first without leaking the first one's formats.
136+ h.EXPECT().FeedbackDone(s.id).Return().Once()
147137 postRound(c, s, tableFD(t, table), 16*3, 0xe281, 1)
148138 require.NoError(t, c.Dispatch(h))
149139 require.Equal(t, &Feedback{MainDevice: 0xe281, Formats: []Format{table[1]}}, s.Feedback())
@@ -153,11 +143,11 @@ func TestFeedbackBadIndexAndSize(t *testing.T) {
153143 c, s := newOwnerConn()
154144 postRound(c, s, tableFD(t, []Format{{1, 1}}), 16, 5, 3)
155145 postRound(c, s, tableFD(t, nil), 15, 5)
156- h := &feedbackHandler{}
146+ h := NewMockHandler(t)
147+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "out of range") })).Return().Once()
148+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "invalid dmabuf format table size") })).Return().Once()
149+ h.EXPECT().FeedbackDone(s.id).Return().Twice()
157150 require.NoError(t, c.Dispatch(h))
158- require.Len(t, h.errs, 2)
159- require.ErrorContains(t, h.errs[0], "out of range")
160- require.ErrorContains(t, h.errs[1], "invalid dmabuf format table size")
161151 }
162152
163153 func fdOpen(fd int) bool { _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); return err == nil }
@@ -198,6 +188,22 @@ func TestEventForClosedSurfaceDropsFD(t *testing.T) {
198188 require.False(t, fdOpen(fd))
199189 }
200190
191+// GO-001: the descriptor is consumed by readTable; the event must not close
192+// the number a second time, which by then may belong to someone else.
193+func TestFeedbackTableFDNotClosedTwice(t *testing.T) {
194+ c, s := newOwnerConn()
195+ fd := tableFD(t, []Format{{1, 1}})
196+ ev := event{kind: evFeedbackTable, id: uint32(s.id), fd: int32(fd), flags: 16}
197+ c.applySurface(&ev, NewMockHandler(t))
198+ require.False(t, fdOpen(fd), "readTable consumed it")
199+ require.Equal(t, int32(-1), ev.fd, "ownership left the event")
200+ other := tableFD(t, nil) // the kernel hands out the freed number again
201+ require.Equal(t, fd, other)
202+ ev.closeFD() // what a second close of the event would do
203+ require.True(t, fdOpen(other), "an unrelated descriptor that reuses the number stays open")
204+ _ = unix.Close(other)
205+}
206+
201207 func TestLockRules(t *testing.T) {
202208 c := &Conn{q: newQueue(false), surfaces: map[SurfaceID]*Surface{}}
203209 l := &Lock{c: c, gen: 1}
@@ -215,3 +221,72 @@ func TestLockRules(t *testing.T) {
215221 require.False(t, l.Locked())
216222 require.ErrorContains(t, l.Unlock(), "before the locked event")
217223 }
224+
225+// GO-004: the buffer scale returns to 1 when the output scale drops, with no
226+// viewport to carry the size.
227+func TestPresentResetsBufferScale(t *testing.T) {
228+ m := newMocksurfaceRequests(t)
229+ s := presentable(m, 2)
230+ s.viewport = nil
231+ s.buffers[1] = importedBuffer{w: 200, h: 100}
232+ m.EXPECT().setBufferScale(int32(2)).Return(nil).Once()
233+ m.EXPECT().setBufferScale(int32(1)).Return(nil).Once()
234+ m.EXPECT().setAcquire(mock.Anything, mock.Anything).Return(nil).Twice()
235+ m.EXPECT().setRelease(mock.Anything, mock.Anything).Return(nil).Twice()
236+ m.EXPECT().attach(uint64(1)).Return(nil).Twice()
237+ m.EXPECT().damageBuffer(mock.Anything).Return(nil).Twice()
238+ m.EXPECT().frame().Return(nil).Twice()
239+ m.EXPECT().commit().Return(nil).Twice()
240+ p := &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}
241+ require.NoError(t, s.Present(p))
242+ s.scale, s.frameReady = 1, true
243+ s.buffers[1] = importedBuffer{w: 100, h: 50}
244+ require.NoError(t, s.Present(p))
245+}
246+
247+// GO-005: size mismatches, timeline point order and a nil request are refused
248+// before anything is sent (the mock is strict).
249+func TestPresentRefusesInconsistentRequests(t *testing.T) {
250+ m := newMocksurfaceRequests(t)
251+ good := &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}
252+ for name, tc := range map[string]struct {
253+ mutate func(*Surface)
254+ p *Present
255+ }{
256+ "nil": {func(*Surface) {}, nil},
257+ "mismatch no viewport": {func(s *Surface) { s.viewport = nil; s.buffers[1] = importedBuffer{w: 99, h: 50} }, good},
258+ "mismatch lock role": {func(s *Surface) { s.role = roleLock; s.buffers[1] = importedBuffer{w: 100, h: 49} }, good},
259+ "same timeline acquire not before release": {func(*Surface) {},
260+ &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 1, AcquirePoint: 5, ReleasePoint: 5}},
261+ } {
262+ s := presentable(m, 1)
263+ tc.mutate(s)
264+ require.Error(t, s.Present(tc.p), name)
265+ }
266+ // With a viewport a different buffer size is legal (scaled by the compositor).
267+ s := presentable(m, 1)
268+ s.buffers[1] = importedBuffer{w: 50, h: 25}
269+ m.EXPECT().setDestination(int32(100), int32(50)).Return(nil).Once()
270+ m.EXPECT().setAcquire(mock.Anything, mock.Anything).Return(nil).Once()
271+ m.EXPECT().setRelease(mock.Anything, mock.Anything).Return(nil).Once()
272+ m.EXPECT().attach(uint64(1)).Return(nil).Once()
273+ m.EXPECT().damageBuffer(mock.Anything).Return(nil).Once()
274+ m.EXPECT().frame().Return(nil).Once()
275+ m.EXPECT().commit().Return(nil).Once()
276+ require.NoError(t, s.Present(good))
277+}
278+
279+// GO-008: lock and layer configure sizes share the bounds check.
280+func TestConfigureEventBounds(t *testing.T) {
281+ for _, kind := range []eventKind{evLayerConfigure, evLockConfigure} {
282+ ev := configureEvent(kind, 3, 9, maxLayerDimension, 1)
283+ require.Equal(t, event{kind: kind, id: 3, serial: 9, a: maxLayerDimension, b: 1}, ev)
284+ require.Equal(t, evBadEvent, configureEvent(kind, 3, 9, maxLayerDimension+1, 1).kind)
285+ require.Equal(t, evBadEvent, configureEvent(kind, 3, 9, 1, 1<<31).kind, "no wrap to a negative int32")
286+ }
287+ c, _ := newOwnerConn()
288+ c.q.post(&event{kind: evBadEvent, id: 1, a: badConfigure})
289+ h := NewMockHandler(t)
290+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "configure size") })).Return().Once()
291+ require.NoError(t, c.Dispatch(h))
292+}
@@ -2,11 +2,11 @@ package neferclient
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/go-wayland-bindings/client/linuxdrmsyncobj"8 "github.com/bnema/go-wayland-bindings/client/linuxdrmsyncobj"
8 "github.com/bnema/go-wayland-bindings/client/viewporter"9 "github.com/bnema/go-wayland-bindings/client/viewporter"
9- "github.com/bnema/go-wayland-bindings/client/wayland"
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 "golang.org/x/sys/unix"12 "golang.org/x/sys/unix"
@@ -14,8 +14,9 @@ import (
14 14
15 // presentable returns a configured surface that sends through req.15 // presentable returns a configured surface that sends through req.
16 func presentable(req surfaceRequests, scale float64) *Surface {16 func presentable(req surfaceRequests, scale float64) *Surface {
17- return &Surface{id: 1, req: req, width: 100, height: 50, scale: scale, configured: true, frameReady: true, fbDone: true,17+ return &Surface{id: 1, req: req, width: 100, height: 50, scale: scale, sentBufScale: 1, role: roleToplevel,
18- buffers: map[uint64]*wayland.Buffer{1: nil},18+ configured: true, frameReady: true, fbDone: true,
19+ buffers: map[uint64]importedBuffer{1: {w: 100, h: 50}},
19 timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{1: nil, 2: nil},20 timelines: map[uint64]*linuxdrmsyncobj.WpLinuxDrmSyncobjTimeline{1: nil, 2: nil},
20 viewport: &viewporter.WpViewport{}}21 viewport: &viewporter.WpViewport{}}
21 }22 }
@@ -84,17 +85,6 @@ func TestPhysicalSize(t *testing.T) {
84 require.Equal(t, [2]int32{125, 63}, [2]int32{w, h}) // 62.5 rounds up85 require.Equal(t, [2]int32{125, 63}, [2]int32{w, h}) // 62.5 rounds up
85 }86 }
86 87
87-// feedbackHandler is the measured input, not a double: it keeps what the
88-// owner-side feedback parsing produced.
89-type feedbackHandler struct {
90- NopHandler
91- done int
92- errs []error
93-}
94-
95-func (h *feedbackHandler) FeedbackDone(SurfaceID) { h.done++ }
96-func (h *feedbackHandler) Error(err error) { h.errs = append(h.errs, err) }
97-
98 func tableFD(t *testing.T, entries []Format) int {88 func tableFD(t *testing.T, entries []Format) int {
99 t.Helper()89 t.Helper()
100 fd, err := unix.MemfdCreate("table", unix.MFD_CLOEXEC)90 fd, err := unix.MemfdCreate("table", unix.MFD_CLOEXEC)
@@ -134,16 +124,16 @@ func TestFeedbackParsedOnOwner(t *testing.T) {
134 c, s := newOwnerConn()124 c, s := newOwnerConn()
135 table := []Format{{0x34325258, 0}, {0x34325241, 7}, {0x34325258, 1 << 40}}125 table := []Format{{0x34325258, 0}, {0x34325241, 7}, {0x34325258, 1 << 40}}
136 postRound(c, s, tableFD(t, table), 16*3, 0xe280, 2, 0)126 postRound(c, s, tableFD(t, table), 16*3, 0xe280, 2, 0)
137- h := &feedbackHandler{}127+ h := NewMockHandler(t)
128+ h.EXPECT().FeedbackDone(s.id).Return().Once()
138 require.NoError(t, c.Dispatch(h))129 require.NoError(t, c.Dispatch(h))
139- require.Empty(t, h.errs)
140- require.Equal(t, 1, h.done)
141 fb := s.Feedback()130 fb := s.Feedback()
142 require.NotNil(t, fb)131 require.NotNil(t, fb)
143 require.Equal(t, uint64(0xe280), fb.MainDevice)132 require.Equal(t, uint64(0xe280), fb.MainDevice)
144 require.Equal(t, []Format{table[2], table[0]}, fb.Formats, "tranche order")133 require.Equal(t, []Format{table[2], table[0]}, fb.Formats, "tranche order")
145 134
146 // A second round replaces the first without leaking the first one's formats.135 // A second round replaces the first without leaking the first one's formats.
136+ h.EXPECT().FeedbackDone(s.id).Return().Once()
147 postRound(c, s, tableFD(t, table), 16*3, 0xe281, 1)137 postRound(c, s, tableFD(t, table), 16*3, 0xe281, 1)
148 require.NoError(t, c.Dispatch(h))138 require.NoError(t, c.Dispatch(h))
149 require.Equal(t, &Feedback{MainDevice: 0xe281, Formats: []Format{table[1]}}, s.Feedback())139 require.Equal(t, &Feedback{MainDevice: 0xe281, Formats: []Format{table[1]}}, s.Feedback())
@@ -153,11 +143,11 @@ func TestFeedbackBadIndexAndSize(t *testing.T) {
153 c, s := newOwnerConn()143 c, s := newOwnerConn()
154 postRound(c, s, tableFD(t, []Format{{1, 1}}), 16, 5, 3)144 postRound(c, s, tableFD(t, []Format{{1, 1}}), 16, 5, 3)
155 postRound(c, s, tableFD(t, nil), 15, 5)145 postRound(c, s, tableFD(t, nil), 15, 5)
156- h := &feedbackHandler{}146+ h := NewMockHandler(t)
147+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "out of range") })).Return().Once()
148+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "invalid dmabuf format table size") })).Return().Once()
149+ h.EXPECT().FeedbackDone(s.id).Return().Twice()
157 require.NoError(t, c.Dispatch(h))150 require.NoError(t, c.Dispatch(h))
158- require.Len(t, h.errs, 2)
159- require.ErrorContains(t, h.errs[0], "out of range")
160- require.ErrorContains(t, h.errs[1], "invalid dmabuf format table size")
161 }151 }
162 152
163 func fdOpen(fd int) bool { _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); return err == nil }153 func fdOpen(fd int) bool { _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); return err == nil }
@@ -198,6 +188,22 @@ func TestEventForClosedSurfaceDropsFD(t *testing.T) {
198 require.False(t, fdOpen(fd))188 require.False(t, fdOpen(fd))
199 }189 }
200 190
191+// GO-001: the descriptor is consumed by readTable; the event must not close
192+// the number a second time, which by then may belong to someone else.
193+func TestFeedbackTableFDNotClosedTwice(t *testing.T) {
194+ c, s := newOwnerConn()
195+ fd := tableFD(t, []Format{{1, 1}})
196+ ev := event{kind: evFeedbackTable, id: uint32(s.id), fd: int32(fd), flags: 16}
197+ c.applySurface(&ev, NewMockHandler(t))
198+ require.False(t, fdOpen(fd), "readTable consumed it")
199+ require.Equal(t, int32(-1), ev.fd, "ownership left the event")
200+ other := tableFD(t, nil) // the kernel hands out the freed number again
201+ require.Equal(t, fd, other)
202+ ev.closeFD() // what a second close of the event would do
203+ require.True(t, fdOpen(other), "an unrelated descriptor that reuses the number stays open")
204+ _ = unix.Close(other)
205+}
206+
201 func TestLockRules(t *testing.T) {207 func TestLockRules(t *testing.T) {
202 c := &Conn{q: newQueue(false), surfaces: map[SurfaceID]*Surface{}}208 c := &Conn{q: newQueue(false), surfaces: map[SurfaceID]*Surface{}}
203 l := &Lock{c: c, gen: 1}209 l := &Lock{c: c, gen: 1}
@@ -215,3 +221,72 @@ func TestLockRules(t *testing.T) {
215 require.False(t, l.Locked())221 require.False(t, l.Locked())
216 require.ErrorContains(t, l.Unlock(), "before the locked event")222 require.ErrorContains(t, l.Unlock(), "before the locked event")
217 }223 }
224+
225+// GO-004: the buffer scale returns to 1 when the output scale drops, with no
226+// viewport to carry the size.
227+func TestPresentResetsBufferScale(t *testing.T) {
228+ m := newMocksurfaceRequests(t)
229+ s := presentable(m, 2)
230+ s.viewport = nil
231+ s.buffers[1] = importedBuffer{w: 200, h: 100}
232+ m.EXPECT().setBufferScale(int32(2)).Return(nil).Once()
233+ m.EXPECT().setBufferScale(int32(1)).Return(nil).Once()
234+ m.EXPECT().setAcquire(mock.Anything, mock.Anything).Return(nil).Twice()
235+ m.EXPECT().setRelease(mock.Anything, mock.Anything).Return(nil).Twice()
236+ m.EXPECT().attach(uint64(1)).Return(nil).Twice()
237+ m.EXPECT().damageBuffer(mock.Anything).Return(nil).Twice()
238+ m.EXPECT().frame().Return(nil).Twice()
239+ m.EXPECT().commit().Return(nil).Twice()
240+ p := &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}
241+ require.NoError(t, s.Present(p))
242+ s.scale, s.frameReady = 1, true
243+ s.buffers[1] = importedBuffer{w: 100, h: 50}
244+ require.NoError(t, s.Present(p))
245+}
246+
247+// GO-005: size mismatches, timeline point order and a nil request are refused
248+// before anything is sent (the mock is strict).
249+func TestPresentRefusesInconsistentRequests(t *testing.T) {
250+ m := newMocksurfaceRequests(t)
251+ good := &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}
252+ for name, tc := range map[string]struct {
253+ mutate func(*Surface)
254+ p *Present
255+ }{
256+ "nil": {func(*Surface) {}, nil},
257+ "mismatch no viewport": {func(s *Surface) { s.viewport = nil; s.buffers[1] = importedBuffer{w: 99, h: 50} }, good},
258+ "mismatch lock role": {func(s *Surface) { s.role = roleLock; s.buffers[1] = importedBuffer{w: 100, h: 49} }, good},
259+ "same timeline acquire not before release": {func(*Surface) {},
260+ &Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 1, AcquirePoint: 5, ReleasePoint: 5}},
261+ } {
262+ s := presentable(m, 1)
263+ tc.mutate(s)
264+ require.Error(t, s.Present(tc.p), name)
265+ }
266+ // With a viewport a different buffer size is legal (scaled by the compositor).
267+ s := presentable(m, 1)
268+ s.buffers[1] = importedBuffer{w: 50, h: 25}
269+ m.EXPECT().setDestination(int32(100), int32(50)).Return(nil).Once()
270+ m.EXPECT().setAcquire(mock.Anything, mock.Anything).Return(nil).Once()
271+ m.EXPECT().setRelease(mock.Anything, mock.Anything).Return(nil).Once()
272+ m.EXPECT().attach(uint64(1)).Return(nil).Once()
273+ m.EXPECT().damageBuffer(mock.Anything).Return(nil).Once()
274+ m.EXPECT().frame().Return(nil).Once()
275+ m.EXPECT().commit().Return(nil).Once()
276+ require.NoError(t, s.Present(good))
277+}
278+
279+// GO-008: lock and layer configure sizes share the bounds check.
280+func TestConfigureEventBounds(t *testing.T) {
281+ for _, kind := range []eventKind{evLayerConfigure, evLockConfigure} {
282+ ev := configureEvent(kind, 3, 9, maxLayerDimension, 1)
283+ require.Equal(t, event{kind: kind, id: 3, serial: 9, a: maxLayerDimension, b: 1}, ev)
284+ require.Equal(t, evBadEvent, configureEvent(kind, 3, 9, maxLayerDimension+1, 1).kind)
285+ require.Equal(t, evBadEvent, configureEvent(kind, 3, 9, 1, 1<<31).kind, "no wrap to a negative int32")
286+ }
287+ c, _ := newOwnerConn()
288+ c.q.post(&event{kind: evBadEvent, id: 1, a: badConfigure})
289+ h := NewMockHandler(t)
290+ h.EXPECT().Error(mock.MatchedBy(func(err error) bool { return strings.Contains(err.Error(), "configure size") })).Return().Once()
291+ require.NoError(t, c.Dispatch(h))
292+}
A surface_wire_test.go +200 -0
@@ -0,0 +1,200 @@
1+package neferclient_test
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+ "golang.org/x/sys/unix"
11+
12+ "github.com/bnema/neferclient"
13+)
14+
15+var surfaceGlobals = []wireGlobal{{1, "wl_compositor", 6}, {2, "zwp_linux_dmabuf_v1", 4},
16+ {3, "wp_linux_drm_syncobj_manager_v1", 1}, {4, "wp_viewporter", 1}, {5, "xdg_wm_base", 6},
17+ {6, "ext_session_lock_manager_v1", 1}, {7, outputIface, 4}}
18+
19+var surfaceOutputs = map[uint32]outputSpec{7: {"DP-1", 1, 640, 480}}
20+
21+func openFDs(t *testing.T) int {
22+ t.Helper()
23+ ents, err := os.ReadDir("/proc/self/fd")
24+ require.NoError(t, err)
25+ return len(ents)
26+}
27+
28+// GO-009 and GO-012: failure paths close each duplicated descriptor exactly
29+// once, and descriptor 0 is never touched.
30+func TestImportErrorPathsCloseDupsOnce(t *testing.T) {
31+ c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
32+ s, err := c.NewToplevel("fd", 64, 48)
33+ require.NoError(t, err)
34+ memfd, err := unix.MemfdCreate("fd", unix.MFD_CLOEXEC)
35+ require.NoError(t, err)
36+ defer unix.Close(memfd)
37+ _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
38+ require.NoError(t, err, "stdin is open for the test")
39+
40+ before := openFDs(t)
41+ // Plane 0 duplicates, plane 1 cannot: the first duplicate must be closed.
42+ err = s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 2,
43+ Planes: [4]neferclient.Plane{{FD: memfd}, {FD: 9999}}})
44+ require.Error(t, err)
45+ require.Equal(t, before, openFDs(t), "no descriptor leaked")
46+ _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
47+ require.NoError(t, err, "descriptor 0 untouched")
48+ require.Error(t, s.ImportTimeline(1, 9999))
49+ require.Equal(t, before, openFDs(t))
50+
51+ // Success paths: the request consumes the duplicates.
52+ require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
53+ Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
54+ require.NoError(t, s.ImportTimeline(1, memfd))
55+ require.Equal(t, before, openFDs(t), "sent descriptors are closed, the caller's stays open")
56+ require.Error(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
57+ Planes: [4]neferclient.Plane{{FD: memfd}}}), "duplicate id")
58+ require.Equal(t, before, openFDs(t))
59+}
60+
61+// requestLog counts the commits the wire peer sees.
62+type requestLog struct{ commits atomic.Int32 }
63+
64+// GO-003: before the first Present the region is staged without a commit; a
65+// lock surface refuses it.
66+func TestSetInputRegionCommitsOnlyOnceMapped(t *testing.T) {
67+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
68+ s, err := c.NewToplevel("input", 64, 48)
69+ require.NoError(t, err)
70+ require.NoError(t, c.Roundtrip()) // the peer consumed the initial commit
71+ var log requestLog
72+ surfaceID := s.SurfaceObjectID()
73+ srv.mu.Lock()
74+ srv.hook = func(obj uint32, op uint16, _ []byte) {
75+ if obj == surfaceID && op == 6 {
76+ log.commits.Add(1)
77+ }
78+ }
79+ srv.mu.Unlock()
80+ s.MarkPresentable(64, 48)
81+
82+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 0, Y: 0, Width: 4, Height: 4}}))
83+ require.NoError(t, c.Roundtrip())
84+ require.Zero(t, log.commits.Load(), "unmapped surface: staged, not committed")
85+
86+ s.MarkPresented()
87+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
88+ require.NoError(t, c.Roundtrip())
89+ require.Equal(t, int32(1), log.commits.Load(), "mapped surface: committed")
90+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
91+ require.NoError(t, c.Roundtrip())
92+ require.Equal(t, int32(1), log.commits.Load(), "unchanged region sends nothing")
93+
94+ l, err := c.Lock()
95+ require.NoError(t, err)
96+ ls, err := l.NewSurface(7)
97+ require.NoError(t, err)
98+ require.Error(t, ls.SetInputRegion(nil), "lock surfaces take all input")
99+}
100+
101+// GO-007: one lock surface per output.
102+func TestLockRefusesSecondSurfacePerOutput(t *testing.T) {
103+ c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
104+ l, err := c.Lock()
105+ require.NoError(t, err)
106+ first, err := l.NewSurface(7)
107+ require.NoError(t, err)
108+ _, err = l.NewSurface(7)
109+ require.ErrorContains(t, err, "already has a lock surface")
110+ require.NoError(t, first.Close())
111+ again, err := l.NewSurface(7)
112+ require.NoError(t, err, "after closing the first one the output is free again")
113+ require.NoError(t, again.Close())
114+}
115+
116+// watchLock records the ext_session_lock_v1 id the peer saw requested and
117+// counts its destroy requests.
118+func watchLock(t *testing.T, srv *wireServer) (id func() uint32, destroys *atomic.Int32) {
119+ t.Helper()
120+ var lockID atomic.Uint32
121+ destroys = new(atomic.Int32)
122+ srv.mu.Lock()
123+ srv.hook = func(obj uint32, op uint16, body []byte) {
124+ srv.mu.Lock()
125+ manager := srv.bound[6].id // the lock manager is bound by the first Lock call
126+ srv.mu.Unlock()
127+ switch {
128+ case obj == manager && op == 1 && len(body) == 4: // lock(new_id)
129+ lockID.Store(binary.LittleEndian.Uint32(body))
130+ case obj == lockID.Load() && obj != 0 && op == 0: // destroy
131+ destroys.Add(1)
132+ }
133+ }
134+ srv.mu.Unlock()
135+ return lockID.Load, destroys
136+}
137+
138+// GO-006: a locked event already on the wire when Close runs wins: destroying
139+// a locked lock is a protocol error, so Close reports it instead.
140+func TestLockCloseSeesLockedInFlight(t *testing.T) {
141+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
142+ lockID, destroys := watchLock(t, srv)
143+
144+ l, err := c.Lock()
145+ require.NoError(t, err)
146+ require.NoError(t, c.Roundtrip()) // the peer saw the lock request
147+ srv.write(frame(lockID(), 0, nil)) // ext_session_lock_v1.locked, not yet dispatched
148+
149+ require.ErrorContains(t, l.Close(), "use Unlock")
150+ require.Zero(t, destroys.Load(), "no destroy request was sent")
151+ require.True(t, l.Locked(), "treated as locked")
152+ require.NoError(t, c.Roundtrip())
153+ require.Zero(t, destroys.Load())
154+}
155+
156+func TestLockCloseDestroysUnlockedLock(t *testing.T) {
157+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
158+ _, destroys := watchLock(t, srv)
159+ l, err := c.Lock()
160+ require.NoError(t, err)
161+ require.NoError(t, l.Close())
162+ require.NoError(t, c.Roundtrip())
163+ require.Equal(t, int32(1), destroys.Load())
164+ require.NoError(t, l.Close(), "idempotent")
165+}
166+
167+// GO-002 reproduction, enabled once wlturbo handles delete_id for objects the
168+// server creates: closing a surface with a pending frame callback must not turn
169+// the compositor's later callback done and delete_id into a transport error.
170+func TestCloseWithPendingFrame(t *testing.T) {
171+ t.Skip("needs wlturbo server-side delete_id handling")
172+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
173+ s, err := c.NewToplevel("close", 64, 48)
174+ require.NoError(t, err)
175+ s.MarkPresentable(64, 48)
176+ surfaceID := s.SurfaceObjectID()
177+ var callback atomic.Uint32
178+ srv.mu.Lock()
179+ srv.hook = func(obj uint32, op uint16, body []byte) {
180+ if obj == surfaceID && op == 3 && len(body) == 4 {
181+ callback.Store(binary.LittleEndian.Uint32(body))
182+ }
183+ }
184+ srv.mu.Unlock()
185+ memfd, err := unix.MemfdCreate("close", unix.MFD_CLOEXEC)
186+ require.NoError(t, err)
187+ defer unix.Close(memfd)
188+ require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
189+ Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
190+ require.NoError(t, s.ImportTimeline(1, memfd))
191+ require.NoError(t, s.ImportTimeline(2, memfd))
192+ require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2}))
193+ require.NoError(t, c.Roundtrip())
194+ require.NoError(t, s.Close())
195+
196+ srv.write(frame(callback.Load(), 0, appendU32(nil, 1)))
197+ srv.write(frame(1, 1, appendU32(nil, callback.Load())))
198+ require.NoError(t, c.Roundtrip())
199+ require.NoError(t, c.Dispatch(nil))
200+}
@@ -0,0 +1,200 @@
1+package neferclient_test
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+ "golang.org/x/sys/unix"
11+
12+ "github.com/bnema/neferclient"
13+)
14+
15+var surfaceGlobals = []wireGlobal{{1, "wl_compositor", 6}, {2, "zwp_linux_dmabuf_v1", 4},
16+ {3, "wp_linux_drm_syncobj_manager_v1", 1}, {4, "wp_viewporter", 1}, {5, "xdg_wm_base", 6},
17+ {6, "ext_session_lock_manager_v1", 1}, {7, outputIface, 4}}
18+
19+var surfaceOutputs = map[uint32]outputSpec{7: {"DP-1", 1, 640, 480}}
20+
21+func openFDs(t *testing.T) int {
22+ t.Helper()
23+ ents, err := os.ReadDir("/proc/self/fd")
24+ require.NoError(t, err)
25+ return len(ents)
26+}
27+
28+// GO-009 and GO-012: failure paths close each duplicated descriptor exactly
29+// once, and descriptor 0 is never touched.
30+func TestImportErrorPathsCloseDupsOnce(t *testing.T) {
31+ c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
32+ s, err := c.NewToplevel("fd", 64, 48)
33+ require.NoError(t, err)
34+ memfd, err := unix.MemfdCreate("fd", unix.MFD_CLOEXEC)
35+ require.NoError(t, err)
36+ defer unix.Close(memfd)
37+ _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
38+ require.NoError(t, err, "stdin is open for the test")
39+
40+ before := openFDs(t)
41+ // Plane 0 duplicates, plane 1 cannot: the first duplicate must be closed.
42+ err = s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 2,
43+ Planes: [4]neferclient.Plane{{FD: memfd}, {FD: 9999}}})
44+ require.Error(t, err)
45+ require.Equal(t, before, openFDs(t), "no descriptor leaked")
46+ _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
47+ require.NoError(t, err, "descriptor 0 untouched")
48+ require.Error(t, s.ImportTimeline(1, 9999))
49+ require.Equal(t, before, openFDs(t))
50+
51+ // Success paths: the request consumes the duplicates.
52+ require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
53+ Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
54+ require.NoError(t, s.ImportTimeline(1, memfd))
55+ require.Equal(t, before, openFDs(t), "sent descriptors are closed, the caller's stays open")
56+ require.Error(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
57+ Planes: [4]neferclient.Plane{{FD: memfd}}}), "duplicate id")
58+ require.Equal(t, before, openFDs(t))
59+}
60+
61+// requestLog counts the commits the wire peer sees.
62+type requestLog struct{ commits atomic.Int32 }
63+
64+// GO-003: before the first Present the region is staged without a commit; a
65+// lock surface refuses it.
66+func TestSetInputRegionCommitsOnlyOnceMapped(t *testing.T) {
67+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
68+ s, err := c.NewToplevel("input", 64, 48)
69+ require.NoError(t, err)
70+ require.NoError(t, c.Roundtrip()) // the peer consumed the initial commit
71+ var log requestLog
72+ surfaceID := s.SurfaceObjectID()
73+ srv.mu.Lock()
74+ srv.hook = func(obj uint32, op uint16, _ []byte) {
75+ if obj == surfaceID && op == 6 {
76+ log.commits.Add(1)
77+ }
78+ }
79+ srv.mu.Unlock()
80+ s.MarkPresentable(64, 48)
81+
82+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 0, Y: 0, Width: 4, Height: 4}}))
83+ require.NoError(t, c.Roundtrip())
84+ require.Zero(t, log.commits.Load(), "unmapped surface: staged, not committed")
85+
86+ s.MarkPresented()
87+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
88+ require.NoError(t, c.Roundtrip())
89+ require.Equal(t, int32(1), log.commits.Load(), "mapped surface: committed")
90+ require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
91+ require.NoError(t, c.Roundtrip())
92+ require.Equal(t, int32(1), log.commits.Load(), "unchanged region sends nothing")
93+
94+ l, err := c.Lock()
95+ require.NoError(t, err)
96+ ls, err := l.NewSurface(7)
97+ require.NoError(t, err)
98+ require.Error(t, ls.SetInputRegion(nil), "lock surfaces take all input")
99+}
100+
101+// GO-007: one lock surface per output.
102+func TestLockRefusesSecondSurfacePerOutput(t *testing.T) {
103+ c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
104+ l, err := c.Lock()
105+ require.NoError(t, err)
106+ first, err := l.NewSurface(7)
107+ require.NoError(t, err)
108+ _, err = l.NewSurface(7)
109+ require.ErrorContains(t, err, "already has a lock surface")
110+ require.NoError(t, first.Close())
111+ again, err := l.NewSurface(7)
112+ require.NoError(t, err, "after closing the first one the output is free again")
113+ require.NoError(t, again.Close())
114+}
115+
116+// watchLock records the ext_session_lock_v1 id the peer saw requested and
117+// counts its destroy requests.
118+func watchLock(t *testing.T, srv *wireServer) (id func() uint32, destroys *atomic.Int32) {
119+ t.Helper()
120+ var lockID atomic.Uint32
121+ destroys = new(atomic.Int32)
122+ srv.mu.Lock()
123+ srv.hook = func(obj uint32, op uint16, body []byte) {
124+ srv.mu.Lock()
125+ manager := srv.bound[6].id // the lock manager is bound by the first Lock call
126+ srv.mu.Unlock()
127+ switch {
128+ case obj == manager && op == 1 && len(body) == 4: // lock(new_id)
129+ lockID.Store(binary.LittleEndian.Uint32(body))
130+ case obj == lockID.Load() && obj != 0 && op == 0: // destroy
131+ destroys.Add(1)
132+ }
133+ }
134+ srv.mu.Unlock()
135+ return lockID.Load, destroys
136+}
137+
138+// GO-006: a locked event already on the wire when Close runs wins: destroying
139+// a locked lock is a protocol error, so Close reports it instead.
140+func TestLockCloseSeesLockedInFlight(t *testing.T) {
141+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
142+ lockID, destroys := watchLock(t, srv)
143+
144+ l, err := c.Lock()
145+ require.NoError(t, err)
146+ require.NoError(t, c.Roundtrip()) // the peer saw the lock request
147+ srv.write(frame(lockID(), 0, nil)) // ext_session_lock_v1.locked, not yet dispatched
148+
149+ require.ErrorContains(t, l.Close(), "use Unlock")
150+ require.Zero(t, destroys.Load(), "no destroy request was sent")
151+ require.True(t, l.Locked(), "treated as locked")
152+ require.NoError(t, c.Roundtrip())
153+ require.Zero(t, destroys.Load())
154+}
155+
156+func TestLockCloseDestroysUnlockedLock(t *testing.T) {
157+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
158+ _, destroys := watchLock(t, srv)
159+ l, err := c.Lock()
160+ require.NoError(t, err)
161+ require.NoError(t, l.Close())
162+ require.NoError(t, c.Roundtrip())
163+ require.Equal(t, int32(1), destroys.Load())
164+ require.NoError(t, l.Close(), "idempotent")
165+}
166+
167+// GO-002 reproduction, enabled once wlturbo handles delete_id for objects the
168+// server creates: closing a surface with a pending frame callback must not turn
169+// the compositor's later callback done and delete_id into a transport error.
170+func TestCloseWithPendingFrame(t *testing.T) {
171+ t.Skip("needs wlturbo server-side delete_id handling")
172+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
173+ s, err := c.NewToplevel("close", 64, 48)
174+ require.NoError(t, err)
175+ s.MarkPresentable(64, 48)
176+ surfaceID := s.SurfaceObjectID()
177+ var callback atomic.Uint32
178+ srv.mu.Lock()
179+ srv.hook = func(obj uint32, op uint16, body []byte) {
180+ if obj == surfaceID && op == 3 && len(body) == 4 {
181+ callback.Store(binary.LittleEndian.Uint32(body))
182+ }
183+ }
184+ srv.mu.Unlock()
185+ memfd, err := unix.MemfdCreate("close", unix.MFD_CLOEXEC)
186+ require.NoError(t, err)
187+ defer unix.Close(memfd)
188+ require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
189+ Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
190+ require.NoError(t, s.ImportTimeline(1, memfd))
191+ require.NoError(t, s.ImportTimeline(2, memfd))
192+ require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2}))
193+ require.NoError(t, c.Roundtrip())
194+ require.NoError(t, s.Close())
195+
196+ srv.write(frame(callback.Load(), 0, appendU32(nil, 1)))
197+ srv.write(frame(1, 1, appendU32(nil, callback.Load())))
198+ require.NoError(t, c.Roundtrip())
199+ require.NoError(t, c.Dispatch(nil))
200+}
M wire_test.go +4 -7
@@ -138,7 +138,11 @@ func (s *wireServer) serve() {
138138 func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
139139 s.mu.Lock()
140140 reg := s.registry
141+ hook := s.hook
141142 s.mu.Unlock()
143+ if hook != nil && obj != 1 && obj != reg {
144+ hook(obj, op, body) // every request on a client-created or bound object
145+ }
142146 switch {
143147 case obj == 1 && op == 1: // get_registry
144148 id := binary.LittleEndian.Uint32(body)
@@ -172,13 +176,6 @@ func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
172176 }
173177 case op == 0 && len(body) == 0: // wl_output.release (destructor, no args)
174178 s.write(frame(1, 1, appendU32(nil, obj)))
175- default:
176- s.mu.Lock()
177- hook := s.hook
178- s.mu.Unlock()
179- if hook != nil {
180- hook(obj, op, body)
181- }
182179 }
183180 }
184181
@@ -138,7 +138,11 @@ func (s *wireServer) serve() {
138 func (s *wireServer) handle(obj uint32, op uint16, body []byte) {138 func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
139 s.mu.Lock()139 s.mu.Lock()
140 reg := s.registry140 reg := s.registry
141+ hook := s.hook
141 s.mu.Unlock()142 s.mu.Unlock()
143+ if hook != nil && obj != 1 && obj != reg {
144+ hook(obj, op, body) // every request on a client-created or bound object
145+ }
142 switch {146 switch {
143 case obj == 1 && op == 1: // get_registry147 case obj == 1 && op == 1: // get_registry
144 id := binary.LittleEndian.Uint32(body)148 id := binary.LittleEndian.Uint32(body)
@@ -172,13 +176,6 @@ func (s *wireServer) handle(obj uint32, op uint16, body []byte) {
172 }176 }
173 case op == 0 && len(body) == 0: // wl_output.release (destructor, no args)177 case op == 0 && len(body) == 0: // wl_output.release (destructor, no args)
174 s.write(frame(1, 1, appendU32(nil, obj)))178 s.write(frame(1, 1, appendU32(nil, obj)))
175- default:
176- s.mu.Lock()
177- hook := s.hook
178- s.mu.Unlock()
179- if hook != nil {
180- hook(obj, op, body)
181- }
182 }179 }
183 }180 }
184 181

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 4

↑↓ moveenter openesc close