v0.3.0 cf370a0

drm.go RawHistoryFolder

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}

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