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