1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "path/filepath"
8 "runtime/debug"
9 "sort"
10 "sync"
11
12 "github.com/bnema/neferwl/internal/adapters/config"
13 "github.com/bnema/neferwl/internal/adapters/drm"
14 "github.com/bnema/neferwl/internal/adapters/seat"
15 "github.com/bnema/neferwl/internal/logging"
16 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"
18)
19
20var errTimeout = errors.New("timeout")
21
22// drmBackend is the seat and the cards with at least one usable output.
23type drmBackend struct {
24 seat *seat.Seat
25 cards []*drmCard
26}
27
28type drmCard struct {
29 *drm.Card
30 fd int
31}
32
33// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
34func safe(name string, fn func() error) (err error) {
35 defer func() {
36 if p := recover(); p != nil {
37 err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
38 }
39 }()
40 return fn()
41}
42
43// openDRM opens the seat and every card with a connected display. Outputs
44// are found by the first Scan of each card.
45func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
46 log := logging.For(ctx, "drm")
47 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
48 if err != nil {
49 log.Error().Err(err).Msg("open seat")
50 return nil, fmt.Errorf("open seat: %w", err)
51 }
52 want := wantFromConfig(cfg)
53 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
54 sort.Strings(paths)
55 b := &drmBackend{seat: s}
56 var errs []error
57 for _, path := range paths {
58 fd, err := s.OpenDevice(path)
59 if err != nil {
60 errs = append(errs, err)
61 continue
62 }
63 card, err := drm.OpenCard(fd, path, want, log)
64 if err != nil {
65 log.Warn().Err(err).Str("card", path).Msg("card unusable")
66 errs = append(errs, err)
67 s.CloseDevice(fd)
68 continue
69 }
70 b.cards = append(b.cards, &drmCard{Card: card, fd: fd})
71 }
72 if len(b.cards) == 0 {
73 s.Close()
74 err = fmt.Errorf("no usable DRM card in %v: %w", paths, errors.Join(errs...))
75 log.Error().Err(err).Msg("open drm")
76 return nil, err
77 }
78 return b, nil
79}
80
81// wantFromConfig turns output.<name> and render.* entries into connector choices.
82func wantFromConfig(cfg ports.Config) drm.Want {
83 r := cfg.Render
84 want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}
85 for _, o := range cfg.Outputs {
86 if o.Off {
87 want.Disabled[o.Name] = true
88 continue
89 }
90 if o.Mode != "" {
91 // Validated at config load.
92 w, h, hz, _ := config.ParseMode(o.Mode)
93 want.Modes[o.Name] = [3]float64{float64(w), float64(h), hz}
94 }
95 }
96 return want
97}
98
99// close releases the cards and the seat so the TTY comes back. Outputs are
100// closed by their goroutines first.
101func (b *drmBackend) close() {
102 for _, c := range b.cards {
103 b.seat.CloseDevice(c.fd)
104 }
105 b.seat.Close()
106}
107
108// runOutputs drives every output of every card: one goroutine per output,
109// one flip reader per card, and a udev watcher that rescans connectors on
110// hotplug. Core learns about outputs through events. It returns when ctx
111// ends, after every output is closed.
112func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
113 ctx, cancel := context.WithCancel(ctx)
114 defer cancel()
115 var readers sync.WaitGroup
116 readerErr := make(chan error, len(b.cards))
117 for _, c := range b.cards {
118 c.SetFormats(formats)
119 readers.Go(func() {
120 if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {
121 readerErr <- fmt.Errorf("%s: %w", c.Path(), err)
122 }
123 })
124 }
125 defer readers.Wait()
126 hotplug := make(chan struct{}, 1)
127 go func() {
128 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
129 log.Warn().Err(err).Msg("hotplug watch disabled")
130 }
131 }()
132 set := newOutputSet()
133 cards := map[string]*drmCard{}
134 send := func(ev ports.OutputEvent) {
135 select {
136 case events <- ev:
137 case <-ctx.Done():
138 }
139 }
140 // stopping holds outputs asked to stop: true when they restart with a
141 // new mode (core keeps the screen), false when they are gone.
142 stopping := map[string]bool{}
143 scan := func() {
144 for _, c := range b.cards {
145 w := want()
146 w.Device = c.Device()
147 c.SetWant(w)
148 added, removed, replaced, err := c.Scan()
149 if err != nil {
150 log.Warn().Err(err).Msg("scan connectors")
151 continue
152 }
153 stop := func(name string, restart bool) {
154 if r := set.outs[name]; r != nil && cards[name] == c {
155 if _, ok := stopping[name]; !ok {
156 stopping[name] = restart
157 r.stop()
158 }
159 }
160 }
161 for _, name := range removed {
162 log.Info().Str("connector", name).Msg("output unplugged")
163 stop(name, false)
164 }
165 for _, name := range replaced {
166 log.Info().Str("connector", name).Msg("output mode change")
167 stop(name, true)
168 }
169 for _, o := range added {
170 name := o.Info().Name
171 if other := cards[name]; other != nil && other != c {
172 // Core and clients know outputs by connector name.
173 log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored")
174 o.Close()
175 c.Release(name)
176 continue
177 }
178 cards[name] = c
179 if cur := o.Cursor(); cur != nil {
180 curs.set(name, cur)
181 }
182 active := b.seat.Subscribe()
183 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
184 defer b.seat.Unsubscribe(active)
185 return safe("output "+name, func() error {
186 defer o.Close()
187 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented)
188 })
189 })
190 send(ports.OutputAdded{Info: o.Info()})
191 }
192 }
193 }
194 scan()
195 if len(set.outs) == 0 {
196 return errors.Join(errors.New("no connected display"), set.wait())
197 }
198 for {
199 select {
200 case <-ctx.Done():
201 return set.wait()
202 case err := <-readerErr:
203 // No more flips on that card: its outputs would freeze.
204 return errors.Join(err, set.wait())
205 case <-hotplug:
206 log.Info().Msg("hotplug")
207 scan()
208 case s := <-scenes:
209 set.scenes(s)
210 case c := <-contents:
211 set.content(c)
212 case c := <-cursorChanges:
213 set.setCursor(c)
214 case name := <-set.stopped:
215 err := set.finish(name)
216 if ctx.Err() != nil {
217 return errors.Join(err, set.wait())
218 }
219 restart := stopping[name]
220 delete(stopping, name)
221 curs.set(name, nil)
222 cards[name].Release(name)
223 delete(cards, name)
224 if err != nil {
225 // A broken output (e.g. its renderer) must not take the
226 // session down. It stays off until the next hotplug, so a
227 // lasting failure does not loop.
228 log.Error().Err(err).Str("connector", name).Msg("output stopped")
229 restart = false
230 }
231 if restart {
232 // Same connector, new mode: core keeps its screen and
233 // workspaces; OutputAdded updates the size.
234 scan()
235 if set.outs[name] != nil {
236 continue
237 }
238 }
239 send(ports.OutputRemoved{Name: name})
240 }
241 }
242}