detached ee184c9

fix(drm): bound the wait for commit events so an output never stays pending

An output waited forever for the event of its pending commit: a commit
whose event never came (fence never signalled, event missed) or an
EBUSY wait with no commit of ours in flight froze the output until a VT
switch. The event is now awaited at most one second, then the commit is
abandoned with a warning (connector, serial, kind, age) and a modeset;
EBUSY is retried after 50 ms and modesets when it lasts a second. Stats
and slow flip lines carry the connector and the pending state.
commit
ee184c9f6b814e53ce70b682dd6b83ad54f68959
author
brice · · 2026-09-26 13:32 UTC
parent
7e8d744
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +185 -6
M internal/adapters/drm/output.go +83 -6
@@ -20,7 +20,9 @@ import (
2020 // Output is one KMS connector on a CRTC, driven by atomic commits (ADR
2121 // 015). One goroutine runs it (Run) and closes it. Every commit that
2222 // changes the screen asks for an event and leaves the output pending until
23-// it arrives: the next commit waits for it, so none fails with EBUSY.
23+// it arrives: the next commit waits for it. The kernel may still refuse
24+// it with EBUSY (it retries soon), and an event that never comes is
25+// bounded (stuckTimeout, then a modeset).
2426 type Output struct {
2527 k kms
2628 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -61,6 +63,11 @@ type Output struct {
6163 pendingSerial, nextSerial uint64
6264 flipStart time.Time
6365 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
6471 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
6572 // screen and queued (0: the composed image).
6673 scanout bool
@@ -117,6 +124,54 @@ func (o *Output) userData(kind uint64) uint64 {
117124 func (o *Output) begin(f pendingFrame) {
118125 o.pendingSerial = o.nextSerial
119126 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
127+ o.busySince = time.Time{}
128+}
129+
130+// Bounds on waiting for a commit event: no path may leave an output
131+// pending forever.
132+const (
133+ stuckTimeout = time.Second
134+ busyRetry = 50 * time.Millisecond
135+)
136+
137+func (o *Output) stuckLimit() time.Duration {
138+ if o.stuckAfter > 0 {
139+ return o.stuckAfter
140+ }
141+ return stuckTimeout
142+}
143+
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.
158+func (o *Output) expire() bool {
159+ age := time.Since(o.flipStart)
160+ kind := "state"
161+ if o.pendingFrame.frame {
162+ kind = "frame"
163+ }
164+ o.pending, o.pendingFrame = false, pendingFrame{}
165+ if o.pendingSerial == 0 {
166+ busy := time.Since(o.busySince)
167+ if o.busySince.IsZero() || busy < o.stuckLimit() {
168+ return false
169+ }
170+ o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
171+ return true
172+ }
173+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
174+ return true
120175 }
121176
122177 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -261,7 +316,7 @@ func (o *Output) primaryProps(req *atomicReq, fb uint32) {
261316 // other plane on the CRTC; needed at start and after every VT resume.
262317 // It blocks until the kernel applied it.
263318 func (o *Output) modeset() error {
264- o.pending = false
319+ o.pending, o.busySince = false, time.Time{}
265320 o.pendingFrame = pendingFrame{}
266321 o.shown, o.queued = 0, 0
267322 blob, err := o.modeBlobFor(o.mode)
@@ -722,10 +777,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
722777 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
723778 vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))
724779 }
780+ // A commit whose event never comes must not stop the output.
781+ var stuck <-chan time.Time
782+ if enabled && o.pending {
783+ stuck = time.After(max(0, o.pendingLimit()-time.Since(o.flipStart)))
784+ }
725785 stateDirty := false
726786 select {
727787 case <-retry:
728788 continue
789+ case <-stuck:
790+ if o.expire() {
791+ if err := o.modeset(); err != nil {
792+ if !o.commitFailed(err, &enabled) {
793+ return fmt.Errorf("recovery modeset: %w", err)
794+ }
795+ continue
796+ }
797+ }
798+ dirty = haveScene
799+ stateDirty = true
729800 case <-vrrOff:
730801 stateDirty = true
731802 case <-cursorWake:
@@ -795,7 +866,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
795866 dirty = dirty || scene.Shows(c.ID)
796867 case <-stats.C:
797868 o.dropClientFBs(time.Now(), false)
798- ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
869+ ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.pending)
799870 if o.cursor != nil {
800871 cs := o.cursor.TakeStats()
801872 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)
@@ -884,10 +955,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
884955 func (o *Output) commitFailed(err error, enabled *bool) bool {
885956 switch {
886957 case errors.Is(err, unix.EBUSY):
887- // A commit of ours is in flight (e.g. across a VT switch): wait
888- // for any event of this CRTC.
958+ // The kernel still works on an earlier commit (e.g. across a VT
959+ // switch, or one whose event already came): wait for any event
960+ // of this CRTC, or retry after busyRetry since none may come.
889961 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
890962 o.pendingSerial = 0
963+ if o.busySince.IsZero() {
964+ o.busySince = o.flipStart
965+ o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
966+ }
891967 return true
892968 case lostMaster(err):
893969 o.log.Warn().Err(err).Msg("commit refused")
@@ -907,8 +983,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
907983 }
908984 f := o.pendingFrame
909985 o.pending, o.pendingFrame = false, pendingFrame{}
986+ o.busySince = time.Time{}
910987 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
911- o.log.Info().Dur("flip_ms", d).Msg("slow flip")
988+ o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
912989 }
913990 if o.cursor != nil {
914991 o.cursor.landed()
@@ -20,7 +20,9 @@ import (
20 // Output is one KMS connector on a CRTC, driven by atomic commits (ADR20 // Output is one KMS connector on a CRTC, driven by atomic commits (ADR
21 // 015). One goroutine runs it (Run) and closes it. Every commit that21 // 015). One goroutine runs it (Run) and closes it. Every commit that
22 // changes the screen asks for an event and leaves the output pending until22 // changes the screen asks for an event and leaves the output pending until
23-// it arrives: the next commit waits for it, so none fails with EBUSY.23+// it arrives: the next commit waits for it. The kernel may still refuse
24+// it with EBUSY (it retries soon), and an event that never comes is
25+// bounded (stuckTimeout, then a modeset).
24 type Output struct {26 type Output struct {
25 k kms27 k kms
26 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents28 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -61,6 +63,11 @@ type Output struct {
61 pendingSerial, nextSerial uint6463 pendingSerial, nextSerial uint64
62 flipStart time.Time64 flipStart time.Time
63 flips int65 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
64 // Direct scanout: client framebuffers by DMABuf ID and the buffer on71 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
65 // screen and queued (0: the composed image).72 // screen and queued (0: the composed image).
66 scanout bool73 scanout bool
@@ -117,6 +124,54 @@ func (o *Output) userData(kind uint64) uint64 {
117 func (o *Output) begin(f pendingFrame) {124 func (o *Output) begin(f pendingFrame) {
118 o.pendingSerial = o.nextSerial125 o.pendingSerial = o.nextSerial
119 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()126 o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
127+ o.busySince = time.Time{}
128+}
129+
130+// Bounds on waiting for a commit event: no path may leave an output
131+// pending forever.
132+const (
133+ stuckTimeout = time.Second
134+ busyRetry = 50 * time.Millisecond
135+)
136+
137+func (o *Output) stuckLimit() time.Duration {
138+ if o.stuckAfter > 0 {
139+ return o.stuckAfter
140+ }
141+ return stuckTimeout
142+}
143+
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.
158+func (o *Output) expire() bool {
159+ age := time.Since(o.flipStart)
160+ kind := "state"
161+ if o.pendingFrame.frame {
162+ kind = "frame"
163+ }
164+ o.pending, o.pendingFrame = false, pendingFrame{}
165+ if o.pendingSerial == 0 {
166+ busy := time.Since(o.busySince)
167+ if o.busySince.IsZero() || busy < o.stuckLimit() {
168+ return false
169+ }
170+ o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
171+ return true
172+ }
173+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
174+ return true
120 }175 }
121 176
122 // CursorLoader returns the image of a cursor at an output scale, at most177 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -261,7 +316,7 @@ func (o *Output) primaryProps(req *atomicReq, fb uint32) {
261 // other plane on the CRTC; needed at start and after every VT resume.316 // other plane on the CRTC; needed at start and after every VT resume.
262 // It blocks until the kernel applied it.317 // It blocks until the kernel applied it.
263 func (o *Output) modeset() error {318 func (o *Output) modeset() error {
264- o.pending = false319+ o.pending, o.busySince = false, time.Time{}
265 o.pendingFrame = pendingFrame{}320 o.pendingFrame = pendingFrame{}
266 o.shown, o.queued = 0, 0321 o.shown, o.queued = 0, 0
267 blob, err := o.modeBlobFor(o.mode)322 blob, err := o.modeBlobFor(o.mode)
@@ -722,10 +777,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
722 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {777 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
723 vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))778 vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))
724 }779 }
780+ // A commit whose event never comes must not stop the output.
781+ var stuck <-chan time.Time
782+ if enabled && o.pending {
783+ stuck = time.After(max(0, o.pendingLimit()-time.Since(o.flipStart)))
784+ }
725 stateDirty := false785 stateDirty := false
726 select {786 select {
727 case <-retry:787 case <-retry:
728 continue788 continue
789+ case <-stuck:
790+ if o.expire() {
791+ if err := o.modeset(); err != nil {
792+ if !o.commitFailed(err, &enabled) {
793+ return fmt.Errorf("recovery modeset: %w", err)
794+ }
795+ continue
796+ }
797+ }
798+ dirty = haveScene
799+ stateDirty = true
729 case <-vrrOff:800 case <-vrrOff:
730 stateDirty = true801 stateDirty = true
731 case <-cursorWake:802 case <-cursorWake:
@@ -795,7 +866,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
795 dirty = dirty || scene.Shows(c.ID)866 dirty = dirty || scene.Shows(c.ID)
796 case <-stats.C:867 case <-stats.C:
797 o.dropClientFBs(time.Now(), false)868 o.dropClientFBs(time.Now(), false)
798- ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)869+ ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.pending)
799 if o.cursor != nil {870 if o.cursor != nil {
800 cs := o.cursor.TakeStats()871 cs := o.cursor.TakeStats()
801 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)872 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)
@@ -884,10 +955,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
884 func (o *Output) commitFailed(err error, enabled *bool) bool {955 func (o *Output) commitFailed(err error, enabled *bool) bool {
885 switch {956 switch {
886 case errors.Is(err, unix.EBUSY):957 case errors.Is(err, unix.EBUSY):
887- // A commit of ours is in flight (e.g. across a VT switch): wait958+ // The kernel still works on an earlier commit (e.g. across a VT
888- // for any event of this CRTC.959+ // switch, or one whose event already came): wait for any event
960+ // of this CRTC, or retry after busyRetry since none may come.
889 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()961 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
890 o.pendingSerial = 0962 o.pendingSerial = 0
963+ if o.busySince.IsZero() {
964+ o.busySince = o.flipStart
965+ o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
966+ }
891 return true967 return true
892 case lostMaster(err):968 case lostMaster(err):
893 o.log.Warn().Err(err).Msg("commit refused")969 o.log.Warn().Err(err).Msg("commit refused")
@@ -907,8 +983,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
907 }983 }
908 f := o.pendingFrame984 f := o.pendingFrame
909 o.pending, o.pendingFrame = false, pendingFrame{}985 o.pending, o.pendingFrame = false, pendingFrame{}
986+ o.busySince = time.Time{}
910 if d := time.Since(o.flipStart); d > 20*time.Millisecond {987 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
911- o.log.Info().Dur("flip_ms", d).Msg("slow flip")988+ o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
912 }989 }
913 if o.cursor != nil {990 if o.cursor != nil {
914 o.cursor.landed()991 o.cursor.landed()
A internal/adapters/drm/stuck_test.go +102 -0
@@ -0,0 +1,102 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/nefertty/internal/mocks/ports"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// runStuck runs an output whose commits return errs (after the start
16+// modeset test and modeset) and whose CRTC never sends an event. It
17+// returns the recorded commits once n were made, failing after 2s.
18+func runStuck(t *testing.T, n int, errs ...error) []commitRec {
19+ t.Helper()
20+ o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
21+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
22+ o.stuckAfter = 200 * time.Millisecond
23+ o.flipped = make(chan flipEvent) // no event ever
24+ r := portsmocks.NewMockRenderer(t)
25+ buf := func() ports.DMABuf {
26+ f, w, _ := os.Pipe()
27+ w.Close()
28+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
29+ }
30+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
31+ r.EXPECT().UseTarget(mock.Anything).Return()
32+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
33+ r.EXPECT().Close().Return().Once()
34+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
35+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
36+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
37+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
38+ scenes := make(chan ports.Scene, 1)
39+ ctx, cancel := context.WithCancel(context.Background())
40+ done := make(chan error, 1)
41+ 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))
43+ }()
44+ count := func() int {
45+ commitMu.Lock()
46+ defer commitMu.Unlock()
47+ return len(*commits)
48+ }
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)
55+ }
56+ commitMu.Lock()
57+ defer commitMu.Unlock()
58+ return append([]commitRec(nil), (*commits)...)
59+}
60+
61+// A commit whose event never comes must not stop the output: it is
62+// abandoned with a modeset, then the scene is committed again.
63+func TestMissingEventRecoversWithModeset(t *testing.T) {
64+ c := runStuck(t, 5)
65+ if c[2].user&3 != userFrame {
66+ t.Fatalf("commit 2 is not a frame: %+v", c[2])
67+ }
68+ if c[3].flags&atomicAllowModes == 0 {
69+ t.Fatalf("no recovery modeset after the missing event: flags %#x", c[3].flags)
70+ }
71+ if c[4].user&3 != userFrame || c[4].user>>userKindBits <= c[2].user>>userKindBits {
72+ t.Fatalf("scene not committed again after the modeset: %+v", c[4])
73+ }
74+}
75+
76+// EBUSY with no commit of ours in flight gets no event: the commit is
77+// retried without one.
78+func TestBusyWithoutEventRetries(t *testing.T) {
79+ c := runStuck(t, 4, unix.EBUSY)
80+ if c[2].user&3 != userFrame || c[3].user&3 != userFrame || c[3].flags&atomicAllowModes != 0 {
81+ t.Fatalf("busy frame not retried: %+v", c[2:])
82+ }
83+}
84+
85+// EBUSY that lasts past the limit ends in a modeset, and a modeset
86+// refused as busy is retried too.
87+func TestLastingBusyModesets(t *testing.T) {
88+ errs := make([]error, 8)
89+ for i := range errs {
90+ errs[i] = unix.EBUSY
91+ }
92+ c := runStuck(t, 2+len(errs)+1, errs...)
93+ found := false
94+ for _, r := range c[2:] {
95+ if r.flags&atomicAllowModes != 0 {
96+ found = true
97+ }
98+ }
99+ if !found {
100+ t.Fatal("no modeset while EBUSY lasted")
101+ }
102+}
@@ -0,0 +1,102 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/nefertty/internal/mocks/ports"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// runStuck runs an output whose commits return errs (after the start
16+// modeset test and modeset) and whose CRTC never sends an event. It
17+// returns the recorded commits once n were made, failing after 2s.
18+func runStuck(t *testing.T, n int, errs ...error) []commitRec {
19+ t.Helper()
20+ o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
21+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
22+ o.stuckAfter = 200 * time.Millisecond
23+ o.flipped = make(chan flipEvent) // no event ever
24+ r := portsmocks.NewMockRenderer(t)
25+ buf := func() ports.DMABuf {
26+ f, w, _ := os.Pipe()
27+ w.Close()
28+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
29+ }
30+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
31+ r.EXPECT().UseTarget(mock.Anything).Return()
32+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
33+ r.EXPECT().Close().Return().Once()
34+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
35+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
36+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
37+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
38+ scenes := make(chan ports.Scene, 1)
39+ ctx, cancel := context.WithCancel(context.Background())
40+ done := make(chan error, 1)
41+ 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))
43+ }()
44+ count := func() int {
45+ commitMu.Lock()
46+ defer commitMu.Unlock()
47+ return len(*commits)
48+ }
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)
55+ }
56+ commitMu.Lock()
57+ defer commitMu.Unlock()
58+ return append([]commitRec(nil), (*commits)...)
59+}
60+
61+// A commit whose event never comes must not stop the output: it is
62+// abandoned with a modeset, then the scene is committed again.
63+func TestMissingEventRecoversWithModeset(t *testing.T) {
64+ c := runStuck(t, 5)
65+ if c[2].user&3 != userFrame {
66+ t.Fatalf("commit 2 is not a frame: %+v", c[2])
67+ }
68+ if c[3].flags&atomicAllowModes == 0 {
69+ t.Fatalf("no recovery modeset after the missing event: flags %#x", c[3].flags)
70+ }
71+ if c[4].user&3 != userFrame || c[4].user>>userKindBits <= c[2].user>>userKindBits {
72+ t.Fatalf("scene not committed again after the modeset: %+v", c[4])
73+ }
74+}
75+
76+// EBUSY with no commit of ours in flight gets no event: the commit is
77+// retried without one.
78+func TestBusyWithoutEventRetries(t *testing.T) {
79+ c := runStuck(t, 4, unix.EBUSY)
80+ if c[2].user&3 != userFrame || c[3].user&3 != userFrame || c[3].flags&atomicAllowModes != 0 {
81+ t.Fatalf("busy frame not retried: %+v", c[2:])
82+ }
83+}
84+
85+// EBUSY that lasts past the limit ends in a modeset, and a modeset
86+// refused as busy is retried too.
87+func TestLastingBusyModesets(t *testing.T) {
88+ errs := make([]error, 8)
89+ for i := range errs {
90+ errs[i] = unix.EBUSY
91+ }
92+ c := runStuck(t, 2+len(errs)+1, errs...)
93+ found := false
94+ for _, r := range c[2:] {
95+ if r.flags&atomicAllowModes != 0 {
96+ found = true
97+ }
98+ }
99+ if !found {
100+ t.Fatal("no modeset while EBUSY lasted")
101+ }
102+}

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