v0.7.0 1e7d719

drm.go RawHistoryFolder

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}

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 23

Tags 10

↑↓ moveenter openesc close