detached 25625c3

fix(drm): keep connector array GC-visible and harden VT resume, flips and shutdown

commit
25625c3dec38ae11dc1c0cea7b192cb0e1e9ba43
author
brice · · 2026-09-24 08:25 UTC
parent
0679ea5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +57 -21
M cmd/nefertty/main.go +2 -0
@@ -184,6 +184,8 @@ func run() error {
184184 reason = "error"
185185 case ctx.Err() != nil:
186186 reason = "signal"
187+ case *timeout > 0 && time.Since(start) >= *timeout:
188+ reason = "timeout"
187189 }
188190 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
189191 return err
@@ -184,6 +184,8 @@ func run() error {
184 reason = "error"184 reason = "error"
185 case ctx.Err() != nil:185 case ctx.Err() != nil:
186 reason = "signal"186 reason = "signal"
187+ case *timeout > 0 && time.Since(start) >= *timeout:
188+ reason = "timeout"
187 }189 }
188 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")190 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
189 return err191 return err
M internal/adapters/drm/kms.go +5 -4
@@ -62,7 +62,8 @@ type getConnector struct {
6262 type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
6363
6464 type modeCrtc struct {
65- setConnectors uint64
65+ // unsafe.Pointer (not uint64) so the GC keeps it valid if the stack moves.
66+ setConnectors unsafe.Pointer
6667 countConnectors uint32
6768 crtcID, fbID uint32
6869 x, y uint32
@@ -111,7 +112,7 @@ type connector struct {
111112 encoders []uint32
112113 }
113114
114-var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"}
115+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
115116
116117 // pickConnector returns the first connected connector and its preferred (else first) mode.
117118 func pickConnector(conns []connector) (connector, modeInfo, error) {
@@ -146,7 +147,7 @@ func resources(fd int) (crtcs, conns []uint32, err error) {
146147 if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
147148 return nil, nil, fmt.Errorf("get resources: %w", err)
148149 }
149- return crtcs[:r.countCrtcs], conns[:r.countConns], nil
150+ return crtcs[:min(int(r.countCrtcs), len(crtcs))], conns[:min(int(r.countConns), len(conns))], nil
150151 }
151152
152153 func readConnector(fd int, id uint32) (connector, error) {
@@ -260,7 +261,7 @@ func copyXRGB(dst []byte, pitch int, img *image.RGBA) {
260261
261262 func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
262263 ids := []uint32{conn}
263- c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))}
264+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: unsafe.Pointer(&ids[0])}
264265 if mode != nil {
265266 c.mode, c.modeValid = *mode, 1
266267 }
@@ -62,7 +62,8 @@ type getConnector struct {
62 type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }62 type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
63 63
64 type modeCrtc struct {64 type modeCrtc struct {
65- setConnectors uint6465+ // unsafe.Pointer (not uint64) so the GC keeps it valid if the stack moves.
66+ setConnectors unsafe.Pointer
66 countConnectors uint3267 countConnectors uint32
67 crtcID, fbID uint3268 crtcID, fbID uint32
68 x, y uint3269 x, y uint32
@@ -111,7 +112,7 @@ type connector struct {
111 encoders []uint32112 encoders []uint32
112 }113 }
113 114
114-var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"}115+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
115 116
116 // pickConnector returns the first connected connector and its preferred (else first) mode.117 // pickConnector returns the first connected connector and its preferred (else first) mode.
117 func pickConnector(conns []connector) (connector, modeInfo, error) {118 func pickConnector(conns []connector) (connector, modeInfo, error) {
@@ -146,7 +147,7 @@ func resources(fd int) (crtcs, conns []uint32, err error) {
146 if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {147 if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
147 return nil, nil, fmt.Errorf("get resources: %w", err)148 return nil, nil, fmt.Errorf("get resources: %w", err)
148 }149 }
149- return crtcs[:r.countCrtcs], conns[:r.countConns], nil150+ return crtcs[:min(int(r.countCrtcs), len(crtcs))], conns[:min(int(r.countConns), len(conns))], nil
150 }151 }
151 152
152 func readConnector(fd int, id uint32) (connector, error) {153 func readConnector(fd int, id uint32) (connector, error) {
@@ -260,7 +261,7 @@ func copyXRGB(dst []byte, pitch int, img *image.RGBA) {
260 261
261 func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {262 func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
262 ids := []uint32{conn}263 ids := []uint32{conn}
263- c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))}264+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: unsafe.Pointer(&ids[0])}
264 if mode != nil {265 if mode != nil {
265 c.mode, c.modeValid = *mode, 1266 c.mode, c.modeValid = *mode, 1
266 }267 }
M internal/adapters/drm/output.go +19 -3
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "image"
8+ "sync"
89 "time"
910
1011 "github.com/bnema/nefertty/internal/ports"
@@ -59,7 +60,9 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
5960 return nil, err
6061 }
6162 o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}
62- o.saved, _ = getCrtc(fd, crtc)
63+ if o.saved, err = getCrtc(fd, crtc); err != nil {
64+ log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
65+ }
6366 for i := range o.bufs {
6467 if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
6568 o.Close()
@@ -124,7 +127,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
124127 }
125128 events := make(chan int, 4)
126129 evErr := make(chan error, 1)
127- go o.readEvents(ctx, events, evErr)
130+ evCtx, stop := context.WithCancel(ctx)
131+ var reader sync.WaitGroup
132+ reader.Go(func() { o.readEvents(evCtx, events, evErr) })
133+ // Join the reader before the card fd can be closed by the caller.
134+ defer func() { stop(); reader.Wait() }()
128135 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
129136 var scene ports.Scene
130137 haveScene, dirty, enabled := false, false, true
@@ -138,7 +145,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
138145 case err := <-evErr:
139146 return err
140147 case on := <-active:
141- if on && !enabled {
148+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
149+ if on {
142150 if err := o.modeset(); err != nil {
143151 o.log.Error().Err(err).Msg("resume")
144152 return err
@@ -148,6 +156,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
148156 enabled = on
149157 o.log.Info().Bool("enabled", on).Msg("output")
150158 case n := <-events:
159+ if !o.pending {
160+ continue // stale event from before a modeset
161+ }
151162 o.pending = false
152163 o.flips += n
153164 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
@@ -173,6 +184,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
173184 return fmt.Errorf("render frame: %w", err)
174185 }
175186 if err := o.present(r.Pixels()); err != nil {
187+ if errors.Is(err, unix.EBUSY) {
188+ // A flip is still in flight; retry after its event.
189+ o.pending, o.flipStart = true, time.Now()
190+ continue // dirty stays true
191+ }
176192 if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
177193 // Lost DRM master mid-switch; the seat disable follows.
178194 o.log.Warn().Err(err).Msg("flip refused")
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "image"7 "image"
8+ "sync"
8 "time"9 "time"
9 10
10 "github.com/bnema/nefertty/internal/ports"11 "github.com/bnema/nefertty/internal/ports"
@@ -59,7 +60,9 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
59 return nil, err60 return nil, err
60 }61 }
61 o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}62 o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}
62- o.saved, _ = getCrtc(fd, crtc)63+ if o.saved, err = getCrtc(fd, crtc); err != nil {
64+ log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
65+ }
63 for i := range o.bufs {66 for i := range o.bufs {
64 if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {67 if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
65 o.Close()68 o.Close()
@@ -124,7 +127,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
124 }127 }
125 events := make(chan int, 4)128 events := make(chan int, 4)
126 evErr := make(chan error, 1)129 evErr := make(chan error, 1)
127- go o.readEvents(ctx, events, evErr)130+ evCtx, stop := context.WithCancel(ctx)
131+ var reader sync.WaitGroup
132+ reader.Go(func() { o.readEvents(evCtx, events, evErr) })
133+ // Join the reader before the card fd can be closed by the caller.
134+ defer func() { stop(); reader.Wait() }()
128 surfaces := make(map[ports.WindowID]ports.SurfaceContent)135 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
129 var scene ports.Scene136 var scene ports.Scene
130 haveScene, dirty, enabled := false, false, true137 haveScene, dirty, enabled := false, false, true
@@ -138,7 +145,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
138 case err := <-evErr:145 case err := <-evErr:
139 return err146 return err
140 case on := <-active:147 case on := <-active:
141- if on && !enabled {148+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
149+ if on {
142 if err := o.modeset(); err != nil {150 if err := o.modeset(); err != nil {
143 o.log.Error().Err(err).Msg("resume")151 o.log.Error().Err(err).Msg("resume")
144 return err152 return err
@@ -148,6 +156,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
148 enabled = on156 enabled = on
149 o.log.Info().Bool("enabled", on).Msg("output")157 o.log.Info().Bool("enabled", on).Msg("output")
150 case n := <-events:158 case n := <-events:
159+ if !o.pending {
160+ continue // stale event from before a modeset
161+ }
151 o.pending = false162 o.pending = false
152 o.flips += n163 o.flips += n
153 if d := time.Since(o.flipStart); d > 20*time.Millisecond {164 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
@@ -173,6 +184,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
173 return fmt.Errorf("render frame: %w", err)184 return fmt.Errorf("render frame: %w", err)
174 }185 }
175 if err := o.present(r.Pixels()); err != nil {186 if err := o.present(r.Pixels()); err != nil {
187+ if errors.Is(err, unix.EBUSY) {
188+ // A flip is still in flight; retry after its event.
189+ o.pending, o.flipStart = true, time.Now()
190+ continue // dirty stays true
191+ }
176 if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {192 if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
177 // Lost DRM master mid-switch; the seat disable follows.193 // Lost DRM master mid-switch; the seat disable follows.
178 o.log.Warn().Err(err).Msg("flip refused")194 o.log.Warn().Err(err).Msg("flip refused")
M internal/adapters/seat/seat.go +4 -5
@@ -128,9 +128,11 @@ func (s *Seat) Name() string {
128128 }
129129
130130 // Subscribe returns a channel that receives the seat state after every enable/disable.
131+// The current state is sent first.
131132 func (s *Seat) Subscribe() <-chan bool {
132133 c := make(chan bool, 1)
133134 s.mu.Lock()
135+ c <- s.active
134136 s.subs = append(s.subs, c)
135137 s.mu.Unlock()
136138 return c
@@ -155,13 +157,10 @@ func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
155157 func (s *Seat) Run(ctx context.Context) error {
156158 for ctx.Err() == nil {
157159 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
158- n, err := unix.Poll(fds, 100)
159- if err != nil && !errors.Is(err, unix.EINTR) {
160+ if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
160161 return fmt.Errorf("seat poll: %w", err)
161162 }
162- if n <= 0 {
163- continue
164- }
163+ // Dispatch every tick: libseat may queue events without the fd becoming readable.
165164 s.mu.Lock()
166165 r := dispatch(s.handle, 0)
167166 s.mu.Unlock()
@@ -128,9 +128,11 @@ func (s *Seat) Name() string {
128 }128 }
129 129
130 // Subscribe returns a channel that receives the seat state after every enable/disable.130 // Subscribe returns a channel that receives the seat state after every enable/disable.
131+// The current state is sent first.
131 func (s *Seat) Subscribe() <-chan bool {132 func (s *Seat) Subscribe() <-chan bool {
132 c := make(chan bool, 1)133 c := make(chan bool, 1)
133 s.mu.Lock()134 s.mu.Lock()
135+ c <- s.active
134 s.subs = append(s.subs, c)136 s.subs = append(s.subs, c)
135 s.mu.Unlock()137 s.mu.Unlock()
136 return c138 return c
@@ -155,13 +157,10 @@ func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
155 func (s *Seat) Run(ctx context.Context) error {157 func (s *Seat) Run(ctx context.Context) error {
156 for ctx.Err() == nil {158 for ctx.Err() == nil {
157 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}159 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
158- n, err := unix.Poll(fds, 100)160+ if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
159- if err != nil && !errors.Is(err, unix.EINTR) {
160 return fmt.Errorf("seat poll: %w", err)161 return fmt.Errorf("seat poll: %w", err)
161 }162 }
162- if n <= 0 {163+ // Dispatch every tick: libseat may queue events without the fd becoming readable.
163- continue
164- }
165 s.mu.Lock()164 s.mu.Lock()
166 r := dispatch(s.handle, 0)165 r := dispatch(s.handle, 0)
167 s.mu.Unlock()166 s.mu.Unlock()
M internal/app/app.go +14 -9
@@ -57,6 +57,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
5757 }
5858 defer hw.close()
5959 width, height = hw.out.Width(), hw.out.Height()
60+ hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
6061 }
6162 client := make(chan ports.ClientEvent, 32)
6263 input := make(chan ports.InputEvent, 32)
@@ -108,7 +109,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
108109 go func() {
109110 defer workers.Done()
110111 if hw != nil {
111- done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input)
112+ done <- safe("input", func() error {
113+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
114+ })
112115 return
113116 }
114117 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
@@ -140,13 +143,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
140143 go func() {
141144 defer workers.Done()
142145 if hw != nil {
143- done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
144- r, err := vulkan.New(w, h)
145- if err != nil {
146- return nil, err
147- }
148- return r, nil
149- }, hw.seat.Subscribe(), renderScenes, contents)
146+ done <- safe("output", func() error {
147+ return hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
148+ r, err := vulkan.New(w, h)
149+ if err != nil {
150+ return nil, err
151+ }
152+ return r, nil
153+ }, hw.outputActive, renderScenes, contents)
154+ })
150155 return
151156 }
152157 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
@@ -160,7 +165,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
160165
161166 if hw != nil {
162167 workers.Add(1)
163- go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }()
168+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
164169 }
165170 var result error
166171 select {
@@ -57,6 +57,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
57 }57 }
58 defer hw.close()58 defer hw.close()
59 width, height = hw.out.Width(), hw.out.Height()59 width, height = hw.out.Width(), hw.out.Height()
60+ hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
60 }61 }
61 client := make(chan ports.ClientEvent, 32)62 client := make(chan ports.ClientEvent, 32)
62 input := make(chan ports.InputEvent, 32)63 input := make(chan ports.InputEvent, 32)
@@ -108,7 +109,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
108 go func() {109 go func() {
109 defer workers.Done()110 defer workers.Done()
110 if hw != nil {111 if hw != nil {
111- done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input)112+ done <- safe("input", func() error {
113+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
114+ })
112 return115 return
113 }116 }
114 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))117 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
@@ -140,13 +143,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
140 go func() {143 go func() {
141 defer workers.Done()144 defer workers.Done()
142 if hw != nil {145 if hw != nil {
143- done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {146+ done <- safe("output", func() error {
144- r, err := vulkan.New(w, h)147+ return hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
145- if err != nil {148+ r, err := vulkan.New(w, h)
146- return nil, err149+ if err != nil {
147- }150+ return nil, err
148- return r, nil151+ }
149- }, hw.seat.Subscribe(), renderScenes, contents)152+ return r, nil
153+ }, hw.outputActive, renderScenes, contents)
154+ })
150 return155 return
151 }156 }
152 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {157 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
@@ -160,7 +165,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
160 165
161 if hw != nil {166 if hw != nil {
162 workers.Add(1)167 workers.Add(1)
163- go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }()168+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
164 }169 }
165 var result error170 var result error
166 select {171 select {
M internal/app/drm.go +13 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "path/filepath"
8+ "runtime/debug"
89 "sort"
910
1011 "github.com/bnema/nefertty/internal/adapters/drm"
@@ -18,6 +19,18 @@ type drmBackend struct {
1819 seat *seat.Seat
1920 out *drm.Output
2021 fd int
22+
23+ inputActive, outputActive <-chan bool
24+}
25+
26+// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
27+func safe(name string, fn func() error) (err error) {
28+ defer func() {
29+ if p := recover(); p != nil {
30+ err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
31+ }
32+ }()
33+ return fn()
2134 }
2235
2336 // openDRM opens the seat and the first card with a connected display.
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "path/filepath"7 "path/filepath"
8+ "runtime/debug"
8 "sort"9 "sort"
9 10
10 "github.com/bnema/nefertty/internal/adapters/drm"11 "github.com/bnema/nefertty/internal/adapters/drm"
@@ -18,6 +19,18 @@ type drmBackend struct {
18 seat *seat.Seat19 seat *seat.Seat
19 out *drm.Output20 out *drm.Output
20 fd int21 fd int
22+
23+ inputActive, outputActive <-chan bool
24+}
25+
26+// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
27+func safe(name string, fn func() error) (err error) {
28+ defer func() {
29+ if p := recover(); p != nil {
30+ err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
31+ }
32+ }()
33+ return fn()
21 }34 }
22 35
23 // openDRM opens the seat and the first card with a connected display.36 // openDRM opens the seat and the first card with a connected display.

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