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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +185 -6
M internal/adapters/drm/output.go +83 -6
| @@ -20,7 +20,9 @@ import ( | |||
| 20 | 20 | // Output is one KMS connector on a CRTC, driven by atomic commits (ADR | |
| 21 | 21 | // 015). One goroutine runs it (Run) and closes it. Every commit that | |
| 22 | 22 | // 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 | 26 | type Output struct { | |
| 25 | 27 | k kms | |
| 26 | 28 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | |
| @@ -61,6 +63,11 @@ type Output struct { | |||
| 61 | 63 | pendingSerial, nextSerial uint64 | |
| 62 | 64 | flipStart time.Time | |
| 63 | 65 | 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 | 71 | // Direct scanout: client framebuffers by DMABuf ID and the buffer on | |
| 65 | 72 | // screen and queued (0: the composed image). | |
| 66 | 73 | scanout bool | |
| @@ -117,6 +124,54 @@ func (o *Output) userData(kind uint64) uint64 { | |||
| 117 | 124 | func (o *Output) begin(f pendingFrame) { | |
| 118 | 125 | o.pendingSerial = o.nextSerial | |
| 119 | 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 | 177 | // 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 | 316 | // other plane on the CRTC; needed at start and after every VT resume. | |
| 262 | 317 | // It blocks until the kernel applied it. | |
| 263 | 318 | func (o *Output) modeset() error { | |
| 264 | - | o.pending = false | |
| 319 | + | o.pending, o.busySince = false, time.Time{} | |
| 265 | 320 | o.pendingFrame = pendingFrame{} | |
| 266 | 321 | o.shown, o.queued = 0, 0 | |
| 267 | 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 | 777 | if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() { | |
| 723 | 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 | 785 | stateDirty := false | |
| 726 | 786 | select { | |
| 727 | 787 | case <-retry: | |
| 728 | 788 | 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 | 800 | case <-vrrOff: | |
| 730 | 801 | stateDirty = true | |
| 731 | 802 | case <-cursorWake: | |
| @@ -795,7 +866,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 795 | 866 | dirty = dirty || scene.Shows(c.ID) | |
| 796 | 867 | case <-stats.C: | |
| 797 | 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 | 870 | if o.cursor != nil { | |
| 800 | 871 | cs := o.cursor.TakeStats() | |
| 801 | 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 | 955 | func (o *Output) commitFailed(err error, enabled *bool) bool { | |
| 885 | 956 | switch { | |
| 886 | 957 | 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. | |
| 889 | 961 | o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now() | |
| 890 | 962 | 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 | 967 | return true | |
| 892 | 968 | case lostMaster(err): | |
| 893 | 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 | 984 | f := o.pendingFrame | |
| 909 | 985 | o.pending, o.pendingFrame = false, pendingFrame{} | |
| 986 | + | o.busySince = time.Time{} | |
| 910 | 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 | 990 | if o.cursor != nil { | |
| 914 | 991 | o.cursor.landed() | |
| @@ -20,7 +20,9 @@ import ( | |||||
| 20 | // Output is one KMS connector on a CRTC, driven by atomic commits (ADR | 20 | // 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 that | 21 | // 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 until | 22 | // 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 kms | 27 | k kms | ||
| 26 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | 28 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | ||
| @@ -61,6 +63,11 @@ type Output struct { | |||||
| 61 | pendingSerial, nextSerial uint64 | 63 | pendingSerial, nextSerial uint64 | ||
| 62 | flipStart time.Time | 64 | flipStart time.Time | ||
| 63 | flips int | 65 | 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 on | 71 | // 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 bool | 73 | 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.nextSerial | 125 | 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 most | 177 | // 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 = false | 319 | + | o.pending, o.busySince = false, time.Time{} |
| 265 | o.pendingFrame = pendingFrame{} | 320 | o.pendingFrame = pendingFrame{} | ||
| 266 | o.shown, o.queued = 0, 0 | 321 | 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 := false | 785 | stateDirty := false | ||
| 726 | select { | 786 | select { | ||
| 727 | case <-retry: | 787 | case <-retry: | ||
| 728 | continue | 788 | 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 = true | 801 | 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): wait | 958 | + | // 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 = 0 | 962 | 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 true | 967 | 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.pendingFrame | 984 | 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 | + | } | |||