detached 834a1c2

refactor(app): extract DRM lease publishing into leasePublisher

Headless also drops its joinErr helper for errors.Join, so a failed stop no longer hides the wait error.
commit
834a1c22370110fe7cd69d266cc9e8806f84c06c
author
brice · · 2026-10-08 05:49 UTC
committer
brice · · 2026-10-08 05:50 UTC
parent
1e618e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +448 -75
M .mockery.yml +5 -0
@@ -91,6 +91,11 @@ packages:
9191 dir: internal/app
9292 pkgname: app
9393 filename: seatdisable_mock_test.go
94+ leaseInventoryCard:
95+ config:
96+ dir: internal/app
97+ pkgname: app
98+ filename: leaseinventory_mock_test.go
9499 github.com/bnema/neferwl/internal/adapters/statefile:
95100 interfaces:
96101 procTree:
@@ -91,6 +91,11 @@ packages:
91 dir: internal/app91 dir: internal/app
92 pkgname: app92 pkgname: app
93 filename: seatdisable_mock_test.go93 filename: seatdisable_mock_test.go
94+ leaseInventoryCard:
95+ config:
96+ dir: internal/app
97+ pkgname: app
98+ filename: leaseinventory_mock_test.go
94 github.com/bnema/neferwl/internal/adapters/statefile:99 github.com/bnema/neferwl/internal/adapters/statefile:
95 interfaces:100 interfaces:
96 procTree:101 procTree:
M internal/app/drm.go +13 -66
@@ -8,7 +8,6 @@ import (
88 "path/filepath"
99 "runtime"
1010 "runtime/debug"
11- "slices"
1211 "sort"
1312 "sync"
1413 "sync/atomic"
@@ -22,7 +21,6 @@ import (
2221 "github.com/bnema/neferwl/internal/adapters/seat"
2322 "github.com/bnema/neferwl/internal/ports"
2423 "github.com/bnema/zerowrap"
25- "golang.org/x/sys/unix"
2624 )
2725
2826 var errTimeout = errors.New("timeout")
@@ -307,59 +305,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
307305 set := newOutputSet(ctx, ch.captured)
308306 set.wireSecurity(ch)
309307 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
310- clientFDs := map[*drmCard]*os.File{}
311- lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}
312- defer func() {
313- for _, f := range clientFDs {
314- f.Close()
315- }
316- }()
317- sendLease := func(msg ports.LeaseMessage) {
318- select {
319- case ch.leaseEvents <- msg:
320- case <-ctx.Done():
321- ports.CloseLeaseFiles(msg)
322- }
323- }
324- publishLeases := func(c *drmCard) {
325- for _, id := range c.FinishedLeases() {
326- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
327- }
328- var next []ports.LeaseConnector
329- if seatActive && !protected() {
330- next = c.Leasable()
331- }
332- if len(next) == 0 {
333- if protected() || clientFDs[c] != nil {
334- sendLease(ports.LeaseConnectors{Card: c.Path()})
335- }
336- if clientFDs[c] != nil {
337- clientFDs[c].Close()
338- delete(clientFDs, c)
339- }
340- delete(lastLeaseConnectors, c)
341- return
342- }
343- if slices.Equal(lastLeaseConnectors[c], next) && clientFDs[c] != nil {
344- return
345- }
346- if clientFDs[c] == nil {
347- f, err := c.ClientFD()
348- if err != nil {
349- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
350- return
351- }
352- clientFDs[c] = f
353- }
354- // Each message owns its fd: a queued inventory outlives clientFDs[c].
355- fd, err := unix.FcntlInt(clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
356- if err != nil {
357- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
358- return
359- }
360- lastLeaseConnectors[c] = next
361- sendLease(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
362- }
308+ leases := newLeasePublisher(ctx, ch.leaseEvents, func() bool { return seatActive }, protected, log)
309+ defer leases.close()
363310 hotplug := make(chan struct{}, 1)
364311 go func() {
365312 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
@@ -408,9 +355,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
408355 var errs []error
409356 for _, c := range b.cards {
410357 errs = append(errs, revokeSecurityLeases(c, func(id uint32) {
411- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
358+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
412359 }))
413- publishLeases(c)
360+ leases.publish(c)
414361 }
415362 errs = append(errs, discardedOutputErr)
416363 err := errors.Join(errs...)
@@ -460,7 +407,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
460407 scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
461408 continue
462409 }
463- publishLeases(c)
410+ leases.publish(c)
464411 stop := func(name string, restart bool) {
465412 if r := set.outs[name]; r != nil && cards[name] == c {
466413 if _, ok := stopping[name]; !ok {
@@ -640,7 +587,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
640587 barrier()
641588 } else {
642589 for _, c := range b.cards {
643- publishLeases(c)
590+ leases.publish(c)
644591 }
645592 }
646593 case <-revokeDeadline:
@@ -664,14 +611,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
664611 if err := c.Revoke(id); err != nil {
665612 log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")
666613 } else {
667- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
614+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
668615 }
669616 }
670- publishLeases(c)
617+ leases.publish(c)
671618 }
672619 } else {
673620 for _, c := range b.cards {
674- publishLeases(c)
621+ leases.publish(c)
675622 }
676623 }
677624 case event := <-ch.leaseRequests:
@@ -680,24 +627,24 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
680627 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
681628 if !seatActive || protected() {
682629 reply.Err = errors.New("DRM seat inactive or session protected")
683- sendLease(reply)
630+ leases.send(reply)
684631 break
685632 }
686633 for _, c := range b.cards {
687634 if c.Path() == req.Card {
688635 reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
689- publishLeases(c)
636+ leases.publish(c)
690637 break
691638 }
692639 }
693- sendLease(reply)
640+ leases.send(reply)
694641 case ports.LeaseRevoke:
695642 for _, c := range b.cards {
696643 if c.Path() == req.Card {
697644 if err := c.Revoke(req.LeaseID); err != nil {
698645 log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")
699646 } else {
700- publishLeases(c)
647+ leases.publish(c)
701648 }
702649 break
703650 }
@@ -8,7 +8,6 @@ import (
8 "path/filepath"8 "path/filepath"
9 "runtime"9 "runtime"
10 "runtime/debug"10 "runtime/debug"
11- "slices"
12 "sort"11 "sort"
13 "sync"12 "sync"
14 "sync/atomic"13 "sync/atomic"
@@ -22,7 +21,6 @@ import (
22 "github.com/bnema/neferwl/internal/adapters/seat"21 "github.com/bnema/neferwl/internal/adapters/seat"
23 "github.com/bnema/neferwl/internal/ports"22 "github.com/bnema/neferwl/internal/ports"
24 "github.com/bnema/zerowrap"23 "github.com/bnema/zerowrap"
25- "golang.org/x/sys/unix"
26 )24 )
27 25
28 var errTimeout = errors.New("timeout")26 var errTimeout = errors.New("timeout")
@@ -307,59 +305,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
307 set := newOutputSet(ctx, ch.captured)305 set := newOutputSet(ctx, ch.captured)
308 set.wireSecurity(ch)306 set.wireSecurity(ch)
309 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }307 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
310- clientFDs := map[*drmCard]*os.File{}308+ leases := newLeasePublisher(ctx, ch.leaseEvents, func() bool { return seatActive }, protected, log)
311- lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}309+ defer leases.close()
312- defer func() {
313- for _, f := range clientFDs {
314- f.Close()
315- }
316- }()
317- sendLease := func(msg ports.LeaseMessage) {
318- select {
319- case ch.leaseEvents <- msg:
320- case <-ctx.Done():
321- ports.CloseLeaseFiles(msg)
322- }
323- }
324- publishLeases := func(c *drmCard) {
325- for _, id := range c.FinishedLeases() {
326- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
327- }
328- var next []ports.LeaseConnector
329- if seatActive && !protected() {
330- next = c.Leasable()
331- }
332- if len(next) == 0 {
333- if protected() || clientFDs[c] != nil {
334- sendLease(ports.LeaseConnectors{Card: c.Path()})
335- }
336- if clientFDs[c] != nil {
337- clientFDs[c].Close()
338- delete(clientFDs, c)
339- }
340- delete(lastLeaseConnectors, c)
341- return
342- }
343- if slices.Equal(lastLeaseConnectors[c], next) && clientFDs[c] != nil {
344- return
345- }
346- if clientFDs[c] == nil {
347- f, err := c.ClientFD()
348- if err != nil {
349- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
350- return
351- }
352- clientFDs[c] = f
353- }
354- // Each message owns its fd: a queued inventory outlives clientFDs[c].
355- fd, err := unix.FcntlInt(clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
356- if err != nil {
357- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
358- return
359- }
360- lastLeaseConnectors[c] = next
361- sendLease(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
362- }
363 hotplug := make(chan struct{}, 1)310 hotplug := make(chan struct{}, 1)
364 go func() {311 go func() {
365 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {312 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
@@ -408,9 +355,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
408 var errs []error355 var errs []error
409 for _, c := range b.cards {356 for _, c := range b.cards {
410 errs = append(errs, revokeSecurityLeases(c, func(id uint32) {357 errs = append(errs, revokeSecurityLeases(c, func(id uint32) {
411- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})358+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
412 }))359 }))
413- publishLeases(c)360+ leases.publish(c)
414 }361 }
415 errs = append(errs, discardedOutputErr)362 errs = append(errs, discardedOutputErr)
416 err := errors.Join(errs...)363 err := errors.Join(errs...)
@@ -460,7 +407,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
460 scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))407 scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
461 continue408 continue
462 }409 }
463- publishLeases(c)410+ leases.publish(c)
464 stop := func(name string, restart bool) {411 stop := func(name string, restart bool) {
465 if r := set.outs[name]; r != nil && cards[name] == c {412 if r := set.outs[name]; r != nil && cards[name] == c {
466 if _, ok := stopping[name]; !ok {413 if _, ok := stopping[name]; !ok {
@@ -640,7 +587,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
640 barrier()587 barrier()
641 } else {588 } else {
642 for _, c := range b.cards {589 for _, c := range b.cards {
643- publishLeases(c)590+ leases.publish(c)
644 }591 }
645 }592 }
646 case <-revokeDeadline:593 case <-revokeDeadline:
@@ -664,14 +611,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
664 if err := c.Revoke(id); err != nil {611 if err := c.Revoke(id); err != nil {
665 log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")612 log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")
666 } else {613 } else {
667- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})614+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
668 }615 }
669 }616 }
670- publishLeases(c)617+ leases.publish(c)
671 }618 }
672 } else {619 } else {
673 for _, c := range b.cards {620 for _, c := range b.cards {
674- publishLeases(c)621+ leases.publish(c)
675 }622 }
676 }623 }
677 case event := <-ch.leaseRequests:624 case event := <-ch.leaseRequests:
@@ -680,24 +627,24 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
680 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}627 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
681 if !seatActive || protected() {628 if !seatActive || protected() {
682 reply.Err = errors.New("DRM seat inactive or session protected")629 reply.Err = errors.New("DRM seat inactive or session protected")
683- sendLease(reply)630+ leases.send(reply)
684 break631 break
685 }632 }
686 for _, c := range b.cards {633 for _, c := range b.cards {
687 if c.Path() == req.Card {634 if c.Path() == req.Card {
688 reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)635 reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
689- publishLeases(c)636+ leases.publish(c)
690 break637 break
691 }638 }
692 }639 }
693- sendLease(reply)640+ leases.send(reply)
694 case ports.LeaseRevoke:641 case ports.LeaseRevoke:
695 for _, c := range b.cards {642 for _, c := range b.cards {
696 if c.Path() == req.Card {643 if c.Path() == req.Card {
697 if err := c.Revoke(req.LeaseID); err != nil {644 if err := c.Revoke(req.LeaseID); err != nil {
698 log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")645 log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")
699 } else {646 } else {
700- publishLeases(c)647+ leases.publish(c)
701 }648 }
702 break649 break
703 }650 }
M internal/app/headless.go +3 -9
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -37,7 +38,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
3738 dir = filepath.Join(o.shots, name)
3839 if err := os.MkdirAll(dir, 0o755); err != nil {
3940 // Outputs already started stop and release their captures.
40- return joinErr(err, set.wait())
41+ return errors.Join(err, set.wait())
4142 }
4243 }
4344 cur := &headless.Cursor{}
@@ -105,14 +106,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
105106 }
106107 // An owner error is not protection or removal evidence.
107108 err := set.finish(stopped.name)
108- return joinErr(err, set.wait())
109+ return errors.Join(err, set.wait())
109110 }
110111 }
111112 }
112-
113-func joinErr(a, b error) error {
114- if a != nil {
115- return a
116- }
117- return b
118-}
@@ -2,6 +2,7 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -37,7 +38,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
37 dir = filepath.Join(o.shots, name)38 dir = filepath.Join(o.shots, name)
38 if err := os.MkdirAll(dir, 0o755); err != nil {39 if err := os.MkdirAll(dir, 0o755); err != nil {
39 // Outputs already started stop and release their captures.40 // Outputs already started stop and release their captures.
40- return joinErr(err, set.wait())41+ return errors.Join(err, set.wait())
41 }42 }
42 }43 }
43 cur := &headless.Cursor{}44 cur := &headless.Cursor{}
@@ -105,14 +106,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
105 }106 }
106 // An owner error is not protection or removal evidence.107 // An owner error is not protection or removal evidence.
107 err := set.finish(stopped.name)108 err := set.finish(stopped.name)
108- return joinErr(err, set.wait())109+ return errors.Join(err, set.wait())
109 }110 }
110 }111 }
111 }112 }
112-
113-func joinErr(a, b error) error {
114- if a != nil {
115- return a
116- }
117- return b
118-}
A internal/app/leaseinventory_mock_test.go +239 -0
@@ -0,0 +1,239 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "os"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockleaseInventoryCard creates a new instance of mockleaseInventoryCard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockleaseInventoryCard(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockleaseInventoryCard {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockleaseInventoryCard{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockleaseInventoryCard is an autogenerated mock type for the leaseInventoryCard type
38+type mockleaseInventoryCard struct {
39+ mock.Mock
40+}
41+
42+type mockleaseInventoryCard_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockleaseInventoryCard) EXPECT() *mockleaseInventoryCard_Expecter {
47+ return &mockleaseInventoryCard_Expecter{mock: &_m.Mock}
48+}
49+
50+// ClientFD provides a mock function for the type mockleaseInventoryCard
51+func (_mock *mockleaseInventoryCard) ClientFD() (*os.File, error) {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ClientFD")
56+ }
57+
58+ var r0 *os.File
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func() (*os.File, error)); ok {
61+ return returnFunc()
62+ }
63+ if returnFunc, ok := ret.Get(0).(func() *os.File); ok {
64+ r0 = returnFunc()
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).(*os.File)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func() error); ok {
71+ r1 = returnFunc()
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// mockleaseInventoryCard_ClientFD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientFD'
79+type mockleaseInventoryCard_ClientFD_Call struct {
80+ *mock.Call
81+}
82+
83+// ClientFD is a helper method to define mock.On call
84+func (_e *mockleaseInventoryCard_Expecter) ClientFD() *mockleaseInventoryCard_ClientFD_Call {
85+ return &mockleaseInventoryCard_ClientFD_Call{Call: _e.mock.On("ClientFD")}
86+}
87+
88+func (_c *mockleaseInventoryCard_ClientFD_Call) Run(run func()) *mockleaseInventoryCard_ClientFD_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ run()
91+ })
92+ return _c
93+}
94+
95+func (_c *mockleaseInventoryCard_ClientFD_Call) Return(file *os.File, err error) *mockleaseInventoryCard_ClientFD_Call {
96+ _c.Call.Return(file, err)
97+ return _c
98+}
99+
100+func (_c *mockleaseInventoryCard_ClientFD_Call) RunAndReturn(run func() (*os.File, error)) *mockleaseInventoryCard_ClientFD_Call {
101+ _c.Call.Return(run)
102+ return _c
103+}
104+
105+// FinishedLeases provides a mock function for the type mockleaseInventoryCard
106+func (_mock *mockleaseInventoryCard) FinishedLeases() []uint32 {
107+ ret := _mock.Called()
108+
109+ if len(ret) == 0 {
110+ panic("no return value specified for FinishedLeases")
111+ }
112+
113+ var r0 []uint32
114+ if returnFunc, ok := ret.Get(0).(func() []uint32); ok {
115+ r0 = returnFunc()
116+ } else {
117+ if ret.Get(0) != nil {
118+ r0 = ret.Get(0).([]uint32)
119+ }
120+ }
121+ return r0
122+}
123+
124+// mockleaseInventoryCard_FinishedLeases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishedLeases'
125+type mockleaseInventoryCard_FinishedLeases_Call struct {
126+ *mock.Call
127+}
128+
129+// FinishedLeases is a helper method to define mock.On call
130+func (_e *mockleaseInventoryCard_Expecter) FinishedLeases() *mockleaseInventoryCard_FinishedLeases_Call {
131+ return &mockleaseInventoryCard_FinishedLeases_Call{Call: _e.mock.On("FinishedLeases")}
132+}
133+
134+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Run(run func()) *mockleaseInventoryCard_FinishedLeases_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ run()
137+ })
138+ return _c
139+}
140+
141+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Return(uint32s []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
142+ _c.Call.Return(uint32s)
143+ return _c
144+}
145+
146+func (_c *mockleaseInventoryCard_FinishedLeases_Call) RunAndReturn(run func() []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
147+ _c.Call.Return(run)
148+ return _c
149+}
150+
151+// Leasable provides a mock function for the type mockleaseInventoryCard
152+func (_mock *mockleaseInventoryCard) Leasable() []ports.LeaseConnector {
153+ ret := _mock.Called()
154+
155+ if len(ret) == 0 {
156+ panic("no return value specified for Leasable")
157+ }
158+
159+ var r0 []ports.LeaseConnector
160+ if returnFunc, ok := ret.Get(0).(func() []ports.LeaseConnector); ok {
161+ r0 = returnFunc()
162+ } else {
163+ if ret.Get(0) != nil {
164+ r0 = ret.Get(0).([]ports.LeaseConnector)
165+ }
166+ }
167+ return r0
168+}
169+
170+// mockleaseInventoryCard_Leasable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Leasable'
171+type mockleaseInventoryCard_Leasable_Call struct {
172+ *mock.Call
173+}
174+
175+// Leasable is a helper method to define mock.On call
176+func (_e *mockleaseInventoryCard_Expecter) Leasable() *mockleaseInventoryCard_Leasable_Call {
177+ return &mockleaseInventoryCard_Leasable_Call{Call: _e.mock.On("Leasable")}
178+}
179+
180+func (_c *mockleaseInventoryCard_Leasable_Call) Run(run func()) *mockleaseInventoryCard_Leasable_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ run()
183+ })
184+ return _c
185+}
186+
187+func (_c *mockleaseInventoryCard_Leasable_Call) Return(leaseConnectors []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
188+ _c.Call.Return(leaseConnectors)
189+ return _c
190+}
191+
192+func (_c *mockleaseInventoryCard_Leasable_Call) RunAndReturn(run func() []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
193+ _c.Call.Return(run)
194+ return _c
195+}
196+
197+// Path provides a mock function for the type mockleaseInventoryCard
198+func (_mock *mockleaseInventoryCard) Path() string {
199+ ret := _mock.Called()
200+
201+ if len(ret) == 0 {
202+ panic("no return value specified for Path")
203+ }
204+
205+ var r0 string
206+ if returnFunc, ok := ret.Get(0).(func() string); ok {
207+ r0 = returnFunc()
208+ } else {
209+ r0 = ret.Get(0).(string)
210+ }
211+ return r0
212+}
213+
214+// mockleaseInventoryCard_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
215+type mockleaseInventoryCard_Path_Call struct {
216+ *mock.Call
217+}
218+
219+// Path is a helper method to define mock.On call
220+func (_e *mockleaseInventoryCard_Expecter) Path() *mockleaseInventoryCard_Path_Call {
221+ return &mockleaseInventoryCard_Path_Call{Call: _e.mock.On("Path")}
222+}
223+
224+func (_c *mockleaseInventoryCard_Path_Call) Run(run func()) *mockleaseInventoryCard_Path_Call {
225+ _c.Call.Run(func(args mock.Arguments) {
226+ run()
227+ })
228+ return _c
229+}
230+
231+func (_c *mockleaseInventoryCard_Path_Call) Return(s string) *mockleaseInventoryCard_Path_Call {
232+ _c.Call.Return(s)
233+ return _c
234+}
235+
236+func (_c *mockleaseInventoryCard_Path_Call) RunAndReturn(run func() string) *mockleaseInventoryCard_Path_Call {
237+ _c.Call.Return(run)
238+ return _c
239+}
@@ -0,0 +1,239 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "os"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockleaseInventoryCard creates a new instance of mockleaseInventoryCard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockleaseInventoryCard(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockleaseInventoryCard {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockleaseInventoryCard{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockleaseInventoryCard is an autogenerated mock type for the leaseInventoryCard type
38+type mockleaseInventoryCard struct {
39+ mock.Mock
40+}
41+
42+type mockleaseInventoryCard_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockleaseInventoryCard) EXPECT() *mockleaseInventoryCard_Expecter {
47+ return &mockleaseInventoryCard_Expecter{mock: &_m.Mock}
48+}
49+
50+// ClientFD provides a mock function for the type mockleaseInventoryCard
51+func (_mock *mockleaseInventoryCard) ClientFD() (*os.File, error) {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ClientFD")
56+ }
57+
58+ var r0 *os.File
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func() (*os.File, error)); ok {
61+ return returnFunc()
62+ }
63+ if returnFunc, ok := ret.Get(0).(func() *os.File); ok {
64+ r0 = returnFunc()
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).(*os.File)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func() error); ok {
71+ r1 = returnFunc()
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// mockleaseInventoryCard_ClientFD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientFD'
79+type mockleaseInventoryCard_ClientFD_Call struct {
80+ *mock.Call
81+}
82+
83+// ClientFD is a helper method to define mock.On call
84+func (_e *mockleaseInventoryCard_Expecter) ClientFD() *mockleaseInventoryCard_ClientFD_Call {
85+ return &mockleaseInventoryCard_ClientFD_Call{Call: _e.mock.On("ClientFD")}
86+}
87+
88+func (_c *mockleaseInventoryCard_ClientFD_Call) Run(run func()) *mockleaseInventoryCard_ClientFD_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ run()
91+ })
92+ return _c
93+}
94+
95+func (_c *mockleaseInventoryCard_ClientFD_Call) Return(file *os.File, err error) *mockleaseInventoryCard_ClientFD_Call {
96+ _c.Call.Return(file, err)
97+ return _c
98+}
99+
100+func (_c *mockleaseInventoryCard_ClientFD_Call) RunAndReturn(run func() (*os.File, error)) *mockleaseInventoryCard_ClientFD_Call {
101+ _c.Call.Return(run)
102+ return _c
103+}
104+
105+// FinishedLeases provides a mock function for the type mockleaseInventoryCard
106+func (_mock *mockleaseInventoryCard) FinishedLeases() []uint32 {
107+ ret := _mock.Called()
108+
109+ if len(ret) == 0 {
110+ panic("no return value specified for FinishedLeases")
111+ }
112+
113+ var r0 []uint32
114+ if returnFunc, ok := ret.Get(0).(func() []uint32); ok {
115+ r0 = returnFunc()
116+ } else {
117+ if ret.Get(0) != nil {
118+ r0 = ret.Get(0).([]uint32)
119+ }
120+ }
121+ return r0
122+}
123+
124+// mockleaseInventoryCard_FinishedLeases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishedLeases'
125+type mockleaseInventoryCard_FinishedLeases_Call struct {
126+ *mock.Call
127+}
128+
129+// FinishedLeases is a helper method to define mock.On call
130+func (_e *mockleaseInventoryCard_Expecter) FinishedLeases() *mockleaseInventoryCard_FinishedLeases_Call {
131+ return &mockleaseInventoryCard_FinishedLeases_Call{Call: _e.mock.On("FinishedLeases")}
132+}
133+
134+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Run(run func()) *mockleaseInventoryCard_FinishedLeases_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ run()
137+ })
138+ return _c
139+}
140+
141+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Return(uint32s []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
142+ _c.Call.Return(uint32s)
143+ return _c
144+}
145+
146+func (_c *mockleaseInventoryCard_FinishedLeases_Call) RunAndReturn(run func() []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
147+ _c.Call.Return(run)
148+ return _c
149+}
150+
151+// Leasable provides a mock function for the type mockleaseInventoryCard
152+func (_mock *mockleaseInventoryCard) Leasable() []ports.LeaseConnector {
153+ ret := _mock.Called()
154+
155+ if len(ret) == 0 {
156+ panic("no return value specified for Leasable")
157+ }
158+
159+ var r0 []ports.LeaseConnector
160+ if returnFunc, ok := ret.Get(0).(func() []ports.LeaseConnector); ok {
161+ r0 = returnFunc()
162+ } else {
163+ if ret.Get(0) != nil {
164+ r0 = ret.Get(0).([]ports.LeaseConnector)
165+ }
166+ }
167+ return r0
168+}
169+
170+// mockleaseInventoryCard_Leasable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Leasable'
171+type mockleaseInventoryCard_Leasable_Call struct {
172+ *mock.Call
173+}
174+
175+// Leasable is a helper method to define mock.On call
176+func (_e *mockleaseInventoryCard_Expecter) Leasable() *mockleaseInventoryCard_Leasable_Call {
177+ return &mockleaseInventoryCard_Leasable_Call{Call: _e.mock.On("Leasable")}
178+}
179+
180+func (_c *mockleaseInventoryCard_Leasable_Call) Run(run func()) *mockleaseInventoryCard_Leasable_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ run()
183+ })
184+ return _c
185+}
186+
187+func (_c *mockleaseInventoryCard_Leasable_Call) Return(leaseConnectors []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
188+ _c.Call.Return(leaseConnectors)
189+ return _c
190+}
191+
192+func (_c *mockleaseInventoryCard_Leasable_Call) RunAndReturn(run func() []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
193+ _c.Call.Return(run)
194+ return _c
195+}
196+
197+// Path provides a mock function for the type mockleaseInventoryCard
198+func (_mock *mockleaseInventoryCard) Path() string {
199+ ret := _mock.Called()
200+
201+ if len(ret) == 0 {
202+ panic("no return value specified for Path")
203+ }
204+
205+ var r0 string
206+ if returnFunc, ok := ret.Get(0).(func() string); ok {
207+ r0 = returnFunc()
208+ } else {
209+ r0 = ret.Get(0).(string)
210+ }
211+ return r0
212+}
213+
214+// mockleaseInventoryCard_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
215+type mockleaseInventoryCard_Path_Call struct {
216+ *mock.Call
217+}
218+
219+// Path is a helper method to define mock.On call
220+func (_e *mockleaseInventoryCard_Expecter) Path() *mockleaseInventoryCard_Path_Call {
221+ return &mockleaseInventoryCard_Path_Call{Call: _e.mock.On("Path")}
222+}
223+
224+func (_c *mockleaseInventoryCard_Path_Call) Run(run func()) *mockleaseInventoryCard_Path_Call {
225+ _c.Call.Run(func(args mock.Arguments) {
226+ run()
227+ })
228+ return _c
229+}
230+
231+func (_c *mockleaseInventoryCard_Path_Call) Return(s string) *mockleaseInventoryCard_Path_Call {
232+ _c.Call.Return(s)
233+ return _c
234+}
235+
236+func (_c *mockleaseInventoryCard_Path_Call) RunAndReturn(run func() string) *mockleaseInventoryCard_Path_Call {
237+ _c.Call.Return(run)
238+ return _c
239+}
A internal/app/leases.go +98 -0
@@ -0,0 +1,98 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerowrap"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// leaseInventoryCard is what the lease publisher reads from a DRM card.
14+type leaseInventoryCard interface {
15+ Path() string
16+ FinishedLeases() []uint32
17+ Leasable() []ports.LeaseConnector
18+ ClientFD() (*os.File, error)
19+}
20+
21+// leasePublisher tells wayland which connectors of each card can be leased
22+// (wp_drm_lease_device_v1), and which leases ended. It keeps one non-master
23+// client fd per advertised card and resends an inventory only when it
24+// changed. The DRM backend loop owns it.
25+type leasePublisher struct {
26+ ctx context.Context
27+ events chan<- ports.LeaseMessage
28+ log zerowrap.Logger
29+ // seatActive and protected say whether leases may be offered: only on an
30+ // active seat in an unprotected session.
31+ seatActive, protected func() bool
32+ clientFDs map[leaseInventoryCard]*os.File
33+ last map[leaseInventoryCard][]ports.LeaseConnector
34+}
35+
36+func newLeasePublisher(ctx context.Context, events chan<- ports.LeaseMessage, seatActive, protected func() bool, log zerowrap.Logger) *leasePublisher {
37+ return &leasePublisher{ctx: ctx, events: events, seatActive: seatActive, protected: protected, log: log, clientFDs: map[leaseInventoryCard]*os.File{}, last: map[leaseInventoryCard][]ports.LeaseConnector{}}
38+}
39+
40+// send delivers msg, closing its files when ctx ends first.
41+func (p *leasePublisher) send(msg ports.LeaseMessage) {
42+ select {
43+ case p.events <- msg:
44+ case <-p.ctx.Done():
45+ ports.CloseLeaseFiles(msg)
46+ }
47+}
48+
49+// publish reports the finished leases of c and its leasable connectors. An
50+// inactive seat or a protected session withdraws the inventory; protection
51+// always tells wayland so.
52+func (p *leasePublisher) publish(c leaseInventoryCard) {
53+ for _, id := range c.FinishedLeases() {
54+ p.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
55+ }
56+ var next []ports.LeaseConnector
57+ if p.seatActive() && !p.protected() {
58+ next = c.Leasable()
59+ }
60+ if len(next) == 0 {
61+ if p.protected() || p.clientFDs[c] != nil {
62+ p.send(ports.LeaseConnectors{Card: c.Path()})
63+ }
64+ if p.clientFDs[c] != nil {
65+ p.clientFDs[c].Close()
66+ delete(p.clientFDs, c)
67+ }
68+ delete(p.last, c)
69+ return
70+ }
71+ if slices.Equal(p.last[c], next) && p.clientFDs[c] != nil {
72+ return
73+ }
74+ if p.clientFDs[c] == nil {
75+ f, err := c.ClientFD()
76+ if err != nil {
77+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
78+ return
79+ }
80+ p.clientFDs[c] = f
81+ }
82+ // Each message owns its fd: a queued inventory outlives clientFDs[c].
83+ fd, err := unix.FcntlInt(p.clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
84+ if err != nil {
85+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
86+ return
87+ }
88+ p.last[c] = next
89+ p.send(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
90+}
91+
92+// close releases the client fds.
93+func (p *leasePublisher) close() {
94+ for _, f := range p.clientFDs {
95+ f.Close()
96+ }
97+ clear(p.clientFDs)
98+}
@@ -0,0 +1,98 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerowrap"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// leaseInventoryCard is what the lease publisher reads from a DRM card.
14+type leaseInventoryCard interface {
15+ Path() string
16+ FinishedLeases() []uint32
17+ Leasable() []ports.LeaseConnector
18+ ClientFD() (*os.File, error)
19+}
20+
21+// leasePublisher tells wayland which connectors of each card can be leased
22+// (wp_drm_lease_device_v1), and which leases ended. It keeps one non-master
23+// client fd per advertised card and resends an inventory only when it
24+// changed. The DRM backend loop owns it.
25+type leasePublisher struct {
26+ ctx context.Context
27+ events chan<- ports.LeaseMessage
28+ log zerowrap.Logger
29+ // seatActive and protected say whether leases may be offered: only on an
30+ // active seat in an unprotected session.
31+ seatActive, protected func() bool
32+ clientFDs map[leaseInventoryCard]*os.File
33+ last map[leaseInventoryCard][]ports.LeaseConnector
34+}
35+
36+func newLeasePublisher(ctx context.Context, events chan<- ports.LeaseMessage, seatActive, protected func() bool, log zerowrap.Logger) *leasePublisher {
37+ return &leasePublisher{ctx: ctx, events: events, seatActive: seatActive, protected: protected, log: log, clientFDs: map[leaseInventoryCard]*os.File{}, last: map[leaseInventoryCard][]ports.LeaseConnector{}}
38+}
39+
40+// send delivers msg, closing its files when ctx ends first.
41+func (p *leasePublisher) send(msg ports.LeaseMessage) {
42+ select {
43+ case p.events <- msg:
44+ case <-p.ctx.Done():
45+ ports.CloseLeaseFiles(msg)
46+ }
47+}
48+
49+// publish reports the finished leases of c and its leasable connectors. An
50+// inactive seat or a protected session withdraws the inventory; protection
51+// always tells wayland so.
52+func (p *leasePublisher) publish(c leaseInventoryCard) {
53+ for _, id := range c.FinishedLeases() {
54+ p.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
55+ }
56+ var next []ports.LeaseConnector
57+ if p.seatActive() && !p.protected() {
58+ next = c.Leasable()
59+ }
60+ if len(next) == 0 {
61+ if p.protected() || p.clientFDs[c] != nil {
62+ p.send(ports.LeaseConnectors{Card: c.Path()})
63+ }
64+ if p.clientFDs[c] != nil {
65+ p.clientFDs[c].Close()
66+ delete(p.clientFDs, c)
67+ }
68+ delete(p.last, c)
69+ return
70+ }
71+ if slices.Equal(p.last[c], next) && p.clientFDs[c] != nil {
72+ return
73+ }
74+ if p.clientFDs[c] == nil {
75+ f, err := c.ClientFD()
76+ if err != nil {
77+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
78+ return
79+ }
80+ p.clientFDs[c] = f
81+ }
82+ // Each message owns its fd: a queued inventory outlives clientFDs[c].
83+ fd, err := unix.FcntlInt(p.clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
84+ if err != nil {
85+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
86+ return
87+ }
88+ p.last[c] = next
89+ p.send(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
90+}
91+
92+// close releases the client fds.
93+func (p *leasePublisher) close() {
94+ for _, f := range p.clientFDs {
95+ f.Close()
96+ }
97+ clear(p.clientFDs)
98+}
A internal/app/leases_test.go +90 -0
@@ -0,0 +1,90 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func leaseRig(t *testing.T, active, protected *bool) (*leasePublisher, chan ports.LeaseMessage, *mockleaseInventoryCard) {
13+ t.Helper()
14+ events := make(chan ports.LeaseMessage, 8)
15+ p := newLeasePublisher(context.Background(), events, func() bool { return *active }, func() bool { return *protected }, logging.For(context.Background(), "app"))
16+ t.Cleanup(p.close)
17+ card := newMockleaseInventoryCard(t)
18+ card.EXPECT().Path().Return("/dev/dri/card0").Maybe()
19+ return p, events, card
20+}
21+
22+func clientFile(t *testing.T) *os.File {
23+ t.Helper()
24+ f, err := os.CreateTemp(t.TempDir(), "card")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ return f
29+}
30+
31+// An inventory is sent once per change, each with its own fd, and finished
32+// leases go first.
33+func TestLeasePublisherSendsChangesOnly(t *testing.T) {
34+ active, protected := true, false
35+ p, events, card := leaseRig(t, &active, &protected)
36+ connectors := []ports.LeaseConnector{{Name: "HDMI-A-1"}}
37+ card.EXPECT().FinishedLeases().Return([]uint32{7}).Once()
38+ card.EXPECT().FinishedLeases().Return(nil)
39+ card.EXPECT().Leasable().Return(connectors)
40+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
41+
42+ p.publish(card)
43+ if fin, ok := (<-events).(ports.LeaseFinished); !ok || fin.LeaseID != 7 {
44+ t.Fatalf("finished lease first: %+v", fin)
45+ }
46+ inv, ok := (<-events).(ports.LeaseConnectors)
47+ if !ok || inv.Device == nil || len(inv.Connectors) != 1 {
48+ t.Fatalf("inventory: %+v", inv)
49+ }
50+ defer inv.Device.Close()
51+ if inv.Device == p.clientFDs[card] {
52+ t.Fatal("message shares the publisher's fd")
53+ }
54+ p.publish(card)
55+ if len(events) != 0 {
56+ t.Fatalf("unchanged inventory resent: %+v", <-events)
57+ }
58+}
59+
60+// Protection withdraws the inventory and closes the client fd, even when
61+// nothing was offered before.
62+func TestLeasePublisherProtectedWithdraws(t *testing.T) {
63+ active, protected := true, true
64+ p, events, card := leaseRig(t, &active, &protected)
65+ card.EXPECT().FinishedLeases().Return(nil)
66+ p.publish(card)
67+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil || len(inv.Connectors) != 0 {
68+ t.Fatalf("protected inventory: %+v", inv)
69+ }
70+
71+ protected = false
72+ card.EXPECT().Leasable().Return([]ports.LeaseConnector{{Name: "DP-2"}}).Once()
73+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
74+ p.publish(card)
75+ inv := (<-events).(ports.LeaseConnectors)
76+ inv.Device.Close()
77+ held := p.clientFDs[card]
78+
79+ active = false // an inactive seat withdraws what was offered
80+ p.publish(card)
81+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil {
82+ t.Fatalf("withdrawn inventory: %+v", inv)
83+ }
84+ if _, err := held.Stat(); err == nil {
85+ t.Fatal("client fd kept after withdrawal")
86+ }
87+ if p.clientFDs[card] != nil {
88+ t.Fatal("client fd still tracked")
89+ }
90+}
@@ -0,0 +1,90 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func leaseRig(t *testing.T, active, protected *bool) (*leasePublisher, chan ports.LeaseMessage, *mockleaseInventoryCard) {
13+ t.Helper()
14+ events := make(chan ports.LeaseMessage, 8)
15+ p := newLeasePublisher(context.Background(), events, func() bool { return *active }, func() bool { return *protected }, logging.For(context.Background(), "app"))
16+ t.Cleanup(p.close)
17+ card := newMockleaseInventoryCard(t)
18+ card.EXPECT().Path().Return("/dev/dri/card0").Maybe()
19+ return p, events, card
20+}
21+
22+func clientFile(t *testing.T) *os.File {
23+ t.Helper()
24+ f, err := os.CreateTemp(t.TempDir(), "card")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ return f
29+}
30+
31+// An inventory is sent once per change, each with its own fd, and finished
32+// leases go first.
33+func TestLeasePublisherSendsChangesOnly(t *testing.T) {
34+ active, protected := true, false
35+ p, events, card := leaseRig(t, &active, &protected)
36+ connectors := []ports.LeaseConnector{{Name: "HDMI-A-1"}}
37+ card.EXPECT().FinishedLeases().Return([]uint32{7}).Once()
38+ card.EXPECT().FinishedLeases().Return(nil)
39+ card.EXPECT().Leasable().Return(connectors)
40+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
41+
42+ p.publish(card)
43+ if fin, ok := (<-events).(ports.LeaseFinished); !ok || fin.LeaseID != 7 {
44+ t.Fatalf("finished lease first: %+v", fin)
45+ }
46+ inv, ok := (<-events).(ports.LeaseConnectors)
47+ if !ok || inv.Device == nil || len(inv.Connectors) != 1 {
48+ t.Fatalf("inventory: %+v", inv)
49+ }
50+ defer inv.Device.Close()
51+ if inv.Device == p.clientFDs[card] {
52+ t.Fatal("message shares the publisher's fd")
53+ }
54+ p.publish(card)
55+ if len(events) != 0 {
56+ t.Fatalf("unchanged inventory resent: %+v", <-events)
57+ }
58+}
59+
60+// Protection withdraws the inventory and closes the client fd, even when
61+// nothing was offered before.
62+func TestLeasePublisherProtectedWithdraws(t *testing.T) {
63+ active, protected := true, true
64+ p, events, card := leaseRig(t, &active, &protected)
65+ card.EXPECT().FinishedLeases().Return(nil)
66+ p.publish(card)
67+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil || len(inv.Connectors) != 0 {
68+ t.Fatalf("protected inventory: %+v", inv)
69+ }
70+
71+ protected = false
72+ card.EXPECT().Leasable().Return([]ports.LeaseConnector{{Name: "DP-2"}}).Once()
73+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
74+ p.publish(card)
75+ inv := (<-events).(ports.LeaseConnectors)
76+ inv.Device.Close()
77+ held := p.clientFDs[card]
78+
79+ active = false // an inactive seat withdraws what was offered
80+ p.publish(card)
81+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil {
82+ t.Fatalf("withdrawn inventory: %+v", inv)
83+ }
84+ if _, err := held.Stat(); err == nil {
85+ t.Fatal("client fd kept after withdrawal")
86+ }
87+ if p.clientFDs[card] != nil {
88+ t.Fatal("client fd still tracked")
89+ }
90+}

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 25

Tags 10

↑↓ moveenter openesc close