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	"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				o.StartOff = set.off[name]
475				progress.started(name, source)
476				if cur := o.Cursor(); cur != nil {
477					curs.set(name, cur)
478				}
479				active := b.seat.Subscribe()
480				realtime := currentConfig.Performance.Realtime
481				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 {
482					defer b.seat.Unsubscribe(active)
483					o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
484					return safe("output "+name, func() error {
485						defer o.Close()
486						if realtime {
487							runtime.LockOSThread()
488							// Keep the thread locked until this goroutine exits: an RT
489							// thread must not return to Go's general-purpose pool.
490							if err := sched.Realtime(log); err != nil {
491								log.Warn().Str("component", "sched").Err(err).Msg("output scheduling")
492							}
493						}
494						return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
495					})
496				}, &o.SecurityEvents)
497				if !started {
498					b.seat.Unsubscribe(active)
499					o.Close()
500					c.Release(name)
501					continue
502				}
503				send(ports.OutputAdded{Info: o.Info()})
504			}
505		}
506		publishInventory()
507		complete(progress.scanError(errors.Join(scanErrors...)))
508		barrier()
509	}
510	scan()
511	if len(set.outs) == 0 {
512		return errors.Join(errors.New("no connected display"), set.wait())
513	}
514	// configure starts cfg; complete reports its result to apply. apply
515	// hands out one configuration at a time, and a rollback or reload starts
516	// on a later iteration, never inside scan or another configure.
517	configure := func(cfg ports.Config) {
518		currentConfig = cfg
519		op++
520		timer.Stop()
521		deadline = nil
522		required := map[string]bool{}
523		// Desired enabled heads, not the inventory's current mode, require a first modeset.
524		w := want(currentConfig)
525		for _, h := range currentHeads.Heads {
526			if !w.Disabled[h.Info.Name] {
527				required[h.Info.Name] = true
528			}
529		}
530		for name := range set.outs {
531			if !w.Disabled[name] {
532				required[name] = true
533			}
534		}
535		progress.start(op, required, stopping)
536		scan()
537		running := map[string]<-chan error{}
538		for name := range set.outs {
539			running[name] = readySources[name]
540		}
541		d := progress.scanned(running)
542		if progress.active {
543			timer.Reset(applyTimeout)
544			deadline = timer.C()
545		}
546		complete(d)
547	}
548	for {
549		if cfg, ok := apply.next(); ok {
550			configure(cfg)
551			continue
552		}
553		configs, configNext := apply.configOut(ch.configured), apply.config
554		replies, replyNext := apply.replyOut(ch.replies)
555		heads, headsNext := apply.headsOut(ch.heads)
556		contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
557		select {
558		case <-ctx.Done():
559			return set.wait()
560		case err := <-readerErr:
561			// No more flips on that card: its outputs would freeze.
562			return errors.Join(err, set.wait())
563		case state, ok := <-ch.securityChanges:
564			if !ok {
565				ch.securityChanges = nil
566				continue
567			}
568			if state.Generation < set.state.Generation {
569				continue
570			}
571			if set.setSecurity(state) {
572				revokeAttempts = 0
573			}
574			// start can refresh the gate while scanning; its queued transition
575			// must still receive a barrier even if forwarding already occurred.
576			revokeTimer.Stop()
577			revokeDeadline = nil
578			if set.state.Protected {
579				barrier()
580			} else {
581				for _, c := range b.cards {
582					publishLeases(c)
583				}
584			}
585		case <-revokeDeadline:
586			revokeDeadline = nil
587			barrier()
588		case <-hotplug:
589			log.Info().Msg("hotplug")
590			scan()
591			if progress.active {
592				running := map[string]<-chan error{}
593				for name := range set.outs {
594					running[name] = readySources[name]
595				}
596				complete(progress.scanned(running))
597			}
598		case enabled := <-active:
599			seatActive = enabled
600			if !enabled {
601				for _, c := range b.cards {
602					for _, id := range c.LeaseIDs() {
603						if err := c.Revoke(id); err != nil {
604							log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")
605						} else {
606							sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
607						}
608					}
609					publishLeases(c)
610				}
611			} else {
612				for _, c := range b.cards {
613					publishLeases(c)
614				}
615			}
616		case event := <-ch.leaseRequests:
617			switch req := event.(type) {
618			case ports.LeaseRequest:
619				reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
620				if !seatActive || protected() {
621					reply.Err = errors.New("DRM seat inactive or session protected")
622					sendLease(reply)
623					break
624				}
625				for _, c := range b.cards {
626					if c.Path() == req.Card {
627						reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
628						publishLeases(c)
629						break
630					}
631				}
632				sendLease(reply)
633			case ports.LeaseRevoke:
634				for _, c := range b.cards {
635					if c.Path() == req.Card {
636						if err := c.Revoke(req.LeaseID); err != nil {
637							log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")
638						} else {
639							publishLeases(c)
640						}
641						break
642					}
643				}
644			}
645		case ev := <-ch.reloads:
646			apply.reload(ev.Config)
647		case req := <-ch.requests:
648			apply.request(req)
649		case configs <- configNext:
650			apply.configSent()
651		case replies <- replyNext:
652			apply.replySent()
653		case heads <- headsNext:
654			apply.headsSent()
655		case result := <-ready:
656			complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
657		case <-deadline:
658			complete(progress.timeout(op))
659		case s := <-ch.scenes:
660			set.scenes(s)
661		case c := <-contents:
662			set.content(c)
663		case contentOut <- contentNext:
664			contentOwner.contentSent()
665		case q := <-ch.captures:
666			set.routeCapture(q)
667		case c := <-ch.cursorChanges:
668			set.setCursor(c)
669		case stopped := <-set.stopped:
670			if !set.stoppedCurrent(stopped) {
671				continue
672			}
673			name := stopped.name
674			output := outputs[name]
675			err := set.finish(name)
676			// finish observes done after the deferred Close. Only affirmative
677			// terminal KMS inactivity retires this exact lifetime; renderer
678			// errors and missing connector inventory are not physical proof.
679			if output != nil && output.InactiveOnClose() {
680				set.securityEvent(ports.SecurityOutputRemoved{Instance: stopped.instance})
681			}
682			delete(outputs, name)
683			if ctx.Err() != nil {
684				return errors.Join(err, set.wait())
685			}
686			restart := stopping[name]
687			delete(stopping, name)
688			curs.set(name, nil)
689			card := cards[name]
690			card.Release(name)
691			lastHeads[card] = releasedHead(lastHeads[card], name)
692			delete(cards, name)
693			source := readySources[name]
694			delete(readySources, name)
695			if stopped := stopReady[name]; stopped != nil {
696				close(stopped)
697				delete(stopReady, name)
698			}
699			stopDecision := progress.stopped(name, source, restart, err)
700			if err != nil {
701				// A broken output (e.g. its renderer) must not take the
702				// session down. It stays off until the next hotplug, so a
703				// lasting failure does not loop.
704				log.Error().Err(err).Str("connector", name).Msg("output stopped")
705				restart = false
706			}
707			// Release changes the enabled state in ConnectedHeads. Do not
708			// restart a failed or disabled output just to refresh inventory.
709			if restart {
710				scan()
711			} else {
712				publishInventory()
713			}
714			complete(stopDecision)
715			if restart {
716				// Same connector, new mode: core keeps its screen and
717				// workspaces; OutputAdded updates the size.
718				if set.outs[name] != nil {
719					running := map[string]<-chan error{}
720					for n := range set.outs {
721						running[n] = readySources[n]
722					}
723					complete(progress.scanned(running))
724					continue
725				}
726			}
727			send(ports.OutputRemoved{Name: name})
728			running := map[string]<-chan error{}
729			for n := range set.outs {
730				running[n] = readySources[n]
731			}
732			complete(progress.scanned(running))
733		}
734	}
735}

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 26

Tags 10

↑↓ moveenter openesc close