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