1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "runtime/debug"
11 "slices"
12 "sort"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "github.com/bnema/neferwl/internal/adapters/clock"
18 "github.com/bnema/neferwl/internal/adapters/config"
19 "github.com/bnema/neferwl/internal/adapters/drm"
20 "github.com/bnema/neferwl/internal/adapters/sched"
21 "github.com/bnema/neferwl/internal/adapters/seat"
22 "github.com/bnema/neferwl/internal/logging"
23 "github.com/bnema/neferwl/internal/ports"
24 "github.com/bnema/zerowrap"
25 "golang.org/x/sys/unix"
26)
27
28var errTimeout = errors.New("timeout")
29
30// securityLeaseCard keeps the native operation and its tracked lease inventory
31// together. A failed revoke must remain in LeaseIDs for the next barrier attempt.
32type securityLeaseCard interface {
33 Path() string
34 Lease([]string) (*os.File, uint32, error)
35 LeaseIDs() []uint32
36 Revoke(uint32) error
37}
38
39func securitySnapshot(gate ports.SessionSecurity) ports.SecurityState {
40 if gate == nil {
41 return ports.SecurityState{}
42 }
43 return gate.Snapshot()
44}
45
46// guardedLease denies requests pending at acquisition and closes any FD created
47// across a transition, even when the native call succeeds. Failed revocations
48// stay tracked by the card and prevent the later backend barrier.
49func guardedLease(c securityLeaseCard, gate ports.SessionSecurity, connectors []string) (*os.File, uint32, error) {
50 before := securitySnapshot(gate)
51 if before.Protected {
52 return nil, 0, errors.New("session protected")
53 }
54 fd, id, err := c.Lease(connectors)
55 if err != nil {
56 return fd, id, err
57 }
58 after := securitySnapshot(gate)
59 if after.Protected || after.Generation != before.Generation {
60 if fd != nil {
61 fd.Close()
62 }
63 return nil, 0, errors.Join(errors.New("session transition during lease"), c.Revoke(id))
64 }
65 return fd, id, nil
66}
67
68func revokeSecurityLeases(c securityLeaseCard, finished func(uint32)) error {
69 var errs []error
70 for _, id := range c.LeaseIDs() {
71 if err := c.Revoke(id); err != nil {
72 errs = append(errs, fmt.Errorf("%s lease %d: %w", c.Path(), id, err))
73 } else {
74 finished(id)
75 }
76 }
77 if ids := c.LeaseIDs(); len(ids) != 0 {
78 errs = append(errs, fmt.Errorf("%s leases still active: %v", c.Path(), ids))
79 }
80 return errors.Join(errs...)
81}
82
83// registerDiscardedOutput accounts for physical output lifetimes that cannot
84// enter connector-name routing. They still require protection just like owners.
85// The same backend-owned counter is used by outputSet.start; no instance reuse.
86func registerDiscardedOutput(set *outputSet, name string) (ports.OutputInstance, error) {
87 if set.nextInstance == ^ports.OutputInstance(0) {
88 set.startErr = errors.New("output instance exhausted")
89 return 0, set.startErr
90 }
91 set.nextInstance++
92 instance := set.nextInstance
93 if !set.securityEvent(ports.SecurityOutputAdded{Instance: instance, Output: name}) {
94 return instance, set.ctx.Err()
95 }
96 return instance, nil
97}
98
99// retireDiscardedOutput consumes only affirmative terminal evidence. A failed
100// close remains in authoritative inventory, even though it has no render owner.
101func retireDiscardedOutput(set *outputSet, instance ports.OutputInstance, inactive bool) error {
102 if !inactive {
103 return fmt.Errorf("discarded output %d shutdown inactivity unconfirmed", instance)
104 }
105 if !set.securityEvent(ports.SecurityOutputRemoved{Instance: instance}) {
106 return set.ctx.Err()
107 }
108 return nil
109}
110
111// applyTimeout bounds how long outputs take to apply a configuration.
112const applyTimeout = 5 * time.Second
113
114// drmBackend is the seat and the cards with at least one usable output.
115type drmBackend struct {
116 seat *seat.Seat
117 cards []*drmCard
118 clock ports.Clock
119 log zerowrap.Logger
120 // preparers are the running outputs, guarded by mu: the seat's goroutine
121 // reads them while the output loop adds and removes them.
122 mu sync.Mutex
123 preparers map[seatDisablePreparer]struct{}
124}
125
126// seatDisablePreparer is an output that can reset its planes before the
127// seat is disabled (drm.Output).
128type seatDisablePreparer interface {
129 PrepareSeatDisable(timeout time.Duration) bool
130}
131
132// seatDisableTimeout bounds how long a seat disable waits for the outputs:
133// the VT switch must not hang on a stuck one.
134const seatDisableTimeout = 100 * time.Millisecond
135
136func (b *drmBackend) addPreparer(p seatDisablePreparer) {
137 b.mu.Lock()
138 defer b.mu.Unlock()
139 if b.preparers == nil {
140 b.preparers = map[seatDisablePreparer]struct{}{}
141 }
142 b.preparers[p] = struct{}{}
143}
144
145func (b *drmBackend) removePreparer(p seatDisablePreparer) {
146 b.mu.Lock()
147 defer b.mu.Unlock()
148 delete(b.preparers, p)
149}
150
151// prepareSeatDisable runs on the seat's goroutine before the seat is
152// disabled and its devices revoked: every running output commits what it
153// must leave behind (planes on Bypass), all in parallel under one bound. A
154// timeout is logged and the disable goes on.
155func (b *drmBackend) prepareSeatDisable() {
156 b.mu.Lock()
157 outs := make([]seatDisablePreparer, 0, len(b.preparers))
158 for p := range b.preparers {
159 outs = append(outs, p)
160 }
161 b.mu.Unlock()
162 var wg sync.WaitGroup
163 var late atomic.Int32
164 for _, p := range outs {
165 wg.Go(func() {
166 if !p.PrepareSeatDisable(seatDisableTimeout) {
167 late.Add(1)
168 }
169 })
170 }
171 wg.Wait()
172 if n := late.Load(); n > 0 {
173 b.log.Warn().Int("outputs", int(n)).Dur("timeout", seatDisableTimeout).Msg("outputs did not prepare for the seat disable in time; disabling anyway")
174 }
175}
176
177type drmCard struct {
178 *drm.Card
179 fd int
180}
181
182// A failed card read must not erase its heads from a multi-card inventory.
183func retainedHeads(previous, found []ports.OutputHead, err error) []ports.OutputHead {
184 if err != nil {
185 return previous
186 }
187 return found
188}
189
190// A stopped instance is no longer enabled, even if the inventory read fails.
191func releasedHead(heads []ports.OutputHead, name string) []ports.OutputHead {
192 heads = append([]ports.OutputHead(nil), heads...)
193 for i := range heads {
194 if heads[i].Info.Name == name {
195 heads[i].Enabled = false
196 heads[i].Current = nil
197 }
198 }
199 return heads
200}
201
202// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
203func safe(name string, fn func() error) (err error) {
204 defer func() {
205 if p := recover(); p != nil {
206 err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
207 }
208 }()
209 return fn()
210}
211
212// openDRM opens the seat and every card with a connected display. Outputs
213// are found by the first Scan of each card.
214func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
215 log := logging.For(ctx, "drm")
216 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
217 if err != nil {
218 log.Error().Err(err).Msg("open seat")
219 return nil, fmt.Errorf("open seat: %w", err)
220 }
221 want := wantFromConfig(cfg)
222 want.TraceFlips = logging.Enabled(ctx, "drm-flip")
223 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
224 sort.Strings(paths)
225 b := &drmBackend{seat: s, clock: clock.System{}, log: log}
226 s.SetBeforeDisable(b.prepareSeatDisable)
227 var errs []error
228 for _, path := range paths {
229 fd, err := s.OpenDevice(path)
230 if err != nil {
231 errs = append(errs, err)
232 continue
233 }
234 card, err := drm.OpenCard(fd, path, want, log, logging.For(ctx, "render"))
235 if err != nil {
236 log.Warn().Err(err).Str("card", path).Msg("card unusable")
237 errs = append(errs, err)
238 s.CloseDevice(fd)
239 continue
240 }
241 b.cards = append(b.cards, &drmCard{Card: card, fd: fd})
242 }
243 if len(b.cards) == 0 {
244 s.Close()
245 err = fmt.Errorf("no usable DRM card in %v: %w", paths, errors.Join(errs...))
246 log.Error().Err(err).Msg("open drm")
247 return nil, err
248 }
249 return b, nil
250}
251
252// wantFromConfig turns output.<name> and render.* entries into connector choices.
253func wantFromConfig(cfg ports.Config) drm.Want {
254 r := cfg.Render
255 want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR, VRRFlipGap: r.VRRFlipGap}
256 for _, o := range cfg.Outputs {
257 nits := o.SDRBrightness
258 if nits == 0 {
259 nits = ports.DefaultSDRBrightness
260 }
261 want.HDR[o.Name] = drm.HDRSettings{Enabled: o.HDR, SDRBrightness: nits}
262 if o.Off {
263 want.Disabled[o.Name] = true
264 continue
265 }
266 if o.Mode != "" {
267 // Validated at config load.
268 w, h, hz, _ := config.ParseMode(o.Mode)
269 want.Modes[o.Name] = [3]float64{float64(w), float64(h), hz}
270 }
271 }
272 return want
273}
274
275// close releases the cards and the seat so the TTY comes back. Outputs are
276// closed by their goroutines first.
277func (b *drmBackend) close() {
278 for _, c := range b.cards {
279 b.seat.CloseDevice(c.fd)
280 }
281 b.seat.Close()
282}
283
284// runOutputs drives every output of every card: one goroutine per output,
285// one flip reader per card, and a udev watcher that rescans connectors on
286// hotplug. Core learns about outputs through events. The loop also owns
287// output configuration: reloads and protocol requests go through apply, and
288// each configuration is finished when its outputs are ready, fail or time
289// out. It returns when ctx ends, after every output is closed.
290func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
291 ctx, cancel := context.WithCancel(ctx)
292 defer cancel()
293 var readers sync.WaitGroup
294 readerErr := make(chan error, len(b.cards))
295 for _, c := range b.cards {
296 c.SetFormats(ch.formats)
297 readers.Go(func() {
298 if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {
299 readerErr <- fmt.Errorf("%s: %w", c.Path(), err)
300 }
301 })
302 }
303 defer readers.Wait()
304 active := b.seat.Subscribe()
305 defer b.seat.Unsubscribe(active)
306 seatActive := true // openDRM waits for the first seat enable
307 set := newOutputSet(ctx, ch.captured)
308 set.wireSecurity(ch)
309 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 }
363 hotplug := make(chan struct{}, 1)
364 go func() {
365 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
366 log.Warn().Err(err).Msg("hotplug watch disabled")
367 }
368 }()
369 cards := map[string]*drmCard{}
370 outputs := map[string]*drm.Output{} // owner result, read only after done
371 send := func(ev ports.OutputEvent) {
372 select {
373 case ch.events <- ev:
374 case <-ctx.Done():
375 }
376 }
377 // stopping holds outputs asked to stop: true when they restart with a
378 // new mode (core keeps the screen), false when they are gone.
379 stopping := map[string]bool{}
380 currentConfig := initial
381 var currentHeads ports.OutputHeads
382 lastHeads := map[*drmCard][]ports.OutputHead{}
383 progress := &applyProgress{completed: map[<-chan error]bool{}}
384 readySources := map[string]<-chan error{}
385 stopReady := map[string]chan struct{}{}
386 type readyResult struct {
387 name string
388 source <-chan error
389 err error
390 }
391 ready := make(chan readyResult)
392 // deadline is the pending apply's timer channel, nil while none is pending.
393 timer := b.clock.NewTimer(applyTimeout)
394 timer.Stop()
395 defer timer.Stop()
396 var deadline <-chan time.Time
397 // A small bounded retry budget avoids a busy loop on failed KMS revokes.
398 revokeTimer := b.clock.NewTimer(time.Second)
399 revokeTimer.Stop()
400 defer revokeTimer.Stop()
401 var revokeDeadline <-chan time.Time
402 var revokeAttempts int
403 var discardedOutputErr error // first unconfirmed lifetime; inventory retains every instance
404 barrier := func() {
405 if !set.state.Protected {
406 return
407 }
408 var errs []error
409 for _, c := range b.cards {
410 errs = append(errs, revokeSecurityLeases(c, func(id uint32) {
411 sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
412 }))
413 publishLeases(c)
414 }
415 errs = append(errs, discardedOutputErr)
416 err := errors.Join(errs...)
417 // Registration runs synchronously in scan before any barrier. A gate
418 // transition racing native revocation belongs to a later round.
419 if securitySnapshot(ch.security).Generation > set.state.Generation {
420 return
421 }
422 set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation, Err: err})
423 revokeAttempts++
424 if err != nil && revokeAttempts < 3 {
425 revokeTimer.Reset(time.Second)
426 revokeDeadline = revokeTimer.C()
427 }
428 }
429 var op uint64
430 complete := func(d applyDecision) {
431 if !d.reply {
432 return
433 }
434 timer.Stop()
435 deadline = nil
436 apply.finished(d.err)
437 }
438 publishInventory := func() {
439 var inventory ports.OutputHeads
440 for _, card := range b.cards {
441 found, err := card.ConnectedHeads()
442 if err != nil {
443 log.Warn().Err(err).Str("card", card.Path()).Msg("inventory outputs")
444 }
445 lastHeads[card] = retainedHeads(lastHeads[card], found, err)
446 inventory.Heads = append(inventory.Heads, lastHeads[card]...)
447 }
448 currentHeads = inventory
449 apply.heads(inventory)
450 }
451 scan := func() {
452 var scanErrors []error
453 for _, c := range b.cards {
454 w := want(currentConfig)
455 w.Device = c.Device()
456 c.SetWant(w)
457 added, removed, replaced, err := c.Scan()
458 if err != nil {
459 log.Warn().Err(err).Str("card", c.Path()).Msg("scan connectors")
460 scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
461 continue
462 }
463 publishLeases(c)
464 stop := func(name string, restart bool) {
465 if r := set.outs[name]; r != nil && cards[name] == c {
466 if _, ok := stopping[name]; !ok {
467 stopping[name] = restart
468 progress.stopping(name)
469 r.stop()
470 }
471 }
472 }
473 for _, name := range removed {
474 log.Info().Str("connector", name).Msg("output unplugged")
475 stop(name, false)
476 }
477 for _, name := range replaced {
478 log.Info().Str("connector", name).Msg("output mode change")
479 stop(name, true)
480 }
481 for _, o := range added {
482 // Every physical lifetime gets the gate before any early exit:
483 // Close must not restore saved desktop, even on a name collision.
484 o.Security = ch.security
485 name := o.Info().Name
486 if other := cards[name]; other != nil && other != c {
487 // Core and clients know outputs by connector name.
488 log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored")
489 instance, registerErr := registerDiscardedOutput(set, name)
490 o.Instance = instance
491 o.Close() // synchronous final KMS disable; never starts a renderer
492 var shutdownErr error
493 if registerErr == nil {
494 shutdownErr = retireDiscardedOutput(set, instance, o.InactiveOnClose())
495 }
496 if err := errors.Join(registerErr, shutdownErr); err != nil {
497 if discardedOutputErr == nil {
498 discardedOutputErr = err
499 }
500 scanErrors = append(scanErrors, err)
501 log.Error().Err(err).Str("connector", name).Str("card", c.Path()).Msg("discarded output protection unconfirmed")
502 // An exhausted instance cannot represent an unsafe lifetime.
503 // Stop the backend rather than ever acknowledge this inventory.
504 if instance == 0 {
505 cancel()
506 }
507 }
508 c.Release(name)
509 continue
510 }
511 cards[name] = c
512 outputs[name] = o
513 readySources[name] = o.Ready()
514 if progress.active && !want(currentConfig).Disabled[name] {
515 progress.required[name] = true
516 }
517 source := readySources[name]
518 stopped := make(chan struct{})
519 stopReady[name] = stopped
520 go func() {
521 select {
522 case err := <-source:
523 select {
524 case ready <- readyResult{name, source, err}:
525 case <-ctx.Done():
526 }
527 case <-stopped:
528 case <-ctx.Done():
529 }
530 }()
531 o.NewCaptureRenderer = newRenderer
532 o.StartOff = set.off[name]
533 progress.started(name, source)
534 if cur := o.Cursor(); cur != nil {
535 curs.set(name, cur)
536 }
537 active := b.seat.Subscribe()
538 realtime := currentConfig.Performance.Realtime
539 started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
540 defer b.seat.Unsubscribe(active)
541 o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
542 return safe("output "+name, func() error {
543 defer o.Close()
544 b.addPreparer(o)
545 defer b.removePreparer(o) // before Close: it handles no request any more
546 if realtime {
547 runtime.LockOSThread()
548 // Keep the thread locked until this goroutine exits: an RT
549 // thread must not return to Go's general-purpose pool.
550 schedLog := logging.For(ctx, "sched")
551 if err := sched.Realtime(schedLog); err != nil {
552 schedLog.Warn().Err(err).Msg("output scheduling")
553 }
554 }
555 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
556 })
557 }, &o.SecurityEvents)
558 if !started {
559 b.seat.Unsubscribe(active)
560 o.Close()
561 c.Release(name)
562 continue
563 }
564 send(ports.OutputAdded{Info: o.Info()})
565 }
566 }
567 publishInventory()
568 complete(progress.scanError(errors.Join(scanErrors...)))
569 barrier()
570 }
571 scan()
572 if len(set.outs) == 0 {
573 return errors.Join(errors.New("no connected display"), set.wait())
574 }
575 // configure starts cfg; complete reports its result to apply. apply
576 // hands out one configuration at a time, and a rollback or reload starts
577 // on a later iteration, never inside scan or another configure.
578 configure := func(cfg ports.Config) {
579 currentConfig = cfg
580 op++
581 timer.Stop()
582 deadline = nil
583 required := map[string]bool{}
584 // Desired enabled heads, not the inventory's current mode, require a first modeset.
585 w := want(currentConfig)
586 for _, h := range currentHeads.Heads {
587 if !w.Disabled[h.Info.Name] {
588 required[h.Info.Name] = true
589 }
590 }
591 for name := range set.outs {
592 if !w.Disabled[name] {
593 required[name] = true
594 }
595 }
596 progress.start(op, required, stopping)
597 scan()
598 running := map[string]<-chan error{}
599 for name := range set.outs {
600 running[name] = readySources[name]
601 }
602 d := progress.scanned(running)
603 if progress.active {
604 timer.Reset(applyTimeout)
605 deadline = timer.C()
606 }
607 complete(d)
608 }
609 for {
610 if cfg, ok := apply.next(); ok {
611 configure(cfg)
612 continue
613 }
614 configs, configNext := apply.configOut(ch.configured), apply.config
615 replies, replyNext := apply.replyOut(ch.replies)
616 heads, headsNext := apply.headsOut(ch.heads)
617 contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
618 select {
619 case <-ctx.Done():
620 return set.wait()
621 case err := <-readerErr:
622 // No more flips on that card: its outputs would freeze.
623 return errors.Join(err, set.wait())
624 case state, ok := <-ch.securityChanges:
625 if !ok {
626 ch.securityChanges = nil
627 continue
628 }
629 if state.Generation < set.state.Generation {
630 continue
631 }
632 if set.setSecurity(state) {
633 revokeAttempts = 0
634 }
635 // start can refresh the gate while scanning; its queued transition
636 // must still receive a barrier even if forwarding already occurred.
637 revokeTimer.Stop()
638 revokeDeadline = nil
639 if set.state.Protected {
640 barrier()
641 } else {
642 for _, c := range b.cards {
643 publishLeases(c)
644 }
645 }
646 case <-revokeDeadline:
647 revokeDeadline = nil
648 barrier()
649 case <-hotplug:
650 log.Info().Msg("hotplug")
651 scan()
652 if progress.active {
653 running := map[string]<-chan error{}
654 for name := range set.outs {
655 running[name] = readySources[name]
656 }
657 complete(progress.scanned(running))
658 }
659 case enabled := <-active:
660 seatActive = enabled
661 if !enabled {
662 for _, c := range b.cards {
663 for _, id := range c.LeaseIDs() {
664 if err := c.Revoke(id); err != nil {
665 log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")
666 } else {
667 sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
668 }
669 }
670 publishLeases(c)
671 }
672 } else {
673 for _, c := range b.cards {
674 publishLeases(c)
675 }
676 }
677 case event := <-ch.leaseRequests:
678 switch req := event.(type) {
679 case ports.LeaseRequest:
680 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
681 if !seatActive || protected() {
682 reply.Err = errors.New("DRM seat inactive or session protected")
683 sendLease(reply)
684 break
685 }
686 for _, c := range b.cards {
687 if c.Path() == req.Card {
688 reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
689 publishLeases(c)
690 break
691 }
692 }
693 sendLease(reply)
694 case ports.LeaseRevoke:
695 for _, c := range b.cards {
696 if c.Path() == req.Card {
697 if err := c.Revoke(req.LeaseID); err != nil {
698 log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")
699 } else {
700 publishLeases(c)
701 }
702 break
703 }
704 }
705 }
706 case ev := <-ch.reloads:
707 apply.reload(ev.Config)
708 case req := <-ch.requests:
709 apply.request(req)
710 case configs <- configNext:
711 apply.configSent()
712 case replies <- replyNext:
713 apply.replySent()
714 case heads <- headsNext:
715 apply.headsSent()
716 case result := <-ready:
717 complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
718 case <-deadline:
719 complete(progress.timeout(op))
720 case s := <-ch.scenes:
721 set.scenes(s)
722 case c := <-contents:
723 set.content(c)
724 case contentOut <- contentNext:
725 contentOwner.contentSent()
726 case q := <-ch.captures:
727 set.routeCapture(q)
728 case c := <-ch.cursorChanges:
729 set.setCursor(c)
730 case stopped := <-set.stopped:
731 if !set.stoppedCurrent(stopped) {
732 continue
733 }
734 name := stopped.name
735 output := outputs[name]
736 err := set.finish(name)
737 // finish observes done after the deferred Close. Only affirmative
738 // terminal KMS inactivity retires this exact lifetime; renderer
739 // errors and missing connector inventory are not physical proof.
740 if output != nil && output.InactiveOnClose() {
741 set.securityEvent(ports.SecurityOutputRemoved{Instance: stopped.instance})
742 }
743 delete(outputs, name)
744 if ctx.Err() != nil {
745 return errors.Join(err, set.wait())
746 }
747 restart := stopping[name]
748 delete(stopping, name)
749 curs.set(name, nil)
750 card := cards[name]
751 card.Release(name)
752 lastHeads[card] = releasedHead(lastHeads[card], name)
753 delete(cards, name)
754 source := readySources[name]
755 delete(readySources, name)
756 if stopped := stopReady[name]; stopped != nil {
757 close(stopped)
758 delete(stopReady, name)
759 }
760 stopDecision := progress.stopped(name, source, restart, err)
761 if err != nil {
762 // A broken output (e.g. its renderer) must not take the
763 // session down. It stays off until the next hotplug, so a
764 // lasting failure does not loop.
765 log.Error().Err(err).Str("connector", name).Msg("output stopped")
766 restart = false
767 }
768 // Release changes the enabled state in ConnectedHeads. Do not
769 // restart a failed or disabled output just to refresh inventory.
770 if restart {
771 scan()
772 } else {
773 publishInventory()
774 }
775 complete(stopDecision)
776 if restart {
777 // Same connector, new mode: core keeps its screen and
778 // workspaces; OutputAdded updates the size.
779 if set.outs[name] != nil {
780 running := map[string]<-chan error{}
781 for n := range set.outs {
782 running[n] = readySources[n]
783 }
784 complete(progress.scanned(running))
785 continue
786 }
787 }
788 send(ports.OutputRemoved{Name: name})
789 running := map[string]<-chan error{}
790 for n := range set.outs {
791 running[n] = readySources[n]
792 }
793 complete(progress.scanned(running))
794 }
795 }
796}