fix(drm): probe the overlay with the cursor state it remembers
testOverlay read the cursor position twice: once for the cache key and once in the TEST_ONLY request. A move on the input goroutine in between cached a state KMS never tested. Take one snapshot and use it for the key, both probes and the colour verdict. Share the overlay commit result handling (colour refusal, overlay conflict, queued buffer) between the composed and overlay-only paths. Cover overlay probe and commit refusals on the overlay-only path, layer content and rewritten scene slices, and the cursor in the probe cache.
- commit
- d251ec83e580f98d75617bca74677d501c47aa58
- author
- brice · · 2026-10-14 08:00 UTC
- parent
- 915e135
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +125 -27
M internal/adapters/drm/frame_submit.go +11 -13
| @@ -219,16 +219,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 219 | 219 | done.Close() | |
| 220 | 220 | } | |
| 221 | 221 | ov.close() | |
| 222 | - | if errors.Is(err, errColorRefused) { | |
| 223 | - | // Cached for the overlay's buffer format: the next frame composes it. | |
| 224 | - | o.setOverlayReason("color_refused") | |
| 225 | - | return false, errOverlayDropped | |
| 226 | - | } | |
| 227 | - | if err != nil && o.overlayConflict(err, ov.buf) { | |
| 228 | - | return false, errOverlayDropped | |
| 229 | - | } | |
| 230 | - | if err == nil { | |
| 231 | - | o.queued = ov.buf | |
| 222 | + | if err = o.finishOverlayCommit(err, ov.buf); err == nil { | |
| 232 | 223 | // The image now holds what was drawn last: composed, or the whole | |
| 233 | 224 | // scene after an overlay refusal. | |
| 234 | 225 | if ov.fb != 0 { | |
| @@ -250,16 +241,23 @@ func (o *Output) commitOverlayOnly(front uint32, scene ports.Scene, f pendingFra | |||
| 250 | 241 | } | |
| 251 | 242 | f.queued, f.zeroCopy = ov.buf, ov.id | |
| 252 | 243 | game := o.vrrProp != 0 && fullscreenShown(&scene) | |
| 253 | - | err := o.commitWith(front, nil, false, o.wantVRR(game), f, ov) | |
| 244 | + | return o.finishOverlayCommit(o.commitWith(front, nil, false, o.wantVRR(game), f, ov), ov.buf) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // finishOverlayCommit applies the result of a frame commit with buf on the | |
| 248 | + | // overlay (0: none): a refusal the overlay causes asks for a frame without | |
| 249 | + | // it (errOverlayDropped); a commit marks buf queued. | |
| 250 | + | func (o *Output) finishOverlayCommit(err error, buf uint64) error { | |
| 254 | 251 | if errors.Is(err, errColorRefused) { | |
| 252 | + | // Cached for the overlay's buffer format: the next frame composes it. | |
| 255 | 253 | o.setOverlayReason("color_refused") | |
| 256 | 254 | return errOverlayDropped | |
| 257 | 255 | } | |
| 258 | - | if err != nil && o.overlayConflict(err, ov.buf) { | |
| 256 | + | if err != nil && o.overlayConflict(err, buf) { | |
| 259 | 257 | return errOverlayDropped | |
| 260 | 258 | } | |
| 261 | 259 | if err == nil { | |
| 262 | - | o.queued = ov.buf | |
| 260 | + | o.queued = buf | |
| 263 | 261 | } | |
| 264 | 262 | return err | |
| 265 | 263 | } | |
| @@ -219,16 +219,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 219 | done.Close() | 219 | done.Close() | ||
| 220 | } | 220 | } | ||
| 221 | ov.close() | 221 | ov.close() | ||
| 222 | - | if errors.Is(err, errColorRefused) { | 222 | + | if err = o.finishOverlayCommit(err, ov.buf); err == nil { |
| 223 | - | // Cached for the overlay's buffer format: the next frame composes it. | |||
| 224 | - | o.setOverlayReason("color_refused") | |||
| 225 | - | return false, errOverlayDropped | |||
| 226 | - | } | |||
| 227 | - | if err != nil && o.overlayConflict(err, ov.buf) { | |||
| 228 | - | return false, errOverlayDropped | |||
| 229 | - | } | |||
| 230 | - | if err == nil { | |||
| 231 | - | o.queued = ov.buf | |||
| 232 | // The image now holds what was drawn last: composed, or the whole | 223 | // The image now holds what was drawn last: composed, or the whole | ||
| 233 | // scene after an overlay refusal. | 224 | // scene after an overlay refusal. | ||
| 234 | if ov.fb != 0 { | 225 | if ov.fb != 0 { | ||
| @@ -250,16 +241,23 @@ func (o *Output) commitOverlayOnly(front uint32, scene ports.Scene, f pendingFra | |||||
| 250 | } | 241 | } | ||
| 251 | f.queued, f.zeroCopy = ov.buf, ov.id | 242 | f.queued, f.zeroCopy = ov.buf, ov.id | ||
| 252 | game := o.vrrProp != 0 && fullscreenShown(&scene) | 243 | game := o.vrrProp != 0 && fullscreenShown(&scene) | ||
| 253 | - | err := o.commitWith(front, nil, false, o.wantVRR(game), f, ov) | 244 | + | return o.finishOverlayCommit(o.commitWith(front, nil, false, o.wantVRR(game), f, ov), ov.buf) |
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // finishOverlayCommit applies the result of a frame commit with buf on the | |||
| 248 | + | // overlay (0: none): a refusal the overlay causes asks for a frame without | |||
| 249 | + | // it (errOverlayDropped); a commit marks buf queued. | |||
| 250 | + | func (o *Output) finishOverlayCommit(err error, buf uint64) error { | |||
| 254 | if errors.Is(err, errColorRefused) { | 251 | if errors.Is(err, errColorRefused) { | ||
| 252 | + | // Cached for the overlay's buffer format: the next frame composes it. | |||
| 255 | o.setOverlayReason("color_refused") | 253 | o.setOverlayReason("color_refused") | ||
| 256 | return errOverlayDropped | 254 | return errOverlayDropped | ||
| 257 | } | 255 | } | ||
| 258 | - | if err != nil && o.overlayConflict(err, ov.buf) { | 256 | + | if err != nil && o.overlayConflict(err, buf) { |
| 259 | return errOverlayDropped | 257 | return errOverlayDropped | ||
| 260 | } | 258 | } | ||
| 261 | if err == nil { | 259 | if err == nil { | ||
| 262 | - | o.queued = ov.buf | 260 | + | o.queued = buf |
| 263 | } | 261 | } | ||
| 264 | return err | 262 | return err | ||
| 265 | } | 263 | } | ||
M internal/adapters/drm/front_test.go +103 -5
| @@ -2,6 +2,7 @@ package drm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "os" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | "testing" | |
| @@ -10,11 +11,12 @@ import ( | |||
| 10 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | // overlayFrames submits the overlay scene once (composed), then with the | |
| 16 | 18 | // overlay window's next buffers: the frames after the first only flip the | |
| 17 | - | // overlay buffer. It returns the windows each render drew. | |
| 19 | + | // overlay buffer. | |
| 18 | 20 | func overlayFrames(t *testing.T, o *Output, r *portsmocks.MockRenderer, s ports.Scene, c map[ports.WindowID]ports.SurfaceContent, frames int) { | |
| 19 | 21 | t.Helper() | |
| 20 | 22 | for i := range frames { | |
| @@ -120,7 +122,10 @@ func TestRunVideoOnOverlayDoesNotRender(t *testing.T) { | |||
| 120 | 122 | o.flipped = flips | |
| 121 | 123 | r := portsmocks.NewMockRenderer(t) | |
| 122 | 124 | pipeBuf := func() ports.DMABuf { | |
| 123 | - | f, w, _ := os.Pipe() | |
| 125 | + | f, w, err := os.Pipe() | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 124 | 129 | w.Close() | |
| 125 | 130 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 126 | 131 | } | |
| @@ -260,10 +265,85 @@ func TestRunVideoOnOverlayDoesNotRender(t *testing.T) { | |||
| 260 | 265 | } | |
| 261 | 266 | } | |
| 262 | 267 | ||
| 268 | + | // Refusals on the overlay-only path: a refused probe composes the whole | |
| 269 | + | // scene at once; a refused commit drops the overlay and asks for a new frame, | |
| 270 | + | // leaving the images and the queue as they were. | |
| 271 | + | func TestSubmitFrameOverlayOnlyRefused(t *testing.T) { | |
| 272 | + | for _, tc := range []struct { | |
| 273 | + | name string | |
| 274 | + | refuse func(r *atomicReq, flags uint32) bool | |
| 275 | + | wantErr error | |
| 276 | + | renders int | |
| 277 | + | }{ | |
| 278 | + | {"probe", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly != 0 }, nil, 1}, | |
| 279 | + | {"commit", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly == 0 }, errOverlayDropped, 0}, | |
| 280 | + | } { | |
| 281 | + | t.Run(tc.name, func(t *testing.T) { | |
| 282 | + | refusing := false | |
| 283 | + | o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error { | |
| 284 | + | if refusing && tc.refuse(r, flags) { | |
| 285 | + | return unix.EINVAL | |
| 286 | + | } | |
| 287 | + | return nil | |
| 288 | + | }) | |
| 289 | + | props := map[string]uint32{"zpos": pZpos} | |
| 290 | + | for n, v := range planeProps { | |
| 291 | + | props[n] = v | |
| 292 | + | } | |
| 293 | + | o.primary.props, o.primary.formats = props, nil | |
| 294 | + | o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}} | |
| 295 | + | o.pickOverlay() | |
| 296 | + | o.cursor, o.vrrProp = nil, 0 | |
| 297 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) { | |
| 298 | + | return uint32(80 + b.ID), nil | |
| 299 | + | }) | |
| 300 | + | s, c := overlayScene() | |
| 301 | + | r := portsmocks.NewMockRenderer(t) | |
| 302 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 303 | + | var drawn []int | |
| 304 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 305 | + | drawn = append(drawn, len(s.Windows)) | |
| 306 | + | return nil, nil | |
| 307 | + | }) | |
| 308 | + | overlayFrames(t, o, r, s, c, 1) // composed, front image held | |
| 309 | + | back, queued := o.back, o.queued | |
| 310 | + | refusing = true | |
| 311 | + | win := c[2] | |
| 312 | + | buf := *win.DMABuf | |
| 313 | + | buf.ID, win.DMABuf, win.Seq = 20, &buf, 2 | |
| 314 | + | c[2] = win | |
| 315 | + | _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil) | |
| 316 | + | if !errors.Is(err, tc.wantErr) && err != tc.wantErr { | |
| 317 | + | t.Fatalf("err %v, want %v", err, tc.wantErr) | |
| 318 | + | } | |
| 319 | + | if got := len(drawn) - 1; got != tc.renders { | |
| 320 | + | t.Fatalf("%d renders, want %d", got, tc.renders) | |
| 321 | + | } | |
| 322 | + | if tc.renders == 1 && drawn[1] != 2 { | |
| 323 | + | t.Fatalf("composed %d windows, want the whole scene", drawn[1]) | |
| 324 | + | } | |
| 325 | + | if tc.wantErr != nil { | |
| 326 | + | if o.back != back || o.queued != queued || o.clientFBs[20].overlayFailed != "cursor_conflict" { | |
| 327 | + | t.Fatalf("back %d queued %d reason %q", o.back, o.queued, o.clientFBs[20].overlayFailed) | |
| 328 | + | } | |
| 329 | + | // The retry composes the window. | |
| 330 | + | refusing = false | |
| 331 | + | if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil { | |
| 332 | + | t.Fatal(err) | |
| 333 | + | } | |
| 334 | + | if drawn[len(drawn)-1] != 2 { | |
| 335 | + | t.Fatalf("retry composed %d windows", drawn[len(drawn)-1]) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | }) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 263 | 342 | // A frame whose buffer KMS took before is not probed again. | |
| 264 | 343 | func TestOverlayProbeCached(t *testing.T) { | |
| 265 | 344 | o, k, commits := overlayOutput(t) | |
| 266 | - | o.cursor = nil | |
| 345 | + | o.cursor.image = true | |
| 346 | + | o.cursor.Move(10, 10) | |
| 267 | 347 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | |
| 268 | 348 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 269 | 349 | s, c := overlayScene() | |
| @@ -281,8 +361,14 @@ func TestOverlayProbeCached(t *testing.T) { | |||
| 281 | 361 | moved.rect.X++ | |
| 282 | 362 | o.testOverlay(70, moved) | |
| 283 | 363 | o.testOverlay(71, ov) | |
| 284 | - | if len(*commits) != 3 { | |
| 285 | - | t.Fatalf("%d probes, want 3", len(*commits)) | |
| 364 | + | o.cursor.Move(20, 10) | |
| 365 | + | o.testOverlay(70, ov) | |
| 366 | + | if len(*commits) != 4 { | |
| 367 | + | t.Fatalf("%d probes, want 4", len(*commits)) | |
| 368 | + | } | |
| 369 | + | // The probe tested the cursor where the cache remembers it. | |
| 370 | + | if v, _ := (*commits)[3].req.value(tCursor, 14); v != 20-uint64(o.cursor.hotX) { | |
| 371 | + | t.Fatalf("probe cursor x %d", v) | |
| 286 | 372 | } | |
| 287 | 373 | // A modeset (VT resume) forgets them. | |
| 288 | 374 | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |
| @@ -312,6 +398,16 @@ func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) { | |||
| 312 | 398 | "image forgotten": func(o *Output, _ *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) { | |
| 313 | 399 | o.front.forget() | |
| 314 | 400 | }, | |
| 401 | + | "layer content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) { | |
| 402 | + | l := c[5] | |
| 403 | + | l.Seq++ | |
| 404 | + | c[5] = l | |
| 405 | + | }, | |
| 406 | + | // The scene's slices rewritten in place by their owner: what the | |
| 407 | + | // image holds is a copy. | |
| 408 | + | "scene slice rewritten": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) { | |
| 409 | + | s.Windows[0].Rect.W = 90 | |
| 410 | + | }, | |
| 315 | 411 | } { | |
| 316 | 412 | t.Run(name, func(t *testing.T) { | |
| 317 | 413 | o, k, _ := overlayOutput(t) | |
| @@ -319,7 +415,9 @@ func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) { | |||
| 319 | 415 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe() | |
| 320 | 416 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 321 | 417 | s, c := overlayScene() | |
| 418 | + | s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerBottom, Rect: ports.Rect{W: 10, H: 10}}} | |
| 322 | 419 | c[1] = ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}} | |
| 420 | + | c[5] = ports.SurfaceContent{ID: 5, Seq: 1, Width: 10, Height: 10, Opaque: true, Solid: &ports.SolidColor{A: 1}} | |
| 323 | 421 | r := portsmocks.NewMockRenderer(t) | |
| 324 | 422 | r.EXPECT().UseTarget(mock.Anything).Return().Twice() | |
| 325 | 423 | var drawn []int | |
| @@ -2,6 +2,7 @@ package drm | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "sync/atomic" | 7 | "sync/atomic" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -10,11 +11,12 @@ import ( | |||||
| 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | ||
| 11 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 12 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | + | "golang.org/x/sys/unix" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | // overlayFrames submits the overlay scene once (composed), then with the | 17 | // overlayFrames submits the overlay scene once (composed), then with the | ||
| 16 | // overlay window's next buffers: the frames after the first only flip the | 18 | // overlay window's next buffers: the frames after the first only flip the | ||
| 17 | - | // overlay buffer. It returns the windows each render drew. | 19 | + | // overlay buffer. |
| 18 | func overlayFrames(t *testing.T, o *Output, r *portsmocks.MockRenderer, s ports.Scene, c map[ports.WindowID]ports.SurfaceContent, frames int) { | 20 | func overlayFrames(t *testing.T, o *Output, r *portsmocks.MockRenderer, s ports.Scene, c map[ports.WindowID]ports.SurfaceContent, frames int) { | ||
| 19 | t.Helper() | 21 | t.Helper() | ||
| 20 | for i := range frames { | 22 | for i := range frames { | ||
| @@ -120,7 +122,10 @@ func TestRunVideoOnOverlayDoesNotRender(t *testing.T) { | |||||
| 120 | o.flipped = flips | 122 | o.flipped = flips | ||
| 121 | r := portsmocks.NewMockRenderer(t) | 123 | r := portsmocks.NewMockRenderer(t) | ||
| 122 | pipeBuf := func() ports.DMABuf { | 124 | pipeBuf := func() ports.DMABuf { | ||
| 123 | - | f, w, _ := os.Pipe() | 125 | + | f, w, err := os.Pipe() |
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 124 | w.Close() | 129 | w.Close() | ||
| 125 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 130 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 126 | } | 131 | } | ||
| @@ -260,10 +265,85 @@ func TestRunVideoOnOverlayDoesNotRender(t *testing.T) { | |||||
| 260 | } | 265 | } | ||
| 261 | } | 266 | } | ||
| 262 | 267 | ||||
| 268 | + | // Refusals on the overlay-only path: a refused probe composes the whole | |||
| 269 | + | // scene at once; a refused commit drops the overlay and asks for a new frame, | |||
| 270 | + | // leaving the images and the queue as they were. | |||
| 271 | + | func TestSubmitFrameOverlayOnlyRefused(t *testing.T) { | |||
| 272 | + | for _, tc := range []struct { | |||
| 273 | + | name string | |||
| 274 | + | refuse func(r *atomicReq, flags uint32) bool | |||
| 275 | + | wantErr error | |||
| 276 | + | renders int | |||
| 277 | + | }{ | |||
| 278 | + | {"probe", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly != 0 }, nil, 1}, | |||
| 279 | + | {"commit", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly == 0 }, errOverlayDropped, 0}, | |||
| 280 | + | } { | |||
| 281 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 282 | + | refusing := false | |||
| 283 | + | o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error { | |||
| 284 | + | if refusing && tc.refuse(r, flags) { | |||
| 285 | + | return unix.EINVAL | |||
| 286 | + | } | |||
| 287 | + | return nil | |||
| 288 | + | }) | |||
| 289 | + | props := map[string]uint32{"zpos": pZpos} | |||
| 290 | + | for n, v := range planeProps { | |||
| 291 | + | props[n] = v | |||
| 292 | + | } | |||
| 293 | + | o.primary.props, o.primary.formats = props, nil | |||
| 294 | + | o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}} | |||
| 295 | + | o.pickOverlay() | |||
| 296 | + | o.cursor, o.vrrProp = nil, 0 | |||
| 297 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) { | |||
| 298 | + | return uint32(80 + b.ID), nil | |||
| 299 | + | }) | |||
| 300 | + | s, c := overlayScene() | |||
| 301 | + | r := portsmocks.NewMockRenderer(t) | |||
| 302 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 303 | + | var drawn []int | |||
| 304 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 305 | + | drawn = append(drawn, len(s.Windows)) | |||
| 306 | + | return nil, nil | |||
| 307 | + | }) | |||
| 308 | + | overlayFrames(t, o, r, s, c, 1) // composed, front image held | |||
| 309 | + | back, queued := o.back, o.queued | |||
| 310 | + | refusing = true | |||
| 311 | + | win := c[2] | |||
| 312 | + | buf := *win.DMABuf | |||
| 313 | + | buf.ID, win.DMABuf, win.Seq = 20, &buf, 2 | |||
| 314 | + | c[2] = win | |||
| 315 | + | _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil) | |||
| 316 | + | if !errors.Is(err, tc.wantErr) && err != tc.wantErr { | |||
| 317 | + | t.Fatalf("err %v, want %v", err, tc.wantErr) | |||
| 318 | + | } | |||
| 319 | + | if got := len(drawn) - 1; got != tc.renders { | |||
| 320 | + | t.Fatalf("%d renders, want %d", got, tc.renders) | |||
| 321 | + | } | |||
| 322 | + | if tc.renders == 1 && drawn[1] != 2 { | |||
| 323 | + | t.Fatalf("composed %d windows, want the whole scene", drawn[1]) | |||
| 324 | + | } | |||
| 325 | + | if tc.wantErr != nil { | |||
| 326 | + | if o.back != back || o.queued != queued || o.clientFBs[20].overlayFailed != "cursor_conflict" { | |||
| 327 | + | t.Fatalf("back %d queued %d reason %q", o.back, o.queued, o.clientFBs[20].overlayFailed) | |||
| 328 | + | } | |||
| 329 | + | // The retry composes the window. | |||
| 330 | + | refusing = false | |||
| 331 | + | if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil { | |||
| 332 | + | t.Fatal(err) | |||
| 333 | + | } | |||
| 334 | + | if drawn[len(drawn)-1] != 2 { | |||
| 335 | + | t.Fatalf("retry composed %d windows", drawn[len(drawn)-1]) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | }) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 263 | // A frame whose buffer KMS took before is not probed again. | 342 | // A frame whose buffer KMS took before is not probed again. | ||
| 264 | func TestOverlayProbeCached(t *testing.T) { | 343 | func TestOverlayProbeCached(t *testing.T) { | ||
| 265 | o, k, commits := overlayOutput(t) | 344 | o, k, commits := overlayOutput(t) | ||
| 266 | - | o.cursor = nil | 345 | + | o.cursor.image = true |
| 346 | + | o.cursor.Move(10, 10) | |||
| 267 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | 347 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | ||
| 268 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | 348 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | ||
| 269 | s, c := overlayScene() | 349 | s, c := overlayScene() | ||
| @@ -281,8 +361,14 @@ func TestOverlayProbeCached(t *testing.T) { | |||||
| 281 | moved.rect.X++ | 361 | moved.rect.X++ | ||
| 282 | o.testOverlay(70, moved) | 362 | o.testOverlay(70, moved) | ||
| 283 | o.testOverlay(71, ov) | 363 | o.testOverlay(71, ov) | ||
| 284 | - | if len(*commits) != 3 { | 364 | + | o.cursor.Move(20, 10) |
| 285 | - | t.Fatalf("%d probes, want 3", len(*commits)) | 365 | + | o.testOverlay(70, ov) |
| 366 | + | if len(*commits) != 4 { | |||
| 367 | + | t.Fatalf("%d probes, want 4", len(*commits)) | |||
| 368 | + | } | |||
| 369 | + | // The probe tested the cursor where the cache remembers it. | |||
| 370 | + | if v, _ := (*commits)[3].req.value(tCursor, 14); v != 20-uint64(o.cursor.hotX) { | |||
| 371 | + | t.Fatalf("probe cursor x %d", v) | |||
| 286 | } | 372 | } | ||
| 287 | // A modeset (VT resume) forgets them. | 373 | // A modeset (VT resume) forgets them. | ||
| 288 | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | 374 | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | ||
| @@ -312,6 +398,16 @@ func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) { | |||||
| 312 | "image forgotten": func(o *Output, _ *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) { | 398 | "image forgotten": func(o *Output, _ *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) { | ||
| 313 | o.front.forget() | 399 | o.front.forget() | ||
| 314 | }, | 400 | }, | ||
| 401 | + | "layer content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) { | |||
| 402 | + | l := c[5] | |||
| 403 | + | l.Seq++ | |||
| 404 | + | c[5] = l | |||
| 405 | + | }, | |||
| 406 | + | // The scene's slices rewritten in place by their owner: what the | |||
| 407 | + | // image holds is a copy. | |||
| 408 | + | "scene slice rewritten": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) { | |||
| 409 | + | s.Windows[0].Rect.W = 90 | |||
| 410 | + | }, | |||
| 315 | } { | 411 | } { | ||
| 316 | t.Run(name, func(t *testing.T) { | 412 | t.Run(name, func(t *testing.T) { | ||
| 317 | o, k, _ := overlayOutput(t) | 413 | o, k, _ := overlayOutput(t) | ||
| @@ -319,7 +415,9 @@ func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) { | |||||
| 319 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe() | 415 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe() | ||
| 320 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | 416 | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | ||
| 321 | s, c := overlayScene() | 417 | s, c := overlayScene() | ||
| 418 | + | s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerBottom, Rect: ports.Rect{W: 10, H: 10}}} | |||
| 322 | c[1] = ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}} | 419 | c[1] = ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}} | ||
| 420 | + | c[5] = ports.SurfaceContent{ID: 5, Seq: 1, Width: 10, Height: 10, Opaque: true, Solid: &ports.SolidColor{A: 1}} | |||
| 323 | r := portsmocks.NewMockRenderer(t) | 421 | r := portsmocks.NewMockRenderer(t) | ||
| 324 | r.EXPECT().UseTarget(mock.Anything).Return().Twice() | 422 | r.EXPECT().UseTarget(mock.Anything).Return().Twice() | ||
| 325 | var drawn []int | 423 | var drawn []int | ||
M internal/adapters/drm/overlay.go +11 -9
| @@ -237,18 +237,20 @@ func (ov overlayWin) close() { | |||
| 237 | 237 | // cursor; the fence does not count) is not probed again: a refusal of the | |
| 238 | 238 | // commit itself still drops the overlay (overlayConflict). | |
| 239 | 239 | func (o *Output) testOverlay(fb uint32, ov overlayWin) bool { | |
| 240 | + | // One cursor snapshot: the input goroutine may move it meanwhile, and | |
| 241 | + | // what is remembered must be what KMS was asked. | |
| 240 | 242 | key := o.overlayProbeOf(fb, ov) | |
| 241 | 243 | if slices.Contains(o.overlayPassed[:], key) { | |
| 242 | 244 | return true | |
| 243 | 245 | } | |
| 244 | - | err := o.probeOverlay(fb, ov, ov.color.mode) | |
| 246 | + | err := o.probeOverlay(fb, ov, ov.color.mode, key.cursor) | |
| 245 | 247 | if err == nil { | |
| 246 | 248 | o.overlayPassed[o.overlayNext] = key | |
| 247 | 249 | o.overlayNext = (o.overlayNext + 1) % len(o.overlayPassed) | |
| 248 | 250 | return true | |
| 249 | 251 | } | |
| 250 | - | if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil { | |
| 251 | - | o.overlay.setVerdict(ov.color.format, o.cursorShown(), false) | |
| 252 | + | if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass, key.cursor) == nil { | |
| 253 | + | o.overlay.setVerdict(ov.color.format, key.cursor.on, false) | |
| 252 | 254 | o.setOverlayReason("color_refused") | |
| 253 | 255 | o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused") | |
| 254 | 256 | return false | |
| @@ -277,17 +279,17 @@ type overlayProbe struct { | |||
| 277 | 279 | ||
| 278 | 280 | func (o *Output) overlayProbeOf(fb uint32, ov overlayWin) overlayProbe { | |
| 279 | 281 | p := overlayProbe{primary: fb, overlay: ov.fb, rect: ov.rect, w: ov.w, h: ov.h, color: ov.color, mult: o.colorMult, ctm: o.ctmBlob} | |
| 280 | - | if o.cursor != nil { | |
| 282 | + | if o.cursor != nil && !o.protected { | |
| 281 | 283 | p.cursor = o.cursor.desired() | |
| 282 | 284 | } | |
| 283 | 285 | return p | |
| 284 | 286 | } | |
| 285 | 287 | ||
| 286 | 288 | // probeOverlay is a TEST_ONLY of the frame: the composed image on the | |
| 287 | - | // primary plane (Bypass), ov on the overlay with colour mode, the cursor. | |
| 288 | - | // Both planes' colour state is written whatever they applied: the test must | |
| 289 | - | // not rely on the state of the last commit. | |
| 290 | - | func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error { | |
| 289 | + | // primary plane (Bypass), ov on the overlay with colour mode, the cursor in | |
| 290 | + | // state cur. Both planes' colour state is written whatever they applied: | |
| 291 | + | // the test must not rely on the state of the last commit. | |
| 292 | + | func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode, cur cursorState) error { | |
| 291 | 293 | req := &o.probeReq // no frame request is alive: the frame commit follows | |
| 292 | 294 | req.reset() | |
| 293 | 295 | o.primaryProps(req, fb) | |
| @@ -295,7 +297,7 @@ func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error { | |||
| 295 | 297 | o.overlayProps(req, ov) | |
| 296 | 298 | o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob) | |
| 297 | 299 | if o.cursor != nil { | |
| 298 | - | o.cursor.props(req, o.crtc, o.cursor.desired()) | |
| 300 | + | o.cursor.props(req, o.crtc, cur) | |
| 299 | 301 | } | |
| 300 | 302 | return o.k.commit(req, atomicTestOnly, 0) | |
| 301 | 303 | } | |
| @@ -237,18 +237,20 @@ func (ov overlayWin) close() { | |||||
| 237 | // cursor; the fence does not count) is not probed again: a refusal of the | 237 | // cursor; the fence does not count) is not probed again: a refusal of the | ||
| 238 | // commit itself still drops the overlay (overlayConflict). | 238 | // commit itself still drops the overlay (overlayConflict). | ||
| 239 | func (o *Output) testOverlay(fb uint32, ov overlayWin) bool { | 239 | func (o *Output) testOverlay(fb uint32, ov overlayWin) bool { | ||
| 240 | + | // One cursor snapshot: the input goroutine may move it meanwhile, and | |||
| 241 | + | // what is remembered must be what KMS was asked. | |||
| 240 | key := o.overlayProbeOf(fb, ov) | 242 | key := o.overlayProbeOf(fb, ov) | ||
| 241 | if slices.Contains(o.overlayPassed[:], key) { | 243 | if slices.Contains(o.overlayPassed[:], key) { | ||
| 242 | return true | 244 | return true | ||
| 243 | } | 245 | } | ||
| 244 | - | err := o.probeOverlay(fb, ov, ov.color.mode) | 246 | + | err := o.probeOverlay(fb, ov, ov.color.mode, key.cursor) |
| 245 | if err == nil { | 247 | if err == nil { | ||
| 246 | o.overlayPassed[o.overlayNext] = key | 248 | o.overlayPassed[o.overlayNext] = key | ||
| 247 | o.overlayNext = (o.overlayNext + 1) % len(o.overlayPassed) | 249 | o.overlayNext = (o.overlayNext + 1) % len(o.overlayPassed) | ||
| 248 | return true | 250 | return true | ||
| 249 | } | 251 | } | ||
| 250 | - | if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil { | 252 | + | if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass, key.cursor) == nil { |
| 251 | - | o.overlay.setVerdict(ov.color.format, o.cursorShown(), false) | 253 | + | o.overlay.setVerdict(ov.color.format, key.cursor.on, false) |
| 252 | o.setOverlayReason("color_refused") | 254 | o.setOverlayReason("color_refused") | ||
| 253 | o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused") | 255 | o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused") | ||
| 254 | return false | 256 | return false | ||
| @@ -277,17 +279,17 @@ type overlayProbe struct { | |||||
| 277 | 279 | ||||
| 278 | func (o *Output) overlayProbeOf(fb uint32, ov overlayWin) overlayProbe { | 280 | func (o *Output) overlayProbeOf(fb uint32, ov overlayWin) overlayProbe { | ||
| 279 | p := overlayProbe{primary: fb, overlay: ov.fb, rect: ov.rect, w: ov.w, h: ov.h, color: ov.color, mult: o.colorMult, ctm: o.ctmBlob} | 281 | p := overlayProbe{primary: fb, overlay: ov.fb, rect: ov.rect, w: ov.w, h: ov.h, color: ov.color, mult: o.colorMult, ctm: o.ctmBlob} | ||
| 280 | - | if o.cursor != nil { | 282 | + | if o.cursor != nil && !o.protected { |
| 281 | p.cursor = o.cursor.desired() | 283 | p.cursor = o.cursor.desired() | ||
| 282 | } | 284 | } | ||
| 283 | return p | 285 | return p | ||
| 284 | } | 286 | } | ||
| 285 | 287 | ||||
| 286 | // probeOverlay is a TEST_ONLY of the frame: the composed image on the | 288 | // probeOverlay is a TEST_ONLY of the frame: the composed image on the | ||
| 287 | - | // primary plane (Bypass), ov on the overlay with colour mode, the cursor. | 289 | + | // primary plane (Bypass), ov on the overlay with colour mode, the cursor in |
| 288 | - | // Both planes' colour state is written whatever they applied: the test must | 290 | + | // state cur. Both planes' colour state is written whatever they applied: |
| 289 | - | // not rely on the state of the last commit. | 291 | + | // the test must not rely on the state of the last commit. |
| 290 | - | func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error { | 292 | + | func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode, cur cursorState) error { |
| 291 | req := &o.probeReq // no frame request is alive: the frame commit follows | 293 | req := &o.probeReq // no frame request is alive: the frame commit follows | ||
| 292 | req.reset() | 294 | req.reset() | ||
| 293 | o.primaryProps(req, fb) | 295 | o.primaryProps(req, fb) | ||
| @@ -295,7 +297,7 @@ func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error { | |||||
| 295 | o.overlayProps(req, ov) | 297 | o.overlayProps(req, ov) | ||
| 296 | o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob) | 298 | o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob) | ||
| 297 | if o.cursor != nil { | 299 | if o.cursor != nil { | ||
| 298 | - | o.cursor.props(req, o.crtc, o.cursor.desired()) | 300 | + | o.cursor.props(req, o.crtc, cur) |
| 299 | } | 301 | } | ||
| 300 | return o.k.commit(req, atomicTestOnly, 0) | 302 | return o.k.commit(req, atomicTestOnly, 0) | ||
| 301 | } | 303 | } | ||