detached aadcd19

fix(drm): wait for unsignalled fences before recovering a stuck commit

A frame commit keeps duplicates of the fences it waits on (render fence,
scanout or overlay acquire fence). When its event is late and a fence
has not signalled, the output logs "frame waits for GPU fence" once and
keeps waiting; it modesets only once every fence signalled and still no
event came. The fences are closed when the commit ends (event, expiry,
modeset, close).

A composed frame refused by KMS keeps its render fence: what the output
has seen is reported only once that fence signalled or a later composed
frame flipped, since the GPU may still read client buffers.

An EBUSY run starts again when the previous refusal is older than a few
retries, so a later EBUSY is retried instead of modesetting at once.

A modeset resets the shown and queued buffers and the pending commit
only after its commit succeeds: a failed modeset leaves the buffers on
screen held.
commit
aadcd19d1358ae3800cb0aa3601430c78188286b
author
brice · · 2026-09-26 13:42 UTC
parent
ee184c9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +305 -48
M internal/adapters/drm/output.go +150 -35
@@ -63,11 +63,19 @@ type Output struct {
6363 pendingSerial, nextSerial uint64
6464 flipStart time.Time
6565 flips int
66- // A commit whose event does not come within stuckAfter (default
67- // stuckTimeout) is abandoned with a modeset. A commit refused with
68- // EBUSY is retried after busyRetry; busySince is when EBUSY began.
69- stuckAfter time.Duration
70- busySince time.Time
66+ // A commit whose event does not come by stuckAt is abandoned with a
67+ // modeset once its fences signalled (stuckAfter, default
68+ // stuckTimeout; fenceWarned once it waits for a fence). A commit
69+ // refused with EBUSY is retried after busyRetry; busySince is when
70+ // the EBUSY run began, lastBusy the latest EBUSY.
71+ stuckAfter time.Duration
72+ stuckAt time.Time
73+ fenceWarned bool
74+ busySince, lastBusy time.Time
75+ // readFences are fences of frames rendered but not committed: the
76+ // GPU may still read client buffers until they signal, so what was
77+ // seen is reported only then (or after a later frame flipped).
78+ readFences []*os.File
7179 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
7280 // screen and queued (0: the composed image).
7381 scanout bool
@@ -103,6 +111,86 @@ type pendingFrame struct {
103111 zeroCopy ports.WindowID // window shown without composition
104112 async bool
105113 shows map[ports.WindowID]uint64
114+ // fences are duplicates of the fences the commit waits on, owned by
115+ // the output until the commit ends. composed is set when the frame
116+ // was rendered (its fence is the renderer's).
117+ fences []*os.File
118+ composed bool
119+}
120+
121+// closeFences closes the fences of f.
122+func (f *pendingFrame) closeFences() {
123+ for _, fd := range f.fences {
124+ fd.Close()
125+ }
126+ f.fences = nil
127+}
128+
129+// dupFences duplicates the non-nil fences.
130+func dupFences(fs ...*os.File) []*os.File {
131+ var out []*os.File
132+ for _, f := range fs {
133+ if d := dupFence(f); d != nil {
134+ out = append(out, d)
135+ }
136+ }
137+ return out
138+}
139+
140+// signalled reports whether every fence signalled (readable); an fd that
141+// cannot be polled counts as signalled.
142+func signalled(fs []*os.File) bool {
143+ if len(fs) == 0 {
144+ return true
145+ }
146+ pfds := make([]unix.PollFd, len(fs))
147+ for i, f := range fs {
148+ pfds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
149+ }
150+ if _, err := unix.Poll(pfds, 0); err != nil {
151+ return true
152+ }
153+ for _, p := range pfds {
154+ if p.Revents == 0 {
155+ return false
156+ }
157+ }
158+ return true
159+}
160+
161+// holdRead keeps the fences of a frame that was not committed.
162+func (o *Output) holdRead(fs ...*os.File) {
163+ o.readFences = append(o.readFences, dupFences(fs...)...)
164+}
165+
166+// readDone reports whether no uncommitted frame may still read client
167+// buffers, closing the fences that signalled.
168+func (o *Output) readDone() bool {
169+ kept := o.readFences[:0]
170+ for _, f := range o.readFences {
171+ if signalled([]*os.File{f}) {
172+ f.Close()
173+ continue
174+ }
175+ kept = append(kept, f)
176+ }
177+ clear(o.readFences[len(kept):])
178+ o.readFences = kept
179+ return len(kept) == 0
180+}
181+
182+// dropRead closes the fences of uncommitted frames.
183+func (o *Output) dropRead() {
184+ for _, f := range o.readFences {
185+ f.Close()
186+ }
187+ o.readFences = nil
188+}
189+
190+// endPending ends the pending commit, closing its fences.
191+func (o *Output) endPending() {
192+ o.pendingFrame.closeFences()
193+ o.pending, o.pendingFrame = false, pendingFrame{}
106194 }
107195
108196 // Commit event userData: the commit serial above userKindBits, the kind
@@ -123,7 +211,9 @@ func (o *Output) userData(kind uint64) uint64 {
123211 // begin marks the commit made with the last userData pending.
124212 func (o *Output) begin(f pendingFrame) {
125213 o.pendingSerial = o.nextSerial
214+ o.pendingFrame.closeFences()
126215 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
216+ o.stuckAt, o.fenceWarned = o.flipStart.Add(o.stuckLimit()), false
127217 o.busySince = time.Time{}
128218 }
129219
@@ -141,29 +231,29 @@ func (o *Output) stuckLimit() time.Duration {
141231 return stuckTimeout
142232 }
143233
144-// pendingLimit is how long the pending commit may wait for its event.
145-// After EBUSY no commit of ours may be in flight: no event may come, so
146-// the commit is retried soon.
147-func (o *Output) pendingLimit() time.Duration {
148- if o.pendingSerial == 0 {
149- return busyRetry
150- }
151- return o.stuckLimit()
152-}
153-
154-// expire ends a pending commit whose event did not come in time, so the
155-// next commit can go. It reports whether the output needs a modeset: an
156-// event is missing (the kernel state is unknown) or EBUSY lasted past
157-// the limit.
234+// expire handles a pending commit whose event did not come by stuckAt.
235+// A commit still waiting for a fence (a long GPU job) keeps waiting.
236+// Else the commit is abandoned so the next can go; it reports whether
237+// the output needs a modeset: an event is missing (the kernel state is
238+// unknown) or EBUSY lasted past the limit.
158239 func (o *Output) expire() bool {
159- age := time.Since(o.flipStart)
240+ now := time.Now()
241+ age := now.Sub(o.flipStart)
242+ if o.pendingSerial != 0 && !signalled(o.pendingFrame.fences) {
243+ if !o.fenceWarned {
244+ o.fenceWarned = true
245+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Dur("age", age).Msg("frame waits for GPU fence")
246+ }
247+ o.stuckAt = now.Add(o.stuckLimit())
248+ return false
249+ }
160250 kind := "state"
161251 if o.pendingFrame.frame {
162252 kind = "frame"
163253 }
164- o.pending, o.pendingFrame = false, pendingFrame{}
254+ o.endPending()
165255 if o.pendingSerial == 0 {
166- busy := time.Since(o.busySince)
256+ busy := now.Sub(o.busySince)
167257 if o.busySince.IsZero() || busy < o.stuckLimit() {
168258 return false
169259 }
@@ -316,9 +406,6 @@ func (o *Output) primaryProps(req *atomicReq, fb uint32) {
316406 // other plane on the CRTC; needed at start and after every VT resume.
317407 // It blocks until the kernel applied it.
318408 func (o *Output) modeset() error {
319- o.pending, o.busySince = false, time.Time{}
320- o.pendingFrame = pendingFrame{}
321- o.shown, o.queued = 0, 0
322409 blob, err := o.modeBlobFor(o.mode)
323410 if err != nil {
324411 return err
@@ -327,6 +414,11 @@ func (o *Output) modeset() error {
327414 _ = o.k.destroyBlob(blob)
328415 return fmt.Errorf("modeset: %w", err)
329416 }
417+ // Only now is nothing of ours pending or on screen: on failure the
418+ // previous buffers may still show.
419+ o.endPending()
420+ o.busySince = time.Time{}
421+ o.shown, o.queued = 0, 0
330422 if o.modeBlob != 0 {
331423 _ = o.k.destroyBlob(o.modeBlob)
332424 }
@@ -531,6 +623,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
531623 o.overlayOn = ov.buf
532624 }
533625 f.frame, f.async = true, async
626+ f.fences = dupFences(fence, ov.acquire)
534627 o.setAsync(async)
535628 o.begin(f)
536629 return nil
@@ -711,6 +804,8 @@ func (o *Output) Close() {
711804 o.cursor.free(o.k)
712805 }
713806 o.freeImages()
807+ o.endPending()
808+ o.dropRead()
714809 o.shown, o.queued = 0, 0
715810 o.dropClientFBs(time.Now(), true)
716811 }
@@ -762,14 +857,18 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
762857 cursorWake = o.cursor.wake
763858 }
764859 for {
860+ readWait := false
765861 if reportDirty && !o.pending {
766- o.report(nil, seen)
767- reportDirty = false
862+ if readWait = !o.readDone(); !readWait {
863+ o.report(nil, seen)
864+ reportDirty = false
865+ }
768866 }
769867 o.flushReport(presented)
770- // An unsent report is retried soon, not only on the next event.
868+ // An unsent report is retried soon, not only on the next event;
869+ // so is one that waits for an uncommitted frame's fence.
771870 var retry <-chan time.Time
772- if len(o.unsent) > 0 {
871+ if len(o.unsent) > 0 || readWait {
773872 retry = time.After(time.Millisecond)
774873 }
775874 // A composed screen that stopped changing still drops VRR on time.
@@ -780,13 +879,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
780879 // A commit whose event never comes must not stop the output.
781880 var stuck <-chan time.Time
782881 if enabled && o.pending {
783- stuck = time.After(max(0, o.pendingLimit()-time.Since(o.flipStart)))
882+ stuck = time.After(max(0, time.Until(o.stuckAt)))
784883 }
785884 stateDirty := false
786885 select {
787886 case <-retry:
788887 continue
789888 case <-stuck:
889+ if o.pending && o.stuckAt.After(time.Now()) {
890+ continue // waits for a fence
891+ }
790892 if o.expire() {
791893 if err := o.modeset(); err != nil {
792894 if !o.commitFailed(err, &enabled) {
@@ -919,8 +1021,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9191021 }
9201022 // The overlay buffer is on screen like a scanned-out one: it
9211023 // is reported shown, so it is not released under the plane.
922- f.queued, f.zeroCopy = ov.buf, ov.id
1024+ f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
9231025 err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov)
1026+ if err != nil {
1027+ // The GPU may still read client buffers for this frame.
1028+ o.holdRead(done)
1029+ }
9241030 if done != nil {
9251031 done.Close()
9261032 }
@@ -958,12 +1064,16 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
9581064 // The kernel still works on an earlier commit (e.g. across a VT
9591065 // switch, or one whose event already came): wait for any event
9601066 // of this CRTC, or retry after busyRetry since none may come.
961- o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
1067+ o.endPending()
1068+ now := time.Now()
1069+ o.pending, o.flipStart, o.stuckAt = true, now, now.Add(busyRetry)
9621070 o.pendingSerial = 0
963- if o.busySince.IsZero() {
964- o.busySince = o.flipStart
1071+ // A run of EBUSY: each refusal follows the previous retry.
1072+ if o.busySince.IsZero() || now.Sub(o.lastBusy) > 4*busyRetry {
1073+ o.busySince = now
9651074 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
9661075 }
1076+ o.lastBusy = now
9671077 return true
9681078 case lostMaster(err):
9691079 o.log.Warn().Err(err).Msg("commit refused")
@@ -982,7 +1092,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
9821092 return false
9831093 }
9841094 f := o.pendingFrame
985- o.pending, o.pendingFrame = false, pendingFrame{}
1095+ o.endPending()
9861096 o.busySince = time.Time{}
9871097 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
9881098 o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
@@ -993,6 +1103,11 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
9931103 if !f.frame {
9941104 return true
9951105 }
1106+ if f.composed {
1107+ // Frames render in order: this flip's fence signalled after
1108+ // those of frames rendered before it and not committed.
1109+ o.dropRead()
1110+ }
9961111 o.flips++
9971112 o.shown = f.queued
9981113 if o.queued == f.queued {
@@ -63,11 +63,19 @@ type Output struct {
63 pendingSerial, nextSerial uint6463 pendingSerial, nextSerial uint64
64 flipStart time.Time64 flipStart time.Time
65 flips int65 flips int
66- // A commit whose event does not come within stuckAfter (default66+ // A commit whose event does not come by stuckAt is abandoned with a
67- // stuckTimeout) is abandoned with a modeset. A commit refused with67+ // modeset once its fences signalled (stuckAfter, default
68- // EBUSY is retried after busyRetry; busySince is when EBUSY began.68+ // stuckTimeout; fenceWarned once it waits for a fence). A commit
69- stuckAfter time.Duration69+ // refused with EBUSY is retried after busyRetry; busySince is when
70- busySince time.Time70+ // the EBUSY run began, lastBusy the latest EBUSY.
71+ stuckAfter time.Duration
72+ stuckAt time.Time
73+ fenceWarned bool
74+ busySince, lastBusy time.Time
75+ // readFences are fences of frames rendered but not committed: the
76+ // GPU may still read client buffers until they signal, so what was
77+ // seen is reported only then (or after a later frame flipped).
78+ readFences []*os.File
71 // Direct scanout: client framebuffers by DMABuf ID and the buffer on79 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
72 // screen and queued (0: the composed image).80 // screen and queued (0: the composed image).
73 scanout bool81 scanout bool
@@ -103,6 +111,86 @@ type pendingFrame struct {
103 zeroCopy ports.WindowID // window shown without composition111 zeroCopy ports.WindowID // window shown without composition
104 async bool112 async bool
105 shows map[ports.WindowID]uint64113 shows map[ports.WindowID]uint64
114+ // fences are duplicates of the fences the commit waits on, owned by
115+ // the output until the commit ends. composed is set when the frame
116+ // was rendered (its fence is the renderer's).
117+ fences []*os.File
118+ composed bool
119+}
120+
121+// closeFences closes the fences of f.
122+func (f *pendingFrame) closeFences() {
123+ for _, fd := range f.fences {
124+ fd.Close()
125+ }
126+ f.fences = nil
127+}
128+
129+// dupFences duplicates the non-nil fences.
130+func dupFences(fs ...*os.File) []*os.File {
131+ var out []*os.File
132+ for _, f := range fs {
133+ if d := dupFence(f); d != nil {
134+ out = append(out, d)
135+ }
136+ }
137+ return out
138+}
139+
140+// signalled reports whether every fence signalled (readable); an fd that
141+// cannot be polled counts as signalled.
142+func signalled(fs []*os.File) bool {
143+ if len(fs) == 0 {
144+ return true
145+ }
146+ pfds := make([]unix.PollFd, len(fs))
147+ for i, f := range fs {
148+ pfds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
149+ }
150+ if _, err := unix.Poll(pfds, 0); err != nil {
151+ return true
152+ }
153+ for _, p := range pfds {
154+ if p.Revents == 0 {
155+ return false
156+ }
157+ }
158+ return true
159+}
160+
161+// holdRead keeps the fences of a frame that was not committed.
162+func (o *Output) holdRead(fs ...*os.File) {
163+ o.readFences = append(o.readFences, dupFences(fs...)...)
164+}
165+
166+// readDone reports whether no uncommitted frame may still read client
167+// buffers, closing the fences that signalled.
168+func (o *Output) readDone() bool {
169+ kept := o.readFences[:0]
170+ for _, f := range o.readFences {
171+ if signalled([]*os.File{f}) {
172+ f.Close()
173+ continue
174+ }
175+ kept = append(kept, f)
176+ }
177+ clear(o.readFences[len(kept):])
178+ o.readFences = kept
179+ return len(kept) == 0
180+}
181+
182+// dropRead closes the fences of uncommitted frames.
183+func (o *Output) dropRead() {
184+ for _, f := range o.readFences {
185+ f.Close()
186+ }
187+ o.readFences = nil
188+}
189+
190+// endPending ends the pending commit, closing its fences.
191+func (o *Output) endPending() {
192+ o.pendingFrame.closeFences()
193+ o.pending, o.pendingFrame = false, pendingFrame{}
106 }194 }
107 195
108 // Commit event userData: the commit serial above userKindBits, the kind196 // Commit event userData: the commit serial above userKindBits, the kind
@@ -123,7 +211,9 @@ func (o *Output) userData(kind uint64) uint64 {
123 // begin marks the commit made with the last userData pending.211 // begin marks the commit made with the last userData pending.
124 func (o *Output) begin(f pendingFrame) {212 func (o *Output) begin(f pendingFrame) {
125 o.pendingSerial = o.nextSerial213 o.pendingSerial = o.nextSerial
214+ o.pendingFrame.closeFences()
126 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()215 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
216+ o.stuckAt, o.fenceWarned = o.flipStart.Add(o.stuckLimit()), false
127 o.busySince = time.Time{}217 o.busySince = time.Time{}
128 }218 }
129 219
@@ -141,29 +231,29 @@ func (o *Output) stuckLimit() time.Duration {
141 return stuckTimeout231 return stuckTimeout
142 }232 }
143 233
144-// pendingLimit is how long the pending commit may wait for its event.234+// expire handles a pending commit whose event did not come by stuckAt.
145-// After EBUSY no commit of ours may be in flight: no event may come, so235+// A commit still waiting for a fence (a long GPU job) keeps waiting.
146-// the commit is retried soon.236+// Else the commit is abandoned so the next can go; it reports whether
147-func (o *Output) pendingLimit() time.Duration {237+// the output needs a modeset: an event is missing (the kernel state is
148- if o.pendingSerial == 0 {238+// unknown) or EBUSY lasted past the limit.
149- return busyRetry
150- }
151- return o.stuckLimit()
152-}
153-
154-// expire ends a pending commit whose event did not come in time, so the
155-// next commit can go. It reports whether the output needs a modeset: an
156-// event is missing (the kernel state is unknown) or EBUSY lasted past
157-// the limit.
158 func (o *Output) expire() bool {239 func (o *Output) expire() bool {
159- age := time.Since(o.flipStart)240+ now := time.Now()
241+ age := now.Sub(o.flipStart)
242+ if o.pendingSerial != 0 && !signalled(o.pendingFrame.fences) {
243+ if !o.fenceWarned {
244+ o.fenceWarned = true
245+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Dur("age", age).Msg("frame waits for GPU fence")
246+ }
247+ o.stuckAt = now.Add(o.stuckLimit())
248+ return false
249+ }
160 kind := "state"250 kind := "state"
161 if o.pendingFrame.frame {251 if o.pendingFrame.frame {
162 kind = "frame"252 kind = "frame"
163 }253 }
164- o.pending, o.pendingFrame = false, pendingFrame{}254+ o.endPending()
165 if o.pendingSerial == 0 {255 if o.pendingSerial == 0 {
166- busy := time.Since(o.busySince)256+ busy := now.Sub(o.busySince)
167 if o.busySince.IsZero() || busy < o.stuckLimit() {257 if o.busySince.IsZero() || busy < o.stuckLimit() {
168 return false258 return false
169 }259 }
@@ -316,9 +406,6 @@ func (o *Output) primaryProps(req *atomicReq, fb uint32) {
316 // other plane on the CRTC; needed at start and after every VT resume.406 // other plane on the CRTC; needed at start and after every VT resume.
317 // It blocks until the kernel applied it.407 // It blocks until the kernel applied it.
318 func (o *Output) modeset() error {408 func (o *Output) modeset() error {
319- o.pending, o.busySince = false, time.Time{}
320- o.pendingFrame = pendingFrame{}
321- o.shown, o.queued = 0, 0
322 blob, err := o.modeBlobFor(o.mode)409 blob, err := o.modeBlobFor(o.mode)
323 if err != nil {410 if err != nil {
324 return err411 return err
@@ -327,6 +414,11 @@ func (o *Output) modeset() error {
327 _ = o.k.destroyBlob(blob)414 _ = o.k.destroyBlob(blob)
328 return fmt.Errorf("modeset: %w", err)415 return fmt.Errorf("modeset: %w", err)
329 }416 }
417+ // Only now is nothing of ours pending or on screen: on failure the
418+ // previous buffers may still show.
419+ o.endPending()
420+ o.busySince = time.Time{}
421+ o.shown, o.queued = 0, 0
330 if o.modeBlob != 0 {422 if o.modeBlob != 0 {
331 _ = o.k.destroyBlob(o.modeBlob)423 _ = o.k.destroyBlob(o.modeBlob)
332 }424 }
@@ -531,6 +623,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
531 o.overlayOn = ov.buf623 o.overlayOn = ov.buf
532 }624 }
533 f.frame, f.async = true, async625 f.frame, f.async = true, async
626+ f.fences = dupFences(fence, ov.acquire)
534 o.setAsync(async)627 o.setAsync(async)
535 o.begin(f)628 o.begin(f)
536 return nil629 return nil
@@ -711,6 +804,8 @@ func (o *Output) Close() {
711 o.cursor.free(o.k)804 o.cursor.free(o.k)
712 }805 }
713 o.freeImages()806 o.freeImages()
807+ o.endPending()
808+ o.dropRead()
714 o.shown, o.queued = 0, 0809 o.shown, o.queued = 0, 0
715 o.dropClientFBs(time.Now(), true)810 o.dropClientFBs(time.Now(), true)
716 }811 }
@@ -762,14 +857,18 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
762 cursorWake = o.cursor.wake857 cursorWake = o.cursor.wake
763 }858 }
764 for {859 for {
860+ readWait := false
765 if reportDirty && !o.pending {861 if reportDirty && !o.pending {
766- o.report(nil, seen)862+ if readWait = !o.readDone(); !readWait {
767- reportDirty = false863+ o.report(nil, seen)
864+ reportDirty = false
865+ }
768 }866 }
769 o.flushReport(presented)867 o.flushReport(presented)
770- // An unsent report is retried soon, not only on the next event.868+ // An unsent report is retried soon, not only on the next event;
869+ // so is one that waits for an uncommitted frame's fence.
771 var retry <-chan time.Time870 var retry <-chan time.Time
772- if len(o.unsent) > 0 {871+ if len(o.unsent) > 0 || readWait {
773 retry = time.After(time.Millisecond)872 retry = time.After(time.Millisecond)
774 }873 }
775 // A composed screen that stopped changing still drops VRR on time.874 // A composed screen that stopped changing still drops VRR on time.
@@ -780,13 +879,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
780 // A commit whose event never comes must not stop the output.879 // A commit whose event never comes must not stop the output.
781 var stuck <-chan time.Time880 var stuck <-chan time.Time
782 if enabled && o.pending {881 if enabled && o.pending {
783- stuck = time.After(max(0, o.pendingLimit()-time.Since(o.flipStart)))882+ stuck = time.After(max(0, time.Until(o.stuckAt)))
784 }883 }
785 stateDirty := false884 stateDirty := false
786 select {885 select {
787 case <-retry:886 case <-retry:
788 continue887 continue
789 case <-stuck:888 case <-stuck:
889+ if o.pending && o.stuckAt.After(time.Now()) {
890+ continue // waits for a fence
891+ }
790 if o.expire() {892 if o.expire() {
791 if err := o.modeset(); err != nil {893 if err := o.modeset(); err != nil {
792 if !o.commitFailed(err, &enabled) {894 if !o.commitFailed(err, &enabled) {
@@ -919,8 +1021,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
919 }1021 }
920 // The overlay buffer is on screen like a scanned-out one: it1022 // The overlay buffer is on screen like a scanned-out one: it
921 // is reported shown, so it is not released under the plane.1023 // is reported shown, so it is not released under the plane.
922- f.queued, f.zeroCopy = ov.buf, ov.id1024+ f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
923 err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov)1025 err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov)
1026+ if err != nil {
1027+ // The GPU may still read client buffers for this frame.
1028+ o.holdRead(done)
1029+ }
924 if done != nil {1030 if done != nil {
925 done.Close()1031 done.Close()
926 }1032 }
@@ -958,12 +1064,16 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
958 // The kernel still works on an earlier commit (e.g. across a VT1064 // The kernel still works on an earlier commit (e.g. across a VT
959 // switch, or one whose event already came): wait for any event1065 // switch, or one whose event already came): wait for any event
960 // of this CRTC, or retry after busyRetry since none may come.1066 // of this CRTC, or retry after busyRetry since none may come.
961- o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()1067+ o.endPending()
1068+ now := time.Now()
1069+ o.pending, o.flipStart, o.stuckAt = true, now, now.Add(busyRetry)
962 o.pendingSerial = 01070 o.pendingSerial = 0
963- if o.busySince.IsZero() {1071+ // A run of EBUSY: each refusal follows the previous retry.
964- o.busySince = o.flipStart1072+ if o.busySince.IsZero() || now.Sub(o.lastBusy) > 4*busyRetry {
1073+ o.busySince = now
965 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")1074 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
966 }1075 }
1076+ o.lastBusy = now
967 return true1077 return true
968 case lostMaster(err):1078 case lostMaster(err):
969 o.log.Warn().Err(err).Msg("commit refused")1079 o.log.Warn().Err(err).Msg("commit refused")
@@ -982,7 +1092,7 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
982 return false1092 return false
983 }1093 }
984 f := o.pendingFrame1094 f := o.pendingFrame
985- o.pending, o.pendingFrame = false, pendingFrame{}1095+ o.endPending()
986 o.busySince = time.Time{}1096 o.busySince = time.Time{}
987 if d := time.Since(o.flipStart); d > 20*time.Millisecond {1097 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
988 o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")1098 o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
@@ -993,6 +1103,11 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
993 if !f.frame {1103 if !f.frame {
994 return true1104 return true
995 }1105 }
1106+ if f.composed {
1107+ // Frames render in order: this flip's fence signalled after
1108+ // those of frames rendered before it and not committed.
1109+ o.dropRead()
1110+ }
996 o.flips++1111 o.flips++
997 o.shown = f.queued1112 o.shown = f.queued
998 if o.queued == f.queued {1113 if o.queued == f.queued {
M internal/adapters/drm/stuck_test.go +155 -13
@@ -3,6 +3,8 @@ package drm
33 import (
44 "context"
55 "os"
6+ "sync"
7+ "sync/atomic"
68 "testing"
79 "time"
810
@@ -16,6 +18,28 @@ import (
1618 // modeset test and modeset) and whose CRTC never sends an event. It
1719 // returns the recorded commits once n were made, failing after 2s.
1820 func runStuck(t *testing.T, n int, errs ...error) []commitRec {
21+ t.Helper()
22+ h := startStuck(t, nil, errs...)
23+ h.scenes <- ports.Scene{Seq: 1}
24+ waitFor(t, func() bool { return h.count() >= n })
25+ return h.stop()
26+}
27+
28+// stuckRun is an output run by startStuck.
29+type stuckRun struct {
30+ t *testing.T
31+ scenes chan ports.Scene
32+ contents chan ports.SurfaceContent
33+ active chan bool
34+ presented chan ports.OutputPresented
35+ count func() int
36+ stop func() []commitRec
37+}
38+
39+// startStuck runs an output whose commits return errs (after the start
40+// modeset test and modeset) and whose CRTC never sends an event. render
41+// makes each frame's fence once the output started (nil: none).
42+func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
1943 t.Helper()
2044 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
2145 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
@@ -29,33 +53,41 @@ func runStuck(t *testing.T, n int, errs ...error) []commitRec {
2953 }
3054 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
3155 r.EXPECT().UseTarget(mock.Anything).Return()
32- r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
56+ var started atomic.Bool
57+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
58+ if render == nil || !started.Load() {
59+ return nil, nil
60+ }
61+ return render(), nil
62+ })
3363 r.EXPECT().Close().Return().Once()
3464 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
3565 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
3666 k.EXPECT().destroyBlob(mock.Anything).Return(nil)
3767 k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
38- scenes := make(chan ports.Scene, 1)
68+ h := &stuckRun{t: t, scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 1), active: make(chan bool, 1), presented: make(chan ports.OutputPresented, 64)}
3969 ctx, cancel := context.WithCancel(context.Background())
4070 done := make(chan error, 1)
4171 go func() {
42- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 64))
72+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented)
4373 }()
44- count := func() int {
74+ h.count = func() int {
4575 commitMu.Lock()
4676 defer commitMu.Unlock()
4777 return len(*commits)
4878 }
49- waitFor(t, func() bool { return count() == 2 })
50- scenes <- ports.Scene{Seq: 1}
51- waitFor(t, func() bool { return count() >= n })
52- cancel()
53- if err := <-done; err != nil {
54- t.Fatal(err)
79+ h.stop = func() []commitRec {
80+ cancel()
81+ if err := <-done; err != nil {
82+ t.Fatal(err)
83+ }
84+ commitMu.Lock()
85+ defer commitMu.Unlock()
86+ return append([]commitRec(nil), (*commits)...)
5587 }
56- commitMu.Lock()
57- defer commitMu.Unlock()
58- return append([]commitRec(nil), (*commits)...)
88+ waitFor(t, func() bool { return h.count() == 2 })
89+ started.Store(true)
90+ return h
5991 }
6092
6193 // A commit whose event never comes must not stop the output: it is
@@ -100,3 +132,113 @@ func TestLastingBusyModesets(t *testing.T) {
100132 t.Fatal("no modeset while EBUSY lasted")
101133 }
102134 }
135+
136+// fencePipe is a fence that signals when its write end is written.
137+func fencePipe(t *testing.T) (fence, signal *os.File) {
138+ t.Helper()
139+ r, w, err := os.Pipe()
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ t.Cleanup(func() { r.Close(); w.Close() })
144+ return r, w
145+}
146+
147+// A frame whose GPU fence has not signalled is slow, not lost: it keeps
148+// waiting past the limit; once the fence signalled and still no event
149+// came, the output modesets.
150+func TestUnsignalledFenceDelaysRecovery(t *testing.T) {
151+ var mu sync.Mutex
152+ var signals []*os.File
153+ h := startStuck(t, func() *os.File {
154+ f, w := fencePipe(t)
155+ mu.Lock()
156+ signals = append(signals, w)
157+ mu.Unlock()
158+ return f
159+ })
160+ h.scenes <- ports.Scene{Seq: 1}
161+ waitFor(t, func() bool { return h.count() == 3 })
162+ time.Sleep(3 * 200 * time.Millisecond)
163+ if n := h.count(); n != 3 {
164+ t.Fatalf("%d commits while the frame's fence is unsignalled", n)
165+ }
166+ mu.Lock()
167+ _, _ = signals[0].Write([]byte{1})
168+ mu.Unlock()
169+ waitFor(t, func() bool { return h.count() >= 4 })
170+ if c := h.stop(); c[3].flags&atomicAllowModes == 0 {
171+ t.Fatalf("no modeset once the fence signalled: %#x", c[3].flags)
172+ }
173+}
174+
175+// A frame refused with EBUSY may still be read by the GPU: what was seen
176+// is reported only once its fence signalled.
177+func TestSeenWaitsForRefusedFrameFence(t *testing.T) {
178+ var mu sync.Mutex
179+ var signals []*os.File
180+ h := startStuck(t, func() *os.File {
181+ f, w := fencePipe(t)
182+ mu.Lock()
183+ signals = append(signals, w)
184+ mu.Unlock()
185+ return f
186+ }, unix.EBUSY, unix.EACCES)
187+ h.scenes <- ports.Scene{Seq: 1}
188+ // EBUSY, then the retry loses DRM master: nothing is pending.
189+ waitFor(t, func() bool { return h.count() == 4 })
190+ h.contents <- ports.SurfaceContent{ID: 1, Seq: 7}
191+ select {
192+ case p := <-h.presented:
193+ t.Fatalf("seen reported while refused frames may be read: %+v", p)
194+ case <-time.After(50 * time.Millisecond):
195+ }
196+ mu.Lock()
197+ for _, w := range signals {
198+ _, _ = w.Write([]byte{1})
199+ }
200+ mu.Unlock()
201+ select {
202+ case p := <-h.presented:
203+ if p.Seen[1] != 7 {
204+ t.Fatalf("report %+v", p)
205+ }
206+ case <-time.After(2 * time.Second):
207+ t.Fatal("seen not reported once the fences signalled")
208+ }
209+ h.stop()
210+}
211+
212+// An EBUSY run ends when commits stop being refused: a later EBUSY is
213+// retried, not taken as the same run lasting past the limit.
214+func TestLaterBusyStartsNewRun(t *testing.T) {
215+ o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY)
216+ o.stuckAfter = 100 * time.Millisecond
217+ enabled := true
218+ o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
219+ time.Sleep(busyRetry)
220+ if o.expire() {
221+ t.Fatal("first EBUSY retry modesets")
222+ }
223+ time.Sleep(2 * o.stuckAfter)
224+ o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
225+ time.Sleep(busyRetry)
226+ if o.expire() {
227+ t.Fatal("a new EBUSY taken as the old run: modeset")
228+ }
229+}
230+
231+// A modeset that fails keeps what is on screen: buffers still shown are
232+// not released under the plane.
233+func TestFailedModesetKeepsShown(t *testing.T) {
234+ o, k, _ := testOutput(t, unix.EACCES)
235+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
236+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
237+ o.shown, o.queued = 5, 6
238+ if err := o.modeset(); err == nil {
239+ t.Fatal("modeset succeeded")
240+ }
241+ if o.shown != 5 || o.queued != 6 {
242+ t.Fatalf("shown %d queued %d after a failed modeset", o.shown, o.queued)
243+ }
244+}
@@ -3,6 +3,8 @@ package drm
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6+ "sync"
7+ "sync/atomic"
6 "testing"8 "testing"
7 "time"9 "time"
8 10
@@ -16,6 +18,28 @@ import (
16 // modeset test and modeset) and whose CRTC never sends an event. It18 // modeset test and modeset) and whose CRTC never sends an event. It
17 // returns the recorded commits once n were made, failing after 2s.19 // returns the recorded commits once n were made, failing after 2s.
18 func runStuck(t *testing.T, n int, errs ...error) []commitRec {20 func runStuck(t *testing.T, n int, errs ...error) []commitRec {
21+ t.Helper()
22+ h := startStuck(t, nil, errs...)
23+ h.scenes <- ports.Scene{Seq: 1}
24+ waitFor(t, func() bool { return h.count() >= n })
25+ return h.stop()
26+}
27+
28+// stuckRun is an output run by startStuck.
29+type stuckRun struct {
30+ t *testing.T
31+ scenes chan ports.Scene
32+ contents chan ports.SurfaceContent
33+ active chan bool
34+ presented chan ports.OutputPresented
35+ count func() int
36+ stop func() []commitRec
37+}
38+
39+// startStuck runs an output whose commits return errs (after the start
40+// modeset test and modeset) and whose CRTC never sends an event. render
41+// makes each frame's fence once the output started (nil: none).
42+func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
19 t.Helper()43 t.Helper()
20 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)44 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
21 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}45 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
@@ -29,33 +53,41 @@ func runStuck(t *testing.T, n int, errs ...error) []commitRec {
29 }53 }
30 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()54 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
31 r.EXPECT().UseTarget(mock.Anything).Return()55 r.EXPECT().UseTarget(mock.Anything).Return()
32- r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)56+ var started atomic.Bool
57+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
58+ if render == nil || !started.Load() {
59+ return nil, nil
60+ }
61+ return render(), nil
62+ })
33 r.EXPECT().Close().Return().Once()63 r.EXPECT().Close().Return().Once()
34 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()64 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
35 k.EXPECT().createBlob(mock.Anything).Return(99, nil)65 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
36 k.EXPECT().destroyBlob(mock.Anything).Return(nil)66 k.EXPECT().destroyBlob(mock.Anything).Return(nil)
37 k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()67 k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
38- scenes := make(chan ports.Scene, 1)68+ h := &stuckRun{t: t, scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 1), active: make(chan bool, 1), presented: make(chan ports.OutputPresented, 64)}
39 ctx, cancel := context.WithCancel(context.Background())69 ctx, cancel := context.WithCancel(context.Background())
40 done := make(chan error, 1)70 done := make(chan error, 1)
41 go func() {71 go func() {
42- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 64))72+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented)
43 }()73 }()
44- count := func() int {74+ h.count = func() int {
45 commitMu.Lock()75 commitMu.Lock()
46 defer commitMu.Unlock()76 defer commitMu.Unlock()
47 return len(*commits)77 return len(*commits)
48 }78 }
49- waitFor(t, func() bool { return count() == 2 })79+ h.stop = func() []commitRec {
50- scenes <- ports.Scene{Seq: 1}80+ cancel()
51- waitFor(t, func() bool { return count() >= n })81+ if err := <-done; err != nil {
52- cancel()82+ t.Fatal(err)
53- if err := <-done; err != nil {83+ }
54- t.Fatal(err)84+ commitMu.Lock()
85+ defer commitMu.Unlock()
86+ return append([]commitRec(nil), (*commits)...)
55 }87 }
56- commitMu.Lock()88+ waitFor(t, func() bool { return h.count() == 2 })
57- defer commitMu.Unlock()89+ started.Store(true)
58- return append([]commitRec(nil), (*commits)...)90+ return h
59 }91 }
60 92
61 // A commit whose event never comes must not stop the output: it is93 // A commit whose event never comes must not stop the output: it is
@@ -100,3 +132,113 @@ func TestLastingBusyModesets(t *testing.T) {
100 t.Fatal("no modeset while EBUSY lasted")132 t.Fatal("no modeset while EBUSY lasted")
101 }133 }
102 }134 }
135+
136+// fencePipe is a fence that signals when its write end is written.
137+func fencePipe(t *testing.T) (fence, signal *os.File) {
138+ t.Helper()
139+ r, w, err := os.Pipe()
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ t.Cleanup(func() { r.Close(); w.Close() })
144+ return r, w
145+}
146+
147+// A frame whose GPU fence has not signalled is slow, not lost: it keeps
148+// waiting past the limit; once the fence signalled and still no event
149+// came, the output modesets.
150+func TestUnsignalledFenceDelaysRecovery(t *testing.T) {
151+ var mu sync.Mutex
152+ var signals []*os.File
153+ h := startStuck(t, func() *os.File {
154+ f, w := fencePipe(t)
155+ mu.Lock()
156+ signals = append(signals, w)
157+ mu.Unlock()
158+ return f
159+ })
160+ h.scenes <- ports.Scene{Seq: 1}
161+ waitFor(t, func() bool { return h.count() == 3 })
162+ time.Sleep(3 * 200 * time.Millisecond)
163+ if n := h.count(); n != 3 {
164+ t.Fatalf("%d commits while the frame's fence is unsignalled", n)
165+ }
166+ mu.Lock()
167+ _, _ = signals[0].Write([]byte{1})
168+ mu.Unlock()
169+ waitFor(t, func() bool { return h.count() >= 4 })
170+ if c := h.stop(); c[3].flags&atomicAllowModes == 0 {
171+ t.Fatalf("no modeset once the fence signalled: %#x", c[3].flags)
172+ }
173+}
174+
175+// A frame refused with EBUSY may still be read by the GPU: what was seen
176+// is reported only once its fence signalled.
177+func TestSeenWaitsForRefusedFrameFence(t *testing.T) {
178+ var mu sync.Mutex
179+ var signals []*os.File
180+ h := startStuck(t, func() *os.File {
181+ f, w := fencePipe(t)
182+ mu.Lock()
183+ signals = append(signals, w)
184+ mu.Unlock()
185+ return f
186+ }, unix.EBUSY, unix.EACCES)
187+ h.scenes <- ports.Scene{Seq: 1}
188+ // EBUSY, then the retry loses DRM master: nothing is pending.
189+ waitFor(t, func() bool { return h.count() == 4 })
190+ h.contents <- ports.SurfaceContent{ID: 1, Seq: 7}
191+ select {
192+ case p := <-h.presented:
193+ t.Fatalf("seen reported while refused frames may be read: %+v", p)
194+ case <-time.After(50 * time.Millisecond):
195+ }
196+ mu.Lock()
197+ for _, w := range signals {
198+ _, _ = w.Write([]byte{1})
199+ }
200+ mu.Unlock()
201+ select {
202+ case p := <-h.presented:
203+ if p.Seen[1] != 7 {
204+ t.Fatalf("report %+v", p)
205+ }
206+ case <-time.After(2 * time.Second):
207+ t.Fatal("seen not reported once the fences signalled")
208+ }
209+ h.stop()
210+}
211+
212+// An EBUSY run ends when commits stop being refused: a later EBUSY is
213+// retried, not taken as the same run lasting past the limit.
214+func TestLaterBusyStartsNewRun(t *testing.T) {
215+ o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY)
216+ o.stuckAfter = 100 * time.Millisecond
217+ enabled := true
218+ o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
219+ time.Sleep(busyRetry)
220+ if o.expire() {
221+ t.Fatal("first EBUSY retry modesets")
222+ }
223+ time.Sleep(2 * o.stuckAfter)
224+ o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
225+ time.Sleep(busyRetry)
226+ if o.expire() {
227+ t.Fatal("a new EBUSY taken as the old run: modeset")
228+ }
229+}
230+
231+// A modeset that fails keeps what is on screen: buffers still shown are
232+// not released under the plane.
233+func TestFailedModesetKeepsShown(t *testing.T) {
234+ o, k, _ := testOutput(t, unix.EACCES)
235+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
236+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
237+ o.shown, o.queued = 5, 6
238+ if err := o.modeset(); err == nil {
239+ t.Fatal("modeset succeeded")
240+ }
241+ if o.shown != 5 || o.queued != 6 {
242+ t.Fatalf("shown %d queued %d after a failed modeset", o.shown, o.queued)
243+ }
244+}

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