Merge pull request 'perf: pace frames and hand content on the display loop without relay goroutines' (#45) from perf/small-damage into main
116 files · +3440 -837
M .mockery.yml +7 -0
| @@ -15,8 +15,10 @@ packages: | |||
| 15 | 15 | interfaces: | |
| 16 | 16 | CaptureFrame: | |
| 17 | 17 | Clock: | |
| 18 | + | ContentReturn: | |
| 18 | 19 | Notifier: | |
| 19 | 20 | OutputScaleStore: | |
| 21 | + | PresentationSink: | |
| 20 | 22 | Renderer: | |
| 21 | 23 | Seat: | |
| 22 | 24 | SessionSecurity: | |
| @@ -96,6 +98,11 @@ packages: | |||
| 96 | 98 | dir: internal/app | |
| 97 | 99 | pkgname: app | |
| 98 | 100 | filename: leaseinventory_mock_test.go | |
| 101 | + | contentAttacher: | |
| 102 | + | config: | |
| 103 | + | dir: internal/app | |
| 104 | + | pkgname: app | |
| 105 | + | filename: contentattacher_mock_test.go | |
| 99 | 106 | github.com/bnema/neferwl/internal/adapters/statefile: | |
| 100 | 107 | interfaces: | |
| 101 | 108 | procTree: | |
M Makefile +2 -2
| @@ -61,7 +61,7 @@ arch: adapter-imports | |||
| 61 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 63 | # headless) run; the others are leaf helpers. | |
| 64 | - | ADAPTER_LIBS := busretry|outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb | |
| 64 | + | ADAPTER_LIBS := busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
M docs/performance.md +3 -1
| @@ -40,7 +40,7 @@ GODEBUG=gctrace=1 neferwl --backend=drm | |||
| 40 | 40 | ||
| 41 | 41 | `make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off). | |
| 42 | 42 | ||
| 43 | - | Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion` and `input-keys`; name them to turn them on. | |
| 43 | + | Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on. | |
| 44 | 44 | ||
| 45 | 45 | ## Frame stats | |
| 46 | 46 | ||
| @@ -54,4 +54,6 @@ Every 10 s, each output logs a `stats` entry: | |||
| 54 | 54 | | `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) | | |
| 55 | 55 | | `redrawn_pixels` | Pixels drawn | | |
| 56 | 56 | ||
| 57 | + | If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s. | |
| 58 | + | ||
| 57 | 59 | Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late. | |
M go.mod +1 -1
| @@ -6,7 +6,7 @@ require ( | |||
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | 7 | github.com/bnema/kvconf v0.1.2 | |
| 8 | 8 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 9 | - | github.com/bnema/purego-libwayland v0.9.0 | |
| 9 | + | github.com/bnema/purego-libwayland v0.10.0 | |
| 10 | 10 | github.com/bnema/purego-vulkan v0.6.0 | |
| 11 | 11 | github.com/bnema/wlturbo v0.5.0 | |
| 12 | 12 | github.com/bnema/zerowrap v1.4.1 | |
M go.sum +2 -2
| @@ -4,8 +4,8 @@ github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |||
| 4 | 4 | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |
| 5 | 5 | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |
| 6 | 6 | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 7 | - | github.com/bnema/purego-libwayland v0.9.0 h1:Ud3Bb3W1T2qk/ZlbXfLIud0Uy1/GFsOCMV2Fai/rT5o= | |
| 8 | - | github.com/bnema/purego-libwayland v0.9.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA= | |
| 7 | + | github.com/bnema/purego-libwayland v0.10.0 h1:N/NTfj1BGdYiabCJ8W0tbBwyYt+2FNtrweoAODe521c= | |
| 8 | + | github.com/bnema/purego-libwayland v0.10.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA= | |
| 9 | 9 | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |
| 10 | 10 | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 11 | 11 | github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4= | |
M internal/adapters/drm/amdgpu_rules_test.go +1 -1
| @@ -431,7 +431,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T) | |||
| 431 | 431 | defer cancel() | |
| 432 | 432 | done := make(chan error, 1) | |
| 433 | 433 | go func() { | |
| 434 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 434 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 435 | 435 | }() | |
| 436 | 436 | for { | |
| 437 | 437 | select { | |
M internal/adapters/drm/capture_test.go +7 -7
| @@ -363,14 +363,14 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | |||
| 363 | 363 | o.NewCaptureRenderer = func(int, int) (ports.Renderer, error) { return child, nil } | |
| 364 | 364 | ||
| 365 | 365 | scenes := make(chan ports.Scene) | |
| 366 | - | contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene | |
| 366 | + | contents := newContentFeed(t, 0) // unbuffered: taken before the scene | |
| 367 | 367 | captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it | |
| 368 | 368 | captured := make(chan ports.CaptureDone, 4) | |
| 369 | - | presented := make(chan ports.OutputPresented, 64) | |
| 369 | + | sink, presented := presentedSink(t, 64) | |
| 370 | 370 | ctx, cancel := context.WithCancel(context.Background()) | |
| 371 | 371 | done := make(chan error, 1) | |
| 372 | 372 | go func() { | |
| 373 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return display, nil }, nil, make(chan bool), scenes, contents, nil, presented, captures, captured) | |
| 373 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return display, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, sink, captures, captured) | |
| 374 | 374 | }() | |
| 375 | 375 | count := func() int { | |
| 376 | 376 | commitMu.Lock() | |
| @@ -379,7 +379,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | |||
| 379 | 379 | } | |
| 380 | 380 | waitFor(t, func() bool { return count() == 2 }) | |
| 381 | 381 | ||
| 382 | - | contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}} | |
| 382 | + | contents.send(ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}) | |
| 383 | 383 | // The request waits for the scene; once a display frame is pending no | |
| 384 | 384 | // request is served, so it must be queued before the scene arrives. | |
| 385 | 385 | req := sessionRequest(t, 1, false) | |
| @@ -411,7 +411,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | |||
| 411 | 411 | ||
| 412 | 412 | // A newer buffer of a display window arrives while the display commit is | |
| 413 | 413 | // pending: the GPU may still read the older one, so it is not reported. | |
| 414 | - | contents <- ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}} | |
| 414 | + | contents.send(ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}) | |
| 415 | 415 | _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished | |
| 416 | 416 | require.NoError(t, err) | |
| 417 | 417 | lifted := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) == 0 }) | |
| @@ -487,7 +487,7 @@ func runCaptureWaitsForItsIndicatorScene(t *testing.T) { | |||
| 487 | 487 | ctx, cancel := context.WithCancel(context.Background()) | |
| 488 | 488 | done := make(chan error, 1) | |
| 489 | 489 | go func() { | |
| 490 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 16), captures, captured) | |
| 490 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), captures, captured) | |
| 491 | 491 | }() | |
| 492 | 492 | count := func() int { | |
| 493 | 493 | commitMu.Lock() | |
| @@ -563,7 +563,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) { | |||
| 563 | 563 | ctx, cancel := context.WithCancel(context.Background()) | |
| 564 | 564 | done := make(chan error, 1) | |
| 565 | 565 | go func() { | |
| 566 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 16), captures, captured) | |
| 566 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), captures, captured) | |
| 567 | 567 | }() | |
| 568 | 568 | count := func() int { | |
| 569 | 569 | commitMu.Lock() | |
M internal/adapters/drm/commit_alloc_test.go +6 -0
| @@ -53,6 +53,12 @@ func TestCommitFrameAllocations(t *testing.T) { | |||
| 53 | 53 | } | |
| 54 | 54 | }) | |
| 55 | 55 | t.Run("success path", func(t *testing.T) { | |
| 56 | + | if raceEnabled { | |
| 57 | + | // The race runtime moves testify's count by a few | |
| 58 | + | // allocations per call, as much as the margin below. | |
| 59 | + | // perf-check runs it without; the subtest above stays exact. | |
| 60 | + | t.Skip("testify allocates unevenly under the race detector") | |
| 61 | + | } | |
| 56 | 62 | o, c := scanoutAllocsOutput(t) | |
| 57 | 63 | k := newMockkms(t) | |
| 58 | 64 | k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
M internal/adapters/drm/commit_test.go +7 -7
| @@ -400,7 +400,7 @@ func enableAfterStartAwayModesetsOnce(t *testing.T) { | |||
| 400 | 400 | ctx, cancel := context.WithCancel(context.Background()) | |
| 401 | 401 | done := make(chan error, 1) | |
| 402 | 402 | go func() { | |
| 403 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil) | |
| 403 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 404 | 404 | }() | |
| 405 | 405 | count := func(flags uint32) int { | |
| 406 | 406 | commitMu.Lock() | |
| @@ -516,12 +516,12 @@ func runReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 516 | 516 | return commitRec{}, false | |
| 517 | 517 | } | |
| 518 | 518 | scenes := make(chan ports.Scene, 1) | |
| 519 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 520 | - | presented := make(chan ports.OutputPresented, 8) | |
| 519 | + | contents := newContentFeed(t, 1) | |
| 520 | + | sink, presented := presentedSink(t, 8) | |
| 521 | 521 | ctx, cancel := context.WithCancel(context.Background()) | |
| 522 | 522 | done := make(chan error, 1) | |
| 523 | 523 | go func() { | |
| 524 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented, nil, nil) | |
| 524 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents.in(), contents.ret, nil, sink, nil, nil) | |
| 525 | 525 | }() | |
| 526 | 526 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |
| 527 | 527 | var frame commitRec | |
| @@ -532,7 +532,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 532 | 532 | var cur commitRec | |
| 533 | 533 | waitFor(t, func() bool { var ok bool; cur, ok = last(userState); return ok }) | |
| 534 | 534 | // A window this output does not show commits while the cursor flies. | |
| 535 | - | contents <- ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}} | |
| 535 | + | contents.send(ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}}) | |
| 536 | 536 | synctest.Wait() | |
| 537 | 537 | flips <- eventOf(cur) | |
| 538 | 538 | select { | |
| @@ -601,11 +601,11 @@ func TestRunClosesFrameFences(t *testing.T) { | |||
| 601 | 601 | before := fds() | |
| 602 | 602 | active := make(chan bool, 1) | |
| 603 | 603 | scenes := make(chan ports.Scene, 1) | |
| 604 | - | presented := make(chan ports.OutputPresented, 4*rounds) | |
| 604 | + | sink, _ := presentedSink(t, 4*rounds) | |
| 605 | 605 | ctx, cancel := context.WithCancel(context.Background()) | |
| 606 | 606 | done := make(chan error, 1) | |
| 607 | 607 | go func() { | |
| 608 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, presented, nil, nil) | |
| 608 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, nil, sink, nil, nil) | |
| 609 | 609 | }() | |
| 610 | 610 | count := func() int { | |
| 611 | 611 | commitMu.Lock() | |
A internal/adapters/drm/content_feed_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // contentFeed is the wayland side of Run's content input in tests: batches go | |
| 12 | + | // out on ch, and the generated ContentReturn mock checks that each comes back | |
| 13 | + | // reset. | |
| 14 | + | type contentFeed struct { | |
| 15 | + | ch chan *ports.ContentBatch | |
| 16 | + | ret *portsmocks.MockContentReturn | |
| 17 | + | returned chan struct{} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // newContentFeed makes a feed whose channel holds buffer batches. | |
| 21 | + | func newContentFeed(t *testing.T, buffer int) *contentFeed { | |
| 22 | + | t.Helper() | |
| 23 | + | f := &contentFeed{ch: make(chan *ports.ContentBatch, buffer), ret: portsmocks.NewMockContentReturn(t), returned: make(chan struct{}, 64)} | |
| 24 | + | f.ret.EXPECT().Return(mock.Anything).Run(func(b *ports.ContentBatch) { | |
| 25 | + | if len(b.Items) != 0 { | |
| 26 | + | t.Errorf("batch returned with %d items, want it reset", len(b.Items)) | |
| 27 | + | } | |
| 28 | + | f.returned <- struct{}{} | |
| 29 | + | }).Return().Maybe() | |
| 30 | + | return f | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // send hands the output one batch holding cs. | |
| 34 | + | func (f *contentFeed) send(cs ...ports.SurfaceContent) { | |
| 35 | + | f.ch <- &ports.ContentBatch{Items: cs} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // in is the channel Run reads. | |
| 39 | + | func (f *contentFeed) in() <-chan *ports.ContentBatch { return f.ch } | |
M internal/adapters/drm/frame_lifecycle.go +3 -1
| @@ -31,7 +31,9 @@ type pendingFrame struct { | |||
| 31 | 31 | queued uint64 | |
| 32 | 32 | zeroCopy ports.WindowID // window shown without composition | |
| 33 | 33 | async bool | |
| 34 | - | shows map[ports.WindowID]uint64 | |
| 34 | + | // animating: the scene shows a running animation; its flip wakes core. | |
| 35 | + | animating bool | |
| 36 | + | shows map[ports.WindowID]uint64 | |
| 35 | 37 | // fences are duplicates of the fences the commit waits on, owned until | |
| 36 | 38 | // the commit ends. composed is set when the frame was rendered. | |
| 37 | 39 | fences []*os.File | |
M internal/adapters/drm/frame_submit.go +2 -2
| @@ -34,7 +34,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 34 | 34 | } | |
| 35 | 35 | // shows is what the frame puts on screen: presentation feedback of | |
| 36 | 36 | // windows it does not draw is discarded, not presented. | |
| 37 | - | f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)} | |
| 37 | + | f := pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.shownBy(scene, seen)} | |
| 38 | 38 | o.wantContent(scene, surfaces) | |
| 39 | 39 | // Requests that cannot be served (exclusion of another session, a | |
| 40 | 40 | // workspace that moved) are failed here, never answered from the | |
| @@ -82,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 82 | 82 | } | |
| 83 | 83 | if decision.fb != 0 { | |
| 84 | 84 | c := decision.content | |
| 85 | - | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { | |
| 85 | + | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { | |
| 86 | 86 | presented = err == nil | |
| 87 | 87 | return true, err | |
| 88 | 88 | } | |
A internal/adapters/drm/frames_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A flip of an animating scene tells core the next frame is due; the flip of | |
| 11 | + | // any other frame does not. A frames channel core has not read stays full: the | |
| 12 | + | // owner never waits. An output without a frames channel (tests, nobody to wake) | |
| 13 | + | // just flips. | |
| 14 | + | func TestCompletedFrames(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | buffer int // frames channel capacity; 0: no channel | |
| 18 | + | animating []bool // the flips, one per frame | |
| 19 | + | want []ports.OutputFrame | |
| 20 | + | }{ | |
| 21 | + | {name: "a settled scene wakes nobody", buffer: 2, animating: []bool{false}}, | |
| 22 | + | {name: "an animating scene wakes core once", buffer: 2, animating: []bool{false, true}, want: []ports.OutputFrame{{Output: "DP-1"}}}, | |
| 23 | + | {name: "a full channel never blocks", buffer: 1, animating: []bool{true, true, true}, want: []ports.OutputFrame{{Output: "DP-1"}}}, | |
| 24 | + | {name: "no frames channel", animating: []bool{true}}, | |
| 25 | + | } | |
| 26 | + | for _, tt := range tests { | |
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |
| 28 | + | o, _, commits := testOutput(t) | |
| 29 | + | o.cursor = nil | |
| 30 | + | var frames chan ports.OutputFrame | |
| 31 | + | if tt.buffer > 0 { | |
| 32 | + | frames = make(chan ports.OutputFrame, tt.buffer) | |
| 33 | + | o.Frames = frames | |
| 34 | + | } | |
| 35 | + | seen := map[ports.WindowID]uint64{} | |
| 36 | + | for i, animating := range tt.animating { | |
| 37 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: animating})) | |
| 38 | + | require.True(t, o.completed(eventOf((*commits)[i]), seen)) | |
| 39 | + | } | |
| 40 | + | if frames != nil { | |
| 41 | + | require.Equal(t, tt.want, drain(frames)) | |
| 42 | + | } | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func drain(ch chan ports.OutputFrame) []ports.OutputFrame { | |
| 48 | + | var out []ports.OutputFrame | |
| 49 | + | for len(ch) > 0 { | |
| 50 | + | out = append(out, <-ch) | |
| 51 | + | } | |
| 52 | + | return out | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // The frame send of an animating flip allocates nothing, full channel or not. | |
| 56 | + | func TestAnimatingFrameSendAllocations(t *testing.T) { | |
| 57 | + | o, _, _ := testOutput(t) | |
| 58 | + | frames := make(chan ports.OutputFrame, 1) | |
| 59 | + | o.Frames = frames | |
| 60 | + | if n := testing.AllocsPerRun(100, func() { | |
| 61 | + | o.wakeCore() // fills the channel, or finds it full | |
| 62 | + | select { | |
| 63 | + | case <-frames: | |
| 64 | + | default: | |
| 65 | + | } | |
| 66 | + | o.wakeCore() | |
| 67 | + | }); n != 0 { | |
| 68 | + | t.Fatalf("wakeCore: %v allocations", n) | |
| 69 | + | } | |
| 70 | + | } | |
A internal/adapters/drm/norace_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !race | |
| 2 | + | ||
| 3 | + | package drm | |
| 4 | + | ||
| 5 | + | const raceEnabled = false | |
M internal/adapters/drm/output.go +26 -19
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 14 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/corewake" | |
| 14 | 15 | "github.com/bnema/neferwl/internal/adapters/outputkit/presented" | |
| 15 | 16 | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 16 | 17 | ||
| @@ -27,10 +28,13 @@ import ( | |||
| 27 | 28 | // bounded (stuckTimeout, then a modeset). | |
| 28 | 29 | type Output struct { | |
| 29 | 30 | // Set before Run. The backend owns instance registration and wake routing. | |
| 30 | - | Security ports.SessionSecurity | |
| 31 | - | SecurityChanges <-chan ports.SecurityState | |
| 32 | - | Instance ports.OutputInstance | |
| 33 | - | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 31 | + | Security ports.SessionSecurity | |
| 32 | + | SecurityChanges <-chan ports.SecurityState | |
| 33 | + | Instance ports.OutputInstance | |
| 34 | + | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 35 | + | // Frames, set before Run, tells core a flip of an animating scene | |
| 36 | + | // completed (nil: nobody is told). Sends never block. | |
| 37 | + | Frames chan<- ports.OutputFrame | |
| 34 | 38 | securityState ports.SecurityState | |
| 35 | 39 | securityPrepared bool | |
| 36 | 40 | securityInvalid bool | |
| @@ -113,9 +117,8 @@ type Output struct { | |||
| 113 | 117 | reports presented.Queue | |
| 114 | 118 | // Last immutable shows snapshot sent in a flip. Never mutate it: wayland | |
| 115 | 119 | // may still be reading a prior report on another goroutine. | |
| 116 | - | showsSnapshot map[ports.WindowID]uint64 | |
| 120 | + | shows surfaces.ShowsSnapshot | |
| 117 | 121 | directShowsSnapshot map[ports.WindowID]uint64 | |
| 118 | - | showsScratch map[ports.WindowID]uint64 | |
| 119 | 122 | // Tearing (ADR 006): async commits of scanned-out buffers that ask for | |
| 120 | 123 | // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence | |
| 121 | 124 | // is set when an async commit may carry IN_FENCE_FD (probed once). | |
| @@ -1002,8 +1005,8 @@ func (o *Output) Close() { | |||
| 1002 | 1005 | func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose } | |
| 1003 | 1006 | ||
| 1004 | 1007 | // Run renders scenes and commits them until ctx ends. active reports seat | |
| 1005 | - | // enable/disable. What the output shows and read is reported on reportsCh. | |
| 1006 | - | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, reportsCh chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { | |
| 1008 | + | // enable/disable. What the output shows and read is pushed to reports. | |
| 1009 | + | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, reports ports.PresentationSink, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { | |
| 1007 | 1010 | defer func() { | |
| 1008 | 1011 | p := recover() | |
| 1009 | 1012 | if p != nil { | |
| @@ -1229,11 +1232,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1229 | 1232 | readWait = readWait || o.capped | |
| 1230 | 1233 | } | |
| 1231 | 1234 | } | |
| 1232 | - | o.reports.Flush(reportsCh) | |
| 1233 | - | // An unsent report is retried soon, not only on the next event; | |
| 1234 | - | // so is one that waits for an uncommitted frame's fence. | |
| 1235 | + | o.reports.Flush(reports) | |
| 1236 | + | // A report that waits for an uncommitted frame's fence is retried | |
| 1237 | + | // soon, not only on the next event. | |
| 1235 | 1238 | var retry <-chan time.Time | |
| 1236 | - | if o.reports.Len() > 0 || readWait { | |
| 1239 | + | if readWait { | |
| 1237 | 1240 | retryTimer.Reset(time.Millisecond) | |
| 1238 | 1241 | retry = retryTimer.C | |
| 1239 | 1242 | } else { | |
| @@ -1429,14 +1432,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1429 | 1432 | o.setCursor(r, loadCursor, want, cursorScale, cursorTransform) | |
| 1430 | 1433 | stateDirty = true | |
| 1431 | 1434 | } | |
| 1432 | - | case c := <-contents: | |
| 1433 | - | table.Update(c, scene) | |
| 1434 | - | if c.Seq > seen[c.ID] { | |
| 1435 | - | seen[c.ID] = c.Seq | |
| 1436 | - | reportDirty = true | |
| 1437 | - | } | |
| 1435 | + | case b := <-contents: | |
| 1438 | 1436 | // Windows on other outputs or workspaces do not need a frame. | |
| 1439 | - | dirty = dirty || capture.Shows(scene, c.ID) | |
| 1437 | + | batchDirty, batchSeen := table.Apply(b, scene, seen, returns) | |
| 1438 | + | dirty = dirty || batchDirty | |
| 1439 | + | reportDirty = reportDirty || batchSeen | |
| 1440 | 1440 | case <-stats.C(): | |
| 1441 | 1441 | now := clk.Now() | |
| 1442 | 1442 | pipeline.Retire(scene) // an idle child of an ended session goes | |
| @@ -1647,9 +1647,16 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1647 | 1647 | o.queued = 0 | |
| 1648 | 1648 | } | |
| 1649 | 1649 | o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: o.refreshPeriod(), ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen) | |
| 1650 | + | if f.animating { | |
| 1651 | + | o.wakeCore() | |
| 1652 | + | } | |
| 1650 | 1653 | return true | |
| 1651 | 1654 | } | |
| 1652 | 1655 | ||
| 1656 | + | // wakeCore tells core the next frame of a running animation is due. A frame | |
| 1657 | + | // core has not read yet is enough, so the owner never waits. | |
| 1658 | + | func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) } | |
| 1659 | + | ||
| 1653 | 1660 | // setCursor loads a cursor for scale and output transform t into the cursor | |
| 1654 | 1661 | // images. | |
| 1655 | 1662 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |
M internal/adapters/drm/output_test.go +33 -26
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | 14 | "testing/synctest" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | - | "github.com/bnema/neferwl/internal/adapters/outputkit/presented" | |
| 18 | 17 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 19 | 18 | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 19 | "github.com/bnema/zerowrap" | |
| @@ -356,11 +355,12 @@ func TestReportsKeepEveryFlip(t *testing.T) { | |||
| 356 | 355 | if o.reports.Len() != 2 || o.reports.Unsent()[0].Flip.Seq != 1 || o.reports.Unsent()[1].Flip.Seq != 2 || o.reports.Unsent()[1].Seen[1] != 3 { | |
| 357 | 356 | t.Fatalf("unsent %+v", o.reports.Unsent()) | |
| 358 | 357 | } | |
| 359 | - | for i := range presented.MaxUnsent + 3 { | |
| 358 | + | for i := range 20 { | |
| 360 | 359 | o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen) | |
| 361 | 360 | } | |
| 362 | - | if o.reports.Len() != presented.MaxUnsent || o.reports.Unsent()[0].Flip.Merged == 0 { | |
| 363 | - | t.Fatalf("cap: %d reports, first merged %d", o.reports.Len(), o.reports.Unsent()[0].Flip.Merged) | |
| 361 | + | // The wayland sink alone merges flips: the output queue keeps them all. | |
| 362 | + | if o.reports.Len() != 22 || o.reports.Unsent()[2].Flip.Merged != 0 { | |
| 363 | + | t.Fatalf("%d reports, merged %d", o.reports.Len(), o.reports.Unsent()[2].Flip.Merged) | |
| 364 | 364 | } | |
| 365 | 365 | } | |
| 366 | 366 | ||
| @@ -597,19 +597,26 @@ func runReportsSeenAfterFlip(t *testing.T) { | |||
| 597 | 597 | return n | |
| 598 | 598 | } | |
| 599 | 599 | scenes := make(chan ports.Scene, 1) | |
| 600 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 601 | - | presented := make(chan ports.OutputPresented, 8) | |
| 600 | + | contents := newContentFeed(t, 1) | |
| 601 | + | sink, presented := presentedSink(t, 8) | |
| 602 | 602 | ctx, cancel := context.WithCancel(context.Background()) | |
| 603 | 603 | done := make(chan error, 1) | |
| 604 | 604 | go func() { | |
| 605 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented, nil, nil) | |
| 605 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, sink, nil, nil) | |
| 606 | 606 | }() | |
| 607 | 607 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}} | |
| 608 | 608 | waitFor(t, func() bool { return frameCommits() == 1 }) | |
| 609 | 609 | // Frame 1 is in flight: content 3 replaces what it may be reading. | |
| 610 | - | contents <- ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{}} | |
| 610 | + | // One batch carries two windows: both are applied and the batch goes | |
| 611 | + | // back to wayland (reset) at once, whatever the frame in flight reads. | |
| 612 | + | contents.send(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{}}, ports.SurfaceContent{ID: 2, Seq: 4, SHM: &ports.SHMBuffer{}}) | |
| 611 | 613 | synctest.Wait() | |
| 612 | 614 | select { | |
| 615 | + | case <-contents.returned: | |
| 616 | + | default: | |
| 617 | + | t.Fatal("batch not returned") | |
| 618 | + | } | |
| 619 | + | select { | |
| 613 | 620 | case p := <-presented: | |
| 614 | 621 | t.Fatalf("report while a frame is in flight: %+v", p) | |
| 615 | 622 | default: | |
| @@ -620,7 +627,7 @@ func runReportsSeenAfterFlip(t *testing.T) { | |||
| 620 | 627 | flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1} | |
| 621 | 628 | select { | |
| 622 | 629 | case p := <-presented: | |
| 623 | - | if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 { | |
| 630 | + | if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 || p.Seen[2] != 4 { | |
| 624 | 631 | t.Fatalf("report %+v", p) | |
| 625 | 632 | } | |
| 626 | 633 | case <-time.After(2 * time.Second): | |
| @@ -679,7 +686,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) { | |||
| 679 | 686 | ctx, cancel := context.WithCancel(context.Background()) | |
| 680 | 687 | done := make(chan error, 1) | |
| 681 | 688 | go func() { | |
| 682 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 689 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 683 | 690 | }() | |
| 684 | 691 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, | |
| 685 | 692 | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}, | |
| @@ -760,7 +767,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) { | |||
| 760 | 767 | active <- false | |
| 761 | 768 | done := make(chan error, 1) | |
| 762 | 769 | go func() { | |
| 763 | - | done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil) | |
| 770 | + | done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 764 | 771 | }() | |
| 765 | 772 | ticks <- now | |
| 766 | 773 | <-trimmed | |
| @@ -814,7 +821,7 @@ func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) { | |||
| 814 | 821 | } | |
| 815 | 822 | done := make(chan error, 1) | |
| 816 | 823 | go func() { | |
| 817 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, requests, replies) | |
| 824 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, nil, requests, replies) | |
| 818 | 825 | }() | |
| 819 | 826 | // The unbuffered handoff proves Run owns the request before any scene. | |
| 820 | 827 | select { | |
| @@ -880,7 +887,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 880 | 887 | ctx, cancel := context.WithCancel(context.Background()) | |
| 881 | 888 | done := make(chan error, 1) | |
| 882 | 889 | go func() { | |
| 883 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), requests, replies) | |
| 890 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, discardSink(t), requests, replies) | |
| 884 | 891 | }() | |
| 885 | 892 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"} | |
| 886 | 893 | select { | |
| @@ -940,7 +947,7 @@ func TestRunPanicReportsReadinessError(t *testing.T) { | |||
| 940 | 947 | t.Fatal("missing readiness") | |
| 941 | 948 | } | |
| 942 | 949 | }() | |
| 943 | - | o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil) | |
| 950 | + | o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil, nil) | |
| 944 | 951 | } | |
| 945 | 952 | ||
| 946 | 953 | // stampRun starts Run on an output with unbuffered scene and content | |
| @@ -950,7 +957,7 @@ func TestRunPanicReportsReadinessError(t *testing.T) { | |||
| 950 | 957 | type stampRun struct { | |
| 951 | 958 | o *Output | |
| 952 | 959 | scenes chan ports.Scene | |
| 953 | - | contents chan ports.SurfaceContent | |
| 960 | + | contents *contentFeed | |
| 954 | 961 | flips chan flipEvent | |
| 955 | 962 | wake chan ports.SecurityState | |
| 956 | 963 | state *atomic.Uint64 | |
| @@ -962,7 +969,7 @@ type stampRun struct { | |||
| 962 | 969 | func startStampRun(t *testing.T) *stampRun { | |
| 963 | 970 | o, k, commits, commitMu := testOutputMu(t) | |
| 964 | 971 | o.cursor, o.tearing = nil, false | |
| 965 | - | sr := &stampRun{o: o, scenes: make(chan ports.Scene), contents: make(chan ports.SurfaceContent), flips: make(chan flipEvent, 1), wake: make(chan ports.SecurityState, 1), state: &atomic.Uint64{}} | |
| 972 | + | sr := &stampRun{o: o, scenes: make(chan ports.Scene), contents: newContentFeed(t, 0), flips: make(chan flipEvent, 1), wake: make(chan ports.SecurityState, 1), state: &atomic.Uint64{}} | |
| 966 | 973 | o.flipped = sr.flips | |
| 967 | 974 | securityGate(t, o, sr.state) | |
| 968 | 975 | o.SecurityChanges = sr.wake | |
| @@ -1000,7 +1007,7 @@ func startStampRun(t *testing.T) *stampRun { | |||
| 1000 | 1007 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1001 | 1008 | done := make(chan error, 1) | |
| 1002 | 1009 | go func() { | |
| 1003 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 1010 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents.in(), sr.contents.ret, nil, discardSink(t), nil, nil) | |
| 1004 | 1011 | }() | |
| 1005 | 1012 | // stop ends Run: its state is ours to read afterwards. | |
| 1006 | 1013 | sr.stop = func() { | |
| @@ -1024,7 +1031,7 @@ func TestRunStampsWhenFrameWasWanted(t *testing.T) { | |||
| 1024 | 1031 | scene.Seq = 2 | |
| 1025 | 1032 | before := monotonic() | |
| 1026 | 1033 | sr.scenes <- scene | |
| 1027 | - | sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // Run went round its loop | |
| 1034 | + | sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}}) // Run went round its loop | |
| 1028 | 1035 | flipSent := monotonic() | |
| 1029 | 1036 | sr.flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1} | |
| 1030 | 1037 | waitFor(t, func() bool { return sr.frameCommit() == 2 }) | |
| @@ -1047,11 +1054,11 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) { | |||
| 1047 | 1054 | waitFor(t, func() bool { return sr.frameCommit() == 1 }) | |
| 1048 | 1055 | scene.Seq = 2 | |
| 1049 | 1056 | sr.scenes <- scene | |
| 1050 | - | sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now | |
| 1057 | + | sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}}) // stamped by now | |
| 1051 | 1058 | // A new security generation, not protected. | |
| 1052 | 1059 | sr.state.Store(2) | |
| 1053 | 1060 | sr.wake <- ports.SecurityState{} | |
| 1054 | - | sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset | |
| 1061 | + | sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}}) // Run went round and reset | |
| 1055 | 1062 | sr.stop() | |
| 1056 | 1063 | if sr.o.wantedAt != 0 { | |
| 1057 | 1064 | t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt) | |
| @@ -1069,8 +1076,8 @@ func TestOutputSameSeqRendersOnce(t *testing.T) { | |||
| 1069 | 1076 | sr.scenes <- scene | |
| 1070 | 1077 | // Unshown content only sends Run round its loop: the flip and the held | |
| 1071 | 1078 | // scene are handled by the time the second one is taken. | |
| 1072 | - | sr.contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}} | |
| 1073 | - | sr.contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}} | |
| 1079 | + | sr.contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}) | |
| 1080 | + | sr.contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}) | |
| 1074 | 1081 | if n := sr.frameCommit(); n != 1 { | |
| 1075 | 1082 | t.Fatalf("%d frames after a scene with the held Seq", n) | |
| 1076 | 1083 | } | |
| @@ -1128,11 +1135,11 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) { | |||
| 1128 | 1135 | return c, ok | |
| 1129 | 1136 | } | |
| 1130 | 1137 | scenes := make(chan ports.Scene, 1) | |
| 1131 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 1138 | + | contents := newContentFeed(t, 1) | |
| 1132 | 1139 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1133 | 1140 | done := make(chan error, 1) | |
| 1134 | 1141 | go func() { | |
| 1135 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, make(chan ports.OutputPresented, 64), nil, nil) | |
| 1142 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, discardSink(t), nil, nil) | |
| 1136 | 1143 | }() | |
| 1137 | 1144 | acq, acqW, _ := os.Pipe() | |
| 1138 | 1145 | defer acq.Close() | |
| @@ -1140,11 +1147,11 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) { | |||
| 1140 | 1147 | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 10, H: 10}} | |
| 1141 | 1148 | scenes <- ports.Scene{Seq: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}} | |
| 1142 | 1149 | full := ports.SurfaceContent{ID: 1, Seq: 1, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 5, Planes: []ports.DMABufPlane{{}}}, Acquire: acq} | |
| 1143 | - | contents <- full | |
| 1150 | + | contents.send(full) | |
| 1144 | 1151 | drawn(1, func(c ports.SurfaceContent, ok bool) bool { return ok && c.Acquire == acq }) | |
| 1145 | 1152 | // The window unmaps: its empty content arrives before the scene that | |
| 1146 | 1153 | // draws it leaving (fading). The last content stays, fence dropped. | |
| 1147 | - | contents <- ports.SurfaceContent{ID: 1, Seq: 2} | |
| 1154 | + | contents.send(ports.SurfaceContent{ID: 1, Seq: 2}) | |
| 1148 | 1155 | kept, _ := drawn(1, func(c ports.SurfaceContent, ok bool) bool { return !ok || c.Acquire == nil }) | |
| 1149 | 1156 | if kept.DMABuf != full.DMABuf || kept.Seq != 1 { | |
| 1150 | 1157 | t.Fatalf("frame after the empty content drew %+v, want the last content", kept) | |
M internal/adapters/drm/power_test.go +2 -2
| @@ -50,7 +50,7 @@ func runOutputPower(t *testing.T) { | |||
| 50 | 50 | ctx, cancel := context.WithCancel(context.Background()) | |
| 51 | 51 | done := make(chan error, 1) | |
| 52 | 52 | go func() { | |
| 53 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 53 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 54 | 54 | }() | |
| 55 | 55 | on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |
| 56 | 56 | scenes <- on | |
| @@ -174,7 +174,7 @@ func runOutputStartOff(t *testing.T) { | |||
| 174 | 174 | ctx, cancel := context.WithCancel(context.Background()) | |
| 175 | 175 | done := make(chan error, 1) | |
| 176 | 176 | go func() { | |
| 177 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 177 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 178 | 178 | }() | |
| 179 | 179 | if err := <-ready; err != nil { | |
| 180 | 180 | t.Fatal(err) | |
A internal/adapters/drm/presented_sink_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // presentedSink is a generated PresentationSink mock that records every push | |
| 12 | + | // on the returned channel. A full channel drops the report: Push never blocks, | |
| 13 | + | // as the port requires. | |
| 14 | + | func presentedSink(t *testing.T, size int) (ports.PresentationSink, chan ports.OutputPresented) { | |
| 15 | + | t.Helper() | |
| 16 | + | ch := make(chan ports.OutputPresented, size) | |
| 17 | + | sink := portsmocks.NewMockPresentationSink(t) | |
| 18 | + | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { | |
| 19 | + | select { | |
| 20 | + | case ch <- r: | |
| 21 | + | default: | |
| 22 | + | } | |
| 23 | + | }).Maybe() | |
| 24 | + | return sink, ch | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // discardSink is a PresentationSink for tests that ignore the reports. | |
| 28 | + | func discardSink(t *testing.T) ports.PresentationSink { | |
| 29 | + | t.Helper() | |
| 30 | + | sink, _ := presentedSink(t, 1) | |
| 31 | + | return sink | |
| 32 | + | } | |
M internal/adapters/drm/protection_retry_test.go +1 -1
| @@ -43,7 +43,7 @@ func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*o | |||
| 43 | 43 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 44 | 44 | done := make(chan error, 1) | |
| 45 | 45 | go func() { | |
| 46 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 46 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 47 | 47 | }() | |
| 48 | 48 | return events, done, cancel | |
| 49 | 49 | } | |
A internal/adapters/drm/race_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | //go:build race | |
| 2 | + | ||
| 3 | + | package drm | |
| 4 | + | ||
| 5 | + | // raceEnabled: the race runtime changes allocation counts, which budgets | |
| 6 | + | // measured against a mocked call cannot tell apart from a regression. | |
| 7 | + | const raceEnabled = true | |
M internal/adapters/drm/reacquire_test.go +1 -1
| @@ -96,7 +96,7 @@ func newRetryHarness(t *testing.T) retryHarness { | |||
| 96 | 96 | ctx, cancel := context.WithCancel(context.Background()) | |
| 97 | 97 | done := make(chan error, 1) | |
| 98 | 98 | go func() { | |
| 99 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil) | |
| 99 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 100 | 100 | }() | |
| 101 | 101 | synctest.Wait() | |
| 102 | 102 | return retryHarness{ | |
M internal/adapters/drm/report.go +6 -20
| @@ -1,41 +1,27 @@ | |||
| 1 | 1 | package drm | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "maps" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 7 | 4 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 5 | ) | |
| 9 | 6 | ||
| 10 | 7 | // Reports to wayland: what the output shows and has read. | |
| 11 | 8 | ||
| 12 | 9 | // report queues what the output shows and has read, so replaced client | |
| 13 | - | // buffers can be released (see presented.Queue for merging). | |
| 10 | + | // buffers can be released (see presented.Queue for coalescing). | |
| 14 | 11 | func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |
| 15 | 12 | o.capped = false | |
| 16 | 13 | var reads map[ports.WindowID]uint64 | |
| 17 | 14 | if o.capHidden != nil { | |
| 18 | 15 | seen, reads, o.capped = o.capHidden(seen) | |
| 19 | 16 | } | |
| 20 | - | if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) { | |
| 21 | - | o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged") | |
| 22 | - | } | |
| 17 | + | o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) | |
| 23 | 18 | } | |
| 24 | 19 | ||
| 25 | - | // shownBy returns an immutable snapshot of the content Seq of drawn windows. | |
| 26 | - | // The scratch map is private to the output; a snapshot may still be read by | |
| 27 | - | // wayland after a subsequent flip, so never refill a previously sent map. | |
| 20 | + | // shownBy returns an immutable snapshot of the content Seq of drawn windows | |
| 21 | + | // (see surfaces.ShowsSnapshot). A closed window drawn from its kept content is | |
| 22 | + | // not shown to its client: the empty content has no frame to present. | |
| 28 | 23 | func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 { | |
| 29 | - | if o.showsScratch == nil { | |
| 30 | - | o.showsScratch = make(map[ports.WindowID]uint64) | |
| 31 | - | } | |
| 32 | - | // A closed window drawn from its kept content is not shown to its | |
| 33 | - | // client: the empty content has no frame to present. | |
| 34 | - | surfaces.FillShown(o.showsScratch, seen, s, o.kept) | |
| 35 | - | if !maps.Equal(o.showsSnapshot, o.showsScratch) { | |
| 36 | - | o.showsSnapshot = maps.Clone(o.showsScratch) | |
| 37 | - | } | |
| 38 | - | return o.showsSnapshot | |
| 24 | + | return o.shows.FromSeen(s, seen, o.kept) | |
| 39 | 25 | } | |
| 40 | 26 | ||
| 41 | 27 | func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 { | |
M internal/adapters/drm/seat_disable_test.go +1 -1
| @@ -85,7 +85,7 @@ func TestRunPreparesSeatDisable(t *testing.T) { | |||
| 85 | 85 | ctx, cancel := context.WithCancel(context.Background()) | |
| 86 | 86 | done := make(chan error, 1) | |
| 87 | 87 | go func() { | |
| 88 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil) | |
| 88 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, discardSink(t), nil, nil) | |
| 89 | 89 | }() | |
| 90 | 90 | if !o.PrepareSeatDisable(5 * time.Second) { | |
| 91 | 91 | t.Fatal("no ack") | |
M internal/adapters/drm/security.go +2 -0
| @@ -113,6 +113,8 @@ func (o *Output) prepareSecurity(ctx context.Context, r ports.Renderer) error { | |||
| 113 | 113 | ||
| 114 | 114 | // Protected frames never enter client-fence KMS paths or capture pipelines. | |
| 115 | 115 | // Locker content is composed only after black preparation for this epoch. | |
| 116 | + | // They never wake core: the pendingFrame has no animating flag, so core falls | |
| 117 | + | // back to its frame timer while a lock is up. This is intentional. | |
| 116 | 118 | func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64) error { | |
| 117 | 119 | if !o.securityPrepared || !o.sceneCurrent(scene) { | |
| 118 | 120 | return errSecurityScene | |
M internal/adapters/drm/security_recovery_test.go +1 -1
| @@ -61,7 +61,7 @@ func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) { | |||
| 61 | 61 | scenes := make(chan ports.Scene, 1) | |
| 62 | 62 | done := make(chan error, 1) | |
| 63 | 63 | go func() { | |
| 64 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 64 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 65 | 65 | }() | |
| 66 | 66 | receive := func() ports.SecurityBackendEvent { | |
| 67 | 67 | t.Helper() | |
M internal/adapters/drm/security_resume_test.go +1 -1
| @@ -61,7 +61,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) { | |||
| 61 | 61 | defer cancel() | |
| 62 | 62 | done := make(chan error, 1) | |
| 63 | 63 | go func() { | |
| 64 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil) | |
| 64 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil, nil) | |
| 65 | 65 | }() | |
| 66 | 66 | active <- true | |
| 67 | 67 | if stage == "master-loss" { | |
M internal/adapters/drm/security_run_test.go +1 -1
| @@ -57,7 +57,7 @@ func securityRunWakePendingResumeAndOff(t *testing.T) { | |||
| 57 | 57 | defer cancel() | |
| 58 | 58 | done := make(chan error, 1) | |
| 59 | 59 | go func() { | |
| 60 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 60 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 61 | 61 | }() | |
| 62 | 62 | scenes <- ports.Scene{Seq: 99, Scale: 1} | |
| 63 | 63 | select { | |
M internal/adapters/drm/security_startup_test.go +2 -2
| @@ -43,7 +43,7 @@ func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) { | |||
| 43 | 43 | defer cancel() | |
| 44 | 44 | done := make(chan error, 1) | |
| 45 | 45 | go func() { | |
| 46 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, captures, captured) | |
| 46 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, nil, captures, captured) | |
| 47 | 47 | }() | |
| 48 | 48 | select { | |
| 49 | 49 | case ev := <-events: | |
| @@ -107,7 +107,7 @@ func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) { | |||
| 107 | 107 | r.EXPECT().Close().Return().Once() | |
| 108 | 108 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |
| 109 | 109 | k.EXPECT().rmFB(mock.Anything).Return(nil) | |
| 110 | - | err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil) | |
| 110 | + | err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil, nil) | |
| 111 | 111 | if !errors.Is(err, context.Canceled) { | |
| 112 | 112 | t.Fatalf("startup cancellation: %v", err) | |
| 113 | 113 | } | |
M internal/adapters/drm/stuck_test.go +6 -4
| @@ -30,9 +30,10 @@ func runStuck(t *testing.T, n int, errs ...error) []commitRec { | |||
| 30 | 30 | type stuckRun struct { | |
| 31 | 31 | t *testing.T | |
| 32 | 32 | scenes chan ports.Scene | |
| 33 | - | contents chan ports.SurfaceContent | |
| 33 | + | contents *contentFeed | |
| 34 | 34 | active chan bool | |
| 35 | 35 | presented chan ports.OutputPresented | |
| 36 | + | sink ports.PresentationSink | |
| 36 | 37 | count func() int | |
| 37 | 38 | stop func() []commitRec | |
| 38 | 39 | } | |
| @@ -67,11 +68,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||
| 67 | 68 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 68 | 69 | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| 69 | 70 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 70 | - | 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)} | |
| 71 | + | h := &stuckRun{t: t, scenes: make(chan ports.Scene, 1), contents: newContentFeed(t, 1), active: make(chan bool, 1), presented: nil} | |
| 72 | + | h.sink, h.presented = presentedSink(t, 64) | |
| 71 | 73 | ctx, cancel := context.WithCancel(context.Background()) | |
| 72 | 74 | done := make(chan error, 1) | |
| 73 | 75 | go func() { | |
| 74 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented, nil, nil) | |
| 76 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents.in(), h.contents.ret, nil, h.sink, nil, nil) | |
| 75 | 77 | }() | |
| 76 | 78 | h.count = func() int { | |
| 77 | 79 | commitMu.Lock() | |
| @@ -194,7 +196,7 @@ func TestSeenWaitsForRefusedFrameFence(t *testing.T) { | |||
| 194 | 196 | h.scenes <- ports.Scene{Seq: 1} | |
| 195 | 197 | // EBUSY, then the retry loses DRM master: nothing is pending. | |
| 196 | 198 | waitFor(t, func() bool { return h.count() == 4 }) | |
| 197 | - | h.contents <- ports.SurfaceContent{ID: 1, Seq: 7} | |
| 199 | + | h.contents.send(ports.SurfaceContent{ID: 1, Seq: 7}) | |
| 198 | 200 | select { | |
| 199 | 201 | case p := <-h.presented: | |
| 200 | 202 | t.Fatalf("seen reported while refused frames may be read: %+v", p) | |
M internal/adapters/drm/vrr_flip_gap_test.go +1 -1
| @@ -115,7 +115,7 @@ func TestRunWaitsVRRFlipGap(t *testing.T) { | |||
| 115 | 115 | ctx, cancel := context.WithCancel(context.Background()) | |
| 116 | 116 | done := make(chan error, 1) | |
| 117 | 117 | go func() { | |
| 118 | - | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 118 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), nil, nil) | |
| 119 | 119 | }() | |
| 120 | 120 | // A composed fullscreen window: a game frame under VRR. | |
| 121 | 121 | game := ports.Scene{OutputWidth: 200, OutputHeight: 100, | |
M internal/adapters/headless/capture_bounds_test.go +2 -2
| @@ -27,7 +27,7 @@ func TestScreenshotReadbackFailureDoesNotWriteBlackPNG(t *testing.T) { | |||
| 27 | 27 | r.EXPECT().Close().Return().Once() | |
| 28 | 28 | done := make(chan error, 1) | |
| 29 | 29 | go func() { | |
| 30 | - | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 30 | + | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 31 | 31 | }() | |
| 32 | 32 | select { | |
| 33 | 33 | case <-attempted: | |
| @@ -64,7 +64,7 @@ func TestCaptureBatchBoundedAndRecycledWhileIdle(t *testing.T) { | |||
| 64 | 64 | replies := make(chan ports.CaptureDone, capture.MaxRequests+1) | |
| 65 | 65 | done := make(chan error, 1) | |
| 66 | 66 | go func() { | |
| 67 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 67 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming) | |
| 68 | 68 | }() | |
| 69 | 69 | for i := range capture.MaxRequests + 1 { | |
| 70 | 70 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
M internal/adapters/headless/capture_test.go +8 -8
| @@ -74,7 +74,7 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa | |||
| 74 | 74 | replies := make(chan ports.CaptureDone, 8) | |
| 75 | 75 | done := make(chan error, 1) | |
| 76 | 76 | go func() { | |
| 77 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 77 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming) | |
| 78 | 78 | }() | |
| 79 | 79 | // Requests wait for a scene: all of them belong to its first frame. | |
| 80 | 80 | for _, q := range reqs { | |
| @@ -163,12 +163,12 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) { | |||
| 163 | 163 | replies := make(chan ports.CaptureDone, 2) | |
| 164 | 164 | done := make(chan error, 1) | |
| 165 | 165 | var made [2]int | |
| 166 | - | presented := make(chan ports.OutputPresented, 4) | |
| 166 | + | sink, presented := presentedSink(t, 4) | |
| 167 | 167 | go func() { | |
| 168 | - | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: presented, | |
| 168 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: sink, | |
| 169 | 169 | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }, | |
| 170 | 170 | NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil }, | |
| 171 | - | }, scenes, nil, nil, incoming) | |
| 171 | + | }, scenes, nil, nil, nil, incoming) | |
| 172 | 172 | }() | |
| 173 | 173 | q := ports.CaptureRequest{ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)} | |
| 174 | 174 | incoming <- q | |
| @@ -241,7 +241,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) { | |||
| 241 | 241 | done := make(chan error, 1) | |
| 242 | 242 | go func() { | |
| 243 | 243 | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, | |
| 244 | - | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming) | |
| 244 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, nil, incoming) | |
| 245 | 245 | }() | |
| 246 | 246 | incoming <- ports.CaptureRequest{ID: 1, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)} | |
| 247 | 247 | scenes <- sessionScene() | |
| @@ -312,7 +312,7 @@ func TestCaptureWaitsForItsIndicatorScene(t *testing.T) { | |||
| 312 | 312 | replies := make(chan ports.CaptureDone, 4) | |
| 313 | 313 | done := make(chan error, 1) | |
| 314 | 314 | go func() { | |
| 315 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 315 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming) | |
| 316 | 316 | }() | |
| 317 | 317 | bare := ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}} | |
| 318 | 318 | scenes <- bare | |
| @@ -356,7 +356,7 @@ func TestCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) { | |||
| 356 | 356 | replies := make(chan ports.CaptureDone, 4) | |
| 357 | 357 | done := make(chan error, 1) | |
| 358 | 358 | go func() { | |
| 359 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 359 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming) | |
| 360 | 360 | }() | |
| 361 | 361 | scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2} | |
| 362 | 362 | start := time.Now() | |
| @@ -406,7 +406,7 @@ func TestCaptureDeliveredOnlyWhenTheIndicatedFramePresents(t *testing.T) { | |||
| 406 | 406 | replies := make(chan ports.CaptureDone, 4) | |
| 407 | 407 | done := make(chan error, 1) | |
| 408 | 408 | go func() { | |
| 409 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 409 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming) | |
| 410 | 410 | }() | |
| 411 | 411 | incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)} | |
| 412 | 412 | scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}, | |
A internal/adapters/headless/content_feed_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // contentFeed is the wayland side of Run's content input in tests: batches go | |
| 12 | + | // out on ch, and the generated ContentReturn mock checks that each comes back | |
| 13 | + | // reset. | |
| 14 | + | type contentFeed struct { | |
| 15 | + | ch chan *ports.ContentBatch | |
| 16 | + | ret *portsmocks.MockContentReturn | |
| 17 | + | returned chan *ports.ContentBatch | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // newContentFeed makes a feed whose channel holds buffer batches. | |
| 21 | + | func newContentFeed(t *testing.T, buffer int) *contentFeed { | |
| 22 | + | t.Helper() | |
| 23 | + | f := &contentFeed{ch: make(chan *ports.ContentBatch, buffer), ret: portsmocks.NewMockContentReturn(t), returned: make(chan *ports.ContentBatch, 64)} | |
| 24 | + | f.ret.EXPECT().Return(mock.Anything).Run(func(b *ports.ContentBatch) { | |
| 25 | + | if len(b.Items) != 0 { | |
| 26 | + | t.Errorf("batch returned with %d items, want it reset", len(b.Items)) | |
| 27 | + | } | |
| 28 | + | f.returned <- b | |
| 29 | + | }).Return().Maybe() | |
| 30 | + | return f | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // send hands the output one batch holding cs. | |
| 34 | + | func (f *contentFeed) send(cs ...ports.SurfaceContent) { | |
| 35 | + | f.ch <- &ports.ContentBatch{Items: cs} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // in is the channel Run reads. | |
| 39 | + | func (f *contentFeed) in() <-chan *ports.ContentBatch { return f.ch } | |
A internal/adapters/headless/flip_alloc_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Steady-state flip reporting: the Shows snapshot is reused while it does not | |
| 11 | + | // change, and a changed one never mutates what an earlier flip handed out. | |
| 12 | + | func TestFlipShowsAllocations(t *testing.T) { | |
| 13 | + | var f flipShows | |
| 14 | + | scene := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}} | |
| 15 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Seq: 1}, 2: {ID: 2, Seq: 2}} | |
| 16 | + | first := f.flipInfo(scene, contents, nil) | |
| 17 | + | require.Equal(t, map[ports.WindowID]uint64{1: 1}, first.Shows) | |
| 18 | + | ||
| 19 | + | require.Zero(t, testing.AllocsPerRun(100, func() { f.of(scene, contents, nil) }), "unchanged shows") | |
| 20 | + | // The FlipInfo is the one allocation left: the sink's merge writes into it. | |
| 21 | + | require.LessOrEqual(t, testing.AllocsPerRun(100, func() { f.flipInfo(scene, contents, nil) }), 1.0, "flip report") | |
| 22 | + | ||
| 23 | + | contents[1] = ports.SurfaceContent{ID: 1, Seq: 3} | |
| 24 | + | second := f.flipInfo(scene, contents, nil) | |
| 25 | + | require.Equal(t, map[ports.WindowID]uint64{1: 1}, first.Shows, "an earlier snapshot is immutable") | |
| 26 | + | require.Equal(t, map[ports.WindowID]uint64{1: 3}, second.Shows) | |
| 27 | + | require.NotSame(t, first, second) | |
| 28 | + | } | |
A internal/adapters/headless/frames_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // runWithFrames runs an output with a frames channel of the given size and | |
| 13 | + | // returns what it needs to feed scenes and stop it. | |
| 14 | + | func runWithFrames(t *testing.T, size int) (scenes chan ports.Scene, frames chan ports.OutputFrame, f *frames, stop func()) { | |
| 15 | + | t.Helper() | |
| 16 | + | scenes = make(chan ports.Scene, 8) | |
| 17 | + | frames = make(chan ports.OutputFrame, size) | |
| 18 | + | r, rec := recordingRenderer(t, nil) | |
| 19 | + | sink, _ := presentedSink(t, 16) | |
| 20 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 21 | + | done := make(chan error, 1) | |
| 22 | + | go func() { | |
| 23 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Name: "HEADLESS-1", Presented: sink, Frames: frames, | |
| 24 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 25 | + | }() | |
| 26 | + | return scenes, frames, rec, func() { | |
| 27 | + | cancel() | |
| 28 | + | require.NoError(t, <-done) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // The presentation of an animating scene tells core the next frame is due; | |
| 33 | + | // the one of a settled scene does not. | |
| 34 | + | func TestAnimatingSceneSendsFrame(t *testing.T) { | |
| 35 | + | scenes, frames, rec, stop := runWithFrames(t, 4) | |
| 36 | + | defer stop() | |
| 37 | + | ||
| 38 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Animating: true} | |
| 39 | + | select { | |
| 40 | + | case got := <-frames: | |
| 41 | + | require.Equal(t, ports.OutputFrame{Output: "HEADLESS-1"}, got) | |
| 42 | + | case <-time.After(3 * time.Second): | |
| 43 | + | t.Fatal("no frame for an animating scene") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // The scene an animation settles on: drawn, nobody woken. | |
| 47 | + | scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2} | |
| 48 | + | waitFrames(t, rec, 2) | |
| 49 | + | // A third scene is drawn after the settled one (the owner handles scenes | |
| 50 | + | // in order): had the settled frame woken core, its frame precedes the | |
| 51 | + | // third's in the channel. | |
| 52 | + | scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2} | |
| 53 | + | waitFrames(t, rec, 3) | |
| 54 | + | require.Empty(t, frames) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // A frames channel core has not read stays full: the owner never waits. | |
| 58 | + | func TestAnimatingFramesNeverBlock(t *testing.T) { | |
| 59 | + | scenes, frames, rec, stop := runWithFrames(t, 1) | |
| 60 | + | defer stop() | |
| 61 | + | for seq := uint64(1); seq <= 3; seq++ { | |
| 62 | + | scenes <- ports.Scene{Seq: seq, OutputWidth: 2, OutputHeight: 2, Animating: true} | |
| 63 | + | waitFrames(t, rec, int(seq)) | |
| 64 | + | } | |
| 65 | + | require.Len(t, frames, 1) | |
| 66 | + | } | |
M internal/adapters/headless/leaving_test.go +3 -2
| @@ -10,15 +10,16 @@ import ( | |||
| 10 | 10 | // A window drawn from a content its client withdrew (closed, fading out) is | |
| 11 | 11 | // not reported shown: the empty content has no frame to present. | |
| 12 | 12 | func TestFlipInfoSkipsKeptContent(t *testing.T) { | |
| 13 | + | var shows flipShows | |
| 13 | 14 | table := surfaces.New() | |
| 14 | 15 | s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}} | |
| 15 | 16 | table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s) | |
| 16 | 17 | table.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, s) | |
| 17 | - | if f := flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 { | |
| 18 | + | if f := shows.flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 { | |
| 18 | 19 | t.Fatalf("control: %v", f.Shows) | |
| 19 | 20 | } | |
| 20 | 21 | table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s) | |
| 21 | - | f := flipInfo(s, table.Map(), table.Kept) | |
| 22 | + | f := shows.flipInfo(s, table.Map(), table.Kept) | |
| 22 | 23 | if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 { | |
| 23 | 24 | t.Fatalf("a leaving window is reported shown: %v", f.Shows) | |
| 24 | 25 | } | |
M internal/adapters/headless/output.go +41 -49
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 16 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/corewake" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/outputkit/presented" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| @@ -47,8 +48,11 @@ type Options struct { | |||
| 47 | 48 | // Name and Presented report what the output has read after each | |
| 48 | 49 | // frame, so wayland can release client buffers (nil: no reports). | |
| 49 | 50 | Name string | |
| 50 | - | Presented chan<- ports.OutputPresented | |
| 51 | - | Captured chan<- ports.CaptureDone | |
| 51 | + | Presented ports.PresentationSink | |
| 52 | + | // Frames tells core a presented scene is animating: its next frame is | |
| 53 | + | // due. Sends never block (nil: nobody is told). | |
| 54 | + | Frames chan<- ports.OutputFrame | |
| 55 | + | Captured chan<- ports.CaptureDone | |
| 52 | 56 | // Clock paces the renderer trim (nil: system clock). | |
| 53 | 57 | Clock ports.Clock | |
| 54 | 58 | } | |
| @@ -57,7 +61,7 @@ type Options struct { | |||
| 57 | 61 | // windows it no longer draws. | |
| 58 | 62 | const trimEvery = 10 * time.Second | |
| 59 | 63 | ||
| 60 | - | func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error { | |
| 64 | + | func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error { | |
| 61 | 65 | r, err := opts.NewRenderer(opts.Width, opts.Height) | |
| 62 | 66 | if err != nil { | |
| 63 | 67 | return fmt.Errorf("create renderer: %w", err) | |
| @@ -111,8 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 111 | 115 | cursorScale := -1.0 // not loaded yet | |
| 112 | 116 | var cursorTransform ports.BufferTransform | |
| 113 | 117 | seen := map[ports.WindowID]uint64{} | |
| 114 | - | // reports holds what the channel could not take yet, retried soon. | |
| 118 | + | // reports queues what the output read; every report is pushed to the | |
| 119 | + | // sink at once (Push never blocks), so it holds one at most. | |
| 115 | 120 | var reports presented.Queue | |
| 121 | + | var shows flipShows | |
| 116 | 122 | var security ports.SecurityState | |
| 117 | 123 | securityKnown := false | |
| 118 | 124 | failRequests := func(reason error) { | |
| @@ -186,10 +192,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 186 | 192 | // A closed window's last content goes once no scene draws it. | |
| 187 | 193 | table.Prune(scene) | |
| 188 | 194 | } | |
| 189 | - | update := func(c ports.SurfaceContent) { | |
| 190 | - | seen[c.ID] = max(seen[c.ID], c.Seq) | |
| 191 | - | dirty = dirty || capture.Shows(scene, c.ID) | |
| 192 | - | table.Update(c, scene) | |
| 195 | + | update := func(b *ports.ContentBatch) { | |
| 196 | + | batchDirty, _ := table.Apply(b, scene, seen, returns) | |
| 197 | + | dirty = dirty || batchDirty | |
| 193 | 198 | } | |
| 194 | 199 | clk := opts.Clock | |
| 195 | 200 | if clk == nil { | |
| @@ -215,10 +220,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 215 | 220 | } | |
| 216 | 221 | return err | |
| 217 | 222 | } | |
| 218 | - | var retry <-chan time.Time | |
| 219 | - | if reports.Len() > 0 { | |
| 220 | - | retry = time.After(time.Millisecond) | |
| 221 | - | } | |
| 222 | 223 | // Requests wait for a scene that shows their indicator, at most | |
| 223 | 224 | // capture.HoldFor (see capture.Hold). | |
| 224 | 225 | var holdDue <-chan time.Time | |
| @@ -290,15 +291,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 290 | 291 | refuse = capture.ErrOutputOff | |
| 291 | 292 | } | |
| 292 | 293 | dirty = requests.Admit(q, refuse, scene, haveScene) || dirty | |
| 293 | - | case <-retry: | |
| 294 | - | if err := checkSecurity(); err != nil { | |
| 295 | - | if ctx.Err() != nil { | |
| 296 | - | return nil | |
| 297 | - | } | |
| 298 | - | return err | |
| 299 | - | } | |
| 300 | - | opts.flush(&reports) | |
| 301 | - | continue | |
| 302 | 294 | case <-trim.C(): | |
| 303 | 295 | if err := checkSecurity(); err != nil { | |
| 304 | 296 | if ctx.Err() != nil { | |
| @@ -312,6 +304,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 312 | 304 | if err := r.Trim(clk.Now()); err != nil { | |
| 313 | 305 | return fmt.Errorf("trim renderer: %w", err) | |
| 314 | 306 | } | |
| 307 | + | opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats") | |
| 315 | 308 | continue | |
| 316 | 309 | case s, ok := <-scenes: | |
| 317 | 310 | if !ok { | |
| @@ -325,7 +318,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 325 | 318 | return err | |
| 326 | 319 | } | |
| 327 | 320 | acceptScene(s) | |
| 328 | - | case c, ok := <-contents: | |
| 321 | + | case b, ok := <-contents: | |
| 329 | 322 | if !ok { | |
| 330 | 323 | contents = nil | |
| 331 | 324 | continue | |
| @@ -336,7 +329,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 336 | 329 | } | |
| 337 | 330 | return err | |
| 338 | 331 | } | |
| 339 | - | update(c) | |
| 332 | + | update(b) | |
| 340 | 333 | case c, ok := <-cursor: | |
| 341 | 334 | if !ok { | |
| 342 | 335 | cursor = nil | |
| @@ -367,7 +360,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 367 | 360 | } | |
| 368 | 361 | acceptScene(s) | |
| 369 | 362 | } | |
| 370 | - | case c, ok := <-contents: | |
| 363 | + | case b, ok := <-contents: | |
| 371 | 364 | if !ok { | |
| 372 | 365 | contents = nil | |
| 373 | 366 | } else { | |
| @@ -377,7 +370,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 377 | 370 | } | |
| 378 | 371 | return err | |
| 379 | 372 | } | |
| 380 | - | update(c) | |
| 373 | + | update(b) | |
| 381 | 374 | } | |
| 382 | 375 | default: | |
| 383 | 376 | break drain | |
| @@ -448,9 +441,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 448 | 441 | _, reads, _ := pipeline.CapHiddenSeen(seen) | |
| 449 | 442 | if len(reads) > 0 { | |
| 450 | 443 | reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) | |
| 451 | - | if reports.Drain(ctx, opts.Presented) != nil { | |
| 452 | - | return nil | |
| 453 | - | } | |
| 444 | + | reports.Flush(opts.Presented) | |
| 454 | 445 | } | |
| 455 | 446 | } | |
| 456 | 447 | if err := pipeline.WaitHidden(ctx); err != nil { | |
| @@ -546,7 +537,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 546 | 537 | pipeline.EndGate(nil) | |
| 547 | 538 | requests.List = capture.Waiting(requests.List, scene) | |
| 548 | 539 | frame++ | |
| 549 | - | opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) | |
| 540 | + | opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen) | |
| 541 | + | if scene.Animating { | |
| 542 | + | corewake.Send(opts.Frames, opts.Name) | |
| 543 | + | } | |
| 550 | 544 | if opts.ScreenshotDir != "" && !opts.protected() { | |
| 551 | 545 | shot := r.Pixels() | |
| 552 | 546 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| @@ -617,37 +611,35 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity | |||
| 617 | 611 | } | |
| 618 | 612 | ||
| 619 | 613 | // report queues what the output read, with flip for a drawn frame, and | |
| 620 | - | // sends what the channel takes now; the rest is retried soon. | |
| 614 | + | // pushes it to the sink. | |
| 621 | 615 | func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |
| 622 | 616 | if opts.Presented == nil { | |
| 623 | 617 | return | |
| 624 | 618 | } | |
| 625 | - | if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) { | |
| 626 | - | opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged") | |
| 627 | - | } | |
| 619 | + | q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) | |
| 628 | 620 | q.Flush(opts.Presented) | |
| 629 | 621 | } | |
| 630 | 622 | ||
| 631 | - | // flush retries reports the channel could not take. | |
| 632 | - | func (opts Options) flush(q *presented.Queue) { | |
| 633 | - | if opts.Presented != nil { | |
| 634 | - | q.Flush(opts.Presented) | |
| 635 | - | } | |
| 623 | + | // flipShows builds the Shows of successive flips. Its owner goroutine is the | |
| 624 | + | // only user. The Shows snapshot is shared with earlier flips of the same | |
| 625 | + | // content (see surfaces.ShowsSnapshot): never modify it. | |
| 626 | + | type flipShows struct { | |
| 627 | + | snapshot surfaces.ShowsSnapshot | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // of returns the content Seq of the windows scene showed to their client. | |
| 631 | + | func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 { | |
| 632 | + | // A closed window drawn from its kept content is not shown to its | |
| 633 | + | // client. | |
| 634 | + | return f.snapshot.FromContents(scene, contents, kept) | |
| 636 | 635 | } | |
| 637 | 636 | ||
| 638 | 637 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | |
| 639 | 638 | // time (software clock, refresh unknown), so presentation feedback and | |
| 640 | - | // frame callbacks follow headless frames. | |
| 641 | - | func flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo { | |
| 639 | + | // frame callbacks follow headless frames. The FlipInfo itself is new per | |
| 640 | + | // flip (the sink counts merges on it); only its Shows is shared. | |
| 641 | + | func (f *flipShows) flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo { | |
| 642 | 642 | var ts unix.Timespec | |
| 643 | 643 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 644 | - | shows := map[ports.WindowID]uint64{} | |
| 645 | - | for id, c := range contents { | |
| 646 | - | // A closed window drawn from its kept content is not shown to | |
| 647 | - | // its client. | |
| 648 | - | if surfaces.Shown(scene, id, kept) { | |
| 649 | - | shows[id] = c.Seq | |
| 650 | - | } | |
| 651 | - | } | |
| 652 | - | return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows} | |
| 644 | + | return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)} | |
| 653 | 645 | } | |
M internal/adapters/headless/output_test.go +73 -23
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package headless | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "image" | |
| @@ -9,6 +10,7 @@ import ( | |||
| 9 | 10 | "maps" | |
| 10 | 11 | "os" | |
| 11 | 12 | "path/filepath" | |
| 13 | + | "strings" | |
| 12 | 14 | "sync" | |
| 13 | 15 | "testing" | |
| 14 | 16 | "time" | |
| @@ -16,6 +18,7 @@ import ( | |||
| 16 | 18 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 17 | 19 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 18 | 20 | "github.com/bnema/neferwl/internal/ports" | |
| 21 | + | "github.com/bnema/zerowrap" | |
| 19 | 22 | "github.com/stretchr/testify/mock" | |
| 20 | 23 | ) | |
| 21 | 24 | ||
| @@ -68,17 +71,17 @@ func waitFrames(t *testing.T, f *frames, n int) { | |||
| 68 | 71 | func TestRun(t *testing.T) { | |
| 69 | 72 | dir := t.TempDir() | |
| 70 | 73 | scenes := make(chan ports.Scene, 8) | |
| 71 | - | contents := make(chan ports.SurfaceContent, 8) | |
| 74 | + | contents := newContentFeed(t, 8) | |
| 72 | 75 | r, f := recordingRenderer(t, nil) | |
| 73 | - | contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}} | |
| 76 | + | contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}) | |
| 74 | 77 | shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}} | |
| 75 | 78 | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown} | |
| 76 | 79 | scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown} | |
| 77 | - | contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}} | |
| 80 | + | contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}) | |
| 78 | 81 | ctx, cancel := context.WithCancel(context.Background()) | |
| 79 | 82 | done := make(chan error, 1) | |
| 80 | 83 | go func() { | |
| 81 | - | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil) | |
| 84 | + | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil) | |
| 82 | 85 | }() | |
| 83 | 86 | waitFrames(t, f, 1) | |
| 84 | 87 | if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 { | |
| @@ -86,7 +89,7 @@ func TestRun(t *testing.T) { | |||
| 86 | 89 | } | |
| 87 | 90 | // An empty content of a window the scene still draws (it unmapped | |
| 88 | 91 | // and fades out) keeps the last one; a scene without it drops it. | |
| 89 | - | contents <- ports.SurfaceContent{ID: 1} | |
| 92 | + | contents.send(ports.SurfaceContent{ID: 1}) | |
| 90 | 93 | waitFrames(t, f, 2) | |
| 91 | 94 | if _, c := f.snapshot(); len(c) < 2 { | |
| 92 | 95 | t.Fatal("no second frame") | |
| @@ -120,20 +123,20 @@ func TestRun(t *testing.T) { | |||
| 120 | 123 | // A scene whose Seq the output already holds is not drawn again. | |
| 121 | 124 | func TestHeadlessSameSeqRendersOnce(t *testing.T) { | |
| 122 | 125 | scenes := make(chan ports.Scene) | |
| 123 | - | contents := make(chan ports.SurfaceContent) | |
| 126 | + | contents := newContentFeed(t, 0) | |
| 124 | 127 | r, f := recordingRenderer(t, nil) | |
| 125 | 128 | ctx, cancel := context.WithCancel(context.Background()) | |
| 126 | 129 | done := make(chan error, 1) | |
| 127 | 130 | go func() { | |
| 128 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil) | |
| 131 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil) | |
| 129 | 132 | }() | |
| 130 | 133 | scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}} | |
| 131 | 134 | scenes <- scene | |
| 132 | 135 | waitFrames(t, f, 1) | |
| 133 | 136 | scenes <- scene | |
| 134 | 137 | // Unshown content sends Run round its loop: the held scene is handled. | |
| 135 | - | contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}} | |
| 136 | - | contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}} | |
| 138 | + | contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}) | |
| 139 | + | contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}) | |
| 137 | 140 | if s, _ := f.snapshot(); len(s) != 1 { | |
| 138 | 141 | t.Fatalf("%d frames for a scene with the held Seq", len(s)) | |
| 139 | 142 | } | |
| @@ -152,22 +155,22 @@ func TestHeadlessSameSeqRendersOnce(t *testing.T) { | |||
| 152 | 155 | // Content of a window the output does not show draws no frame. | |
| 153 | 156 | func TestRunSkipsContentNotShown(t *testing.T) { | |
| 154 | 157 | scenes := make(chan ports.Scene, 1) | |
| 155 | - | contents := make(chan ports.SurfaceContent, 2) | |
| 158 | + | contents := newContentFeed(t, 2) | |
| 156 | 159 | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}} | |
| 157 | 160 | r, f := recordingRenderer(t, nil) | |
| 158 | 161 | ctx, cancel := context.WithCancel(context.Background()) | |
| 159 | 162 | done := make(chan error, 1) | |
| 160 | 163 | go func() { | |
| 161 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil) | |
| 164 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil) | |
| 162 | 165 | }() | |
| 163 | 166 | waitFrames(t, f, 1) | |
| 164 | - | contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}} | |
| 165 | - | contents <- ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}} | |
| 167 | + | contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}) | |
| 168 | + | contents.send(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}) | |
| 166 | 169 | time.Sleep(50 * time.Millisecond) | |
| 167 | 170 | if s, _ := f.snapshot(); len(s) != 1 { | |
| 168 | 171 | t.Fatalf("%d frames for content not shown", len(s)) | |
| 169 | 172 | } | |
| 170 | - | contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}} | |
| 173 | + | contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}) | |
| 171 | 174 | waitFrames(t, f, 2) | |
| 172 | 175 | if _, c := f.snapshot(); c[1][2].SHM == nil { | |
| 173 | 176 | t.Error("content not shown was dropped") | |
| @@ -182,11 +185,11 @@ func TestErrors(t *testing.T) { | |||
| 182 | 185 | expected := errors.New("failure") | |
| 183 | 186 | scenes := make(chan ports.Scene, 1) | |
| 184 | 187 | scenes <- ports.Scene{} | |
| 185 | - | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil); !errors.Is(err, expected) { | |
| 188 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) { | |
| 186 | 189 | t.Fatal(err) | |
| 187 | 190 | } | |
| 188 | 191 | r, _ := recordingRenderer(t, expected) | |
| 189 | - | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil); !errors.Is(err, expected) { | |
| 192 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) { | |
| 190 | 193 | t.Fatal(err) | |
| 191 | 194 | } | |
| 192 | 195 | } | |
| @@ -214,8 +217,10 @@ func TestRunTrimsRendererOnTick(t *testing.T) { | |||
| 214 | 217 | return nil | |
| 215 | 218 | }).Twice() | |
| 216 | 219 | done := make(chan error, 1) | |
| 220 | + | var statsLog bytes.Buffer // written by Run's goroutine, read after it ends | |
| 221 | + | log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog}) | |
| 217 | 222 | go func() { | |
| 218 | - | done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil) | |
| 223 | + | done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil) | |
| 219 | 224 | }() | |
| 220 | 225 | ticks <- now | |
| 221 | 226 | if got := <-trimmed; !got.Equal(now) { | |
| @@ -228,6 +233,15 @@ func TestRunTrimsRendererOnTick(t *testing.T) { | |||
| 228 | 233 | if s, _ := f.snapshot(); len(s) != 0 { | |
| 229 | 234 | t.Fatalf("%d frames for a trim", len(s)) | |
| 230 | 235 | } | |
| 236 | + | // A trim tick that succeeded logs the periodic stats once. | |
| 237 | + | if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 { | |
| 238 | + | t.Fatalf("%d stats entries: %s", got, statsLog.String()) | |
| 239 | + | } | |
| 240 | + | for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`} { | |
| 241 | + | if !strings.Contains(statsLog.String(), want) { | |
| 242 | + | t.Fatalf("stats entry lacks %s: %s", want, statsLog.String()) | |
| 243 | + | } | |
| 244 | + | } | |
| 231 | 245 | } | |
| 232 | 246 | ||
| 233 | 247 | func TestWritePNGAtomic(t *testing.T) { | |
| @@ -258,15 +272,15 @@ func TestWritePNGAtomic(t *testing.T) { | |||
| 258 | 272 | ||
| 259 | 273 | func TestRunPassesLayerContent(t *testing.T) { | |
| 260 | 274 | scenes := make(chan ports.Scene, 1) | |
| 261 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 275 | + | contents := newContentFeed(t, 1) | |
| 262 | 276 | layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}} | |
| 263 | 277 | scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}} | |
| 264 | - | contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}} | |
| 278 | + | contents.send(ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}}) | |
| 265 | 279 | r, f := recordingRenderer(t, nil) | |
| 266 | 280 | ctx, cancel := context.WithCancel(context.Background()) | |
| 267 | 281 | done := make(chan error, 1) | |
| 268 | 282 | go func() { | |
| 269 | - | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil) | |
| 283 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil) | |
| 270 | 284 | }() | |
| 271 | 285 | deadline := time.After(3 * time.Second) | |
| 272 | 286 | for { | |
| @@ -302,7 +316,7 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) { | |||
| 302 | 316 | ctx, cancel := context.WithCancel(context.Background()) | |
| 303 | 317 | done := make(chan error, 1) | |
| 304 | 318 | go func() { | |
| 305 | - | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, requests) | |
| 319 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, requests) | |
| 306 | 320 | }() | |
| 307 | 321 | scenes <- ports.Scene{Background: "#000000"} | |
| 308 | 322 | waitFrames(t, frames, 1) | |
| @@ -363,7 +377,7 @@ func TestHeadlessRawHDRScreenshot(t *testing.T) { | |||
| 363 | 377 | ctx, cancel := context.WithCancel(context.Background()) | |
| 364 | 378 | done := make(chan error, 1) | |
| 365 | 379 | go func() { | |
| 366 | - | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 380 | + | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 367 | 381 | }() | |
| 368 | 382 | select { | |
| 369 | 383 | case <-rendered: | |
| @@ -438,7 +452,7 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | |||
| 438 | 452 | ctx, cancel := context.WithCancel(context.Background()) | |
| 439 | 453 | done := make(chan error, 1) | |
| 440 | 454 | go func() { | |
| 441 | - | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil) | |
| 455 | + | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil) | |
| 442 | 456 | }() | |
| 443 | 457 | select { | |
| 444 | 458 | case f := <-formats: | |
| @@ -458,3 +472,39 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | |||
| 458 | 472 | }) | |
| 459 | 473 | } | |
| 460 | 474 | } | |
| 475 | + | ||
| 476 | + | // One batch with two windows applies both before the frame they show in, and | |
| 477 | + | // goes back to wayland reset and exactly once. | |
| 478 | + | func TestRunAppliesWholeBatch(t *testing.T) { | |
| 479 | + | scenes := make(chan ports.Scene, 1) | |
| 480 | + | contents := newContentFeed(t, 1) | |
| 481 | + | shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}} | |
| 482 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown} | |
| 483 | + | r, f := recordingRenderer(t, nil) | |
| 484 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 485 | + | done := make(chan error, 1) | |
| 486 | + | go func() { | |
| 487 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil) | |
| 488 | + | }() | |
| 489 | + | waitFrames(t, f, 1) | |
| 490 | + | contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}) | |
| 491 | + | waitFrames(t, f, 2) | |
| 492 | + | if _, c := f.snapshot(); len(c[1]) != 2 || c[1][1].SHM == nil || c[1][2].SHM == nil { | |
| 493 | + | t.Errorf("batch not applied whole: %+v", c[1]) | |
| 494 | + | } | |
| 495 | + | select { | |
| 496 | + | case b := <-contents.returned: | |
| 497 | + | if len(b.Items) != 0 { | |
| 498 | + | t.Errorf("batch returned with %d items", len(b.Items)) | |
| 499 | + | } | |
| 500 | + | case <-time.After(2 * time.Second): | |
| 501 | + | t.Fatal("batch not returned") | |
| 502 | + | } | |
| 503 | + | cancel() | |
| 504 | + | if err := <-done; err != nil { | |
| 505 | + | t.Fatal(err) | |
| 506 | + | } | |
| 507 | + | if len(contents.returned) != 0 { | |
| 508 | + | t.Error("batch returned more than once") | |
| 509 | + | } | |
| 510 | + | } | |
A internal/adapters/headless/presented_sink_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // presentedSink is a generated PresentationSink mock that records every push | |
| 12 | + | // on the returned channel. A full channel drops the report: Push never blocks, | |
| 13 | + | // as the port requires. | |
| 14 | + | func presentedSink(t *testing.T, size int) (ports.PresentationSink, chan ports.OutputPresented) { | |
| 15 | + | t.Helper() | |
| 16 | + | ch := make(chan ports.OutputPresented, size) | |
| 17 | + | sink := portsmocks.NewMockPresentationSink(t) | |
| 18 | + | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { | |
| 19 | + | select { | |
| 20 | + | case ch <- r: | |
| 21 | + | default: | |
| 22 | + | } | |
| 23 | + | }).Maybe() | |
| 24 | + | return sink, ch | |
| 25 | + | } | |
M internal/adapters/headless/security_capture_test.go +2 -2
| @@ -61,7 +61,7 @@ func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) { | |||
| 61 | 61 | incoming := make(chan ports.CaptureRequest) | |
| 62 | 62 | replies := make(chan ports.CaptureDone, 2) | |
| 63 | 63 | go func() { | |
| 64 | - | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |
| 64 | + | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming) | |
| 65 | 65 | }() | |
| 66 | 66 | dst := sessionCaptureFile(t) | |
| 67 | 67 | incoming <- ports.CaptureRequest{ID: 91, Exclude: excluded, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst} | |
| @@ -124,7 +124,7 @@ func TestTransitionDuringHeadlessRenderWaitRetainsFenceAndRejectsCapture(t *test | |||
| 124 | 124 | ctx, cancel := context.WithCancel(context.Background()) | |
| 125 | 125 | done := make(chan error, 1) | |
| 126 | 126 | go func() { | |
| 127 | - | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |
| 127 | + | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming) | |
| 128 | 128 | }() | |
| 129 | 129 | dst := sessionCaptureFile(t) | |
| 130 | 130 | incoming <- ports.CaptureRequest{ID: 92, Dst: dst} | |
M internal/adapters/headless/security_exclude_fence_test.go +4 -4
| @@ -46,9 +46,9 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | // Native capture and Pixels have no expectations; neither is admitted. | |
| 48 | 48 | scenes := make(chan ports.Scene, 2) | |
| 49 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 49 | + | contents := newContentFeed(t, 1) | |
| 50 | 50 | incoming := make(chan ports.CaptureRequest) | |
| 51 | - | presented := make(chan ports.OutputPresented, 8) | |
| 51 | + | sink, presented := presentedSink(t, 8) | |
| 52 | 52 | proofs := make(chan ports.SecurityBackendEvent, 2) | |
| 53 | 53 | replies := make(chan ports.CaptureDone, 2) | |
| 54 | 54 | changes := make(chan ports.SecurityState, 1) | |
| @@ -56,11 +56,11 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 56 | 56 | defer cancel() | |
| 57 | 57 | done := make(chan error, 1) | |
| 58 | 58 | go func() { | |
| 59 | - | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 7, Width: 2, Height: 2, Presented: presented, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, incoming) | |
| 59 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 7, Width: 2, Height: 2, Presented: sink, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, incoming) | |
| 60 | 60 | }() | |
| 61 | 61 | dst := sessionCaptureFile(t) | |
| 62 | 62 | incoming <- ports.CaptureRequest{ID: 99, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst} | |
| 63 | - | contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}} | |
| 63 | + | contents.send(ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}}) | |
| 64 | 64 | scene := sessionScene() | |
| 65 | 65 | scene.Security = admitted | |
| 66 | 66 | scene.Scale = 1 | |
M internal/adapters/headless/security_test.go +11 -11
| @@ -58,14 +58,14 @@ func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) { | |||
| 58 | 58 | scenes := make(chan ports.Scene, 3) | |
| 59 | 59 | scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}} | |
| 60 | 60 | scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}} | |
| 61 | - | contents := make(chan ports.SurfaceContent, 1) | |
| 61 | + | contents := newContentFeed(t, 1) | |
| 62 | 62 | acquire, acquireSignal, err := os.Pipe() | |
| 63 | 63 | if err != nil { | |
| 64 | 64 | t.Fatal(err) | |
| 65 | 65 | } | |
| 66 | 66 | defer acquire.Close() | |
| 67 | 67 | defer acquireSignal.Close() | |
| 68 | - | contents <- ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire} | |
| 68 | + | contents.send(ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire}) | |
| 69 | 69 | proof := make(chan ports.SecurityBackendEvent, 4) | |
| 70 | 70 | replies := make(chan ports.CaptureDone, 4) | |
| 71 | 71 | requests := make(chan ports.CaptureRequest, 2) | |
| @@ -78,7 +78,7 @@ func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) { | |||
| 78 | 78 | done := make(chan error, 1) | |
| 79 | 79 | dir := t.TempDir() | |
| 80 | 80 | go func() { | |
| 81 | - | done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, requests) | |
| 81 | + | done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, requests) | |
| 82 | 82 | }() | |
| 83 | 83 | t.Cleanup(func() { | |
| 84 | 84 | cancel() | |
| @@ -146,11 +146,11 @@ func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) { | |||
| 146 | 146 | scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}} | |
| 147 | 147 | changes := make(chan ports.SecurityState, 1) | |
| 148 | 148 | proofs := make(chan ports.SecurityBackendEvent, 2) | |
| 149 | - | presented := make(chan ports.OutputPresented, 4) | |
| 149 | + | sink, presented := presentedSink(t, 4) | |
| 150 | 150 | ctx, cancel := context.WithCancel(context.Background()) | |
| 151 | 151 | done := make(chan error, 1) | |
| 152 | 152 | go func() { | |
| 153 | - | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 153 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: sink, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 154 | 154 | }() | |
| 155 | 155 | t.Cleanup(func() { | |
| 156 | 156 | cancel() | |
| @@ -191,7 +191,7 @@ func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) { | |||
| 191 | 191 | ctx, cancel := context.WithCancel(context.Background()) | |
| 192 | 192 | done := make(chan error, 1) | |
| 193 | 193 | go func() { | |
| 194 | - | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil) | |
| 194 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil) | |
| 195 | 195 | }() | |
| 196 | 196 | locked := ports.SecurityState{Generation: 2, Protected: true} | |
| 197 | 197 | state.Store(locked) | |
| @@ -289,7 +289,7 @@ func TestSecurityRawHDRSuppressed(t *testing.T) { | |||
| 289 | 289 | ctx, cancel := context.WithCancel(context.Background()) | |
| 290 | 290 | done := make(chan error, 1) | |
| 291 | 291 | go func() { | |
| 292 | - | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 292 | + | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 293 | 293 | }() | |
| 294 | 294 | // The black protection frame follows the frame under test (or is | |
| 295 | 295 | // the only one): the raw block is over once it is rendered. | |
| @@ -336,7 +336,7 @@ func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) { | |||
| 336 | 336 | ctx, cancel := context.WithCancel(context.Background()) | |
| 337 | 337 | done := make(chan error, 1) | |
| 338 | 338 | go func() { | |
| 339 | - | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, incoming) | |
| 339 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, incoming) | |
| 340 | 340 | }() | |
| 341 | 341 | file, err := os.CreateTemp(t.TempDir(), "queued") | |
| 342 | 342 | if err != nil { | |
| @@ -378,13 +378,13 @@ func TestSecurityOffOutputProofDoesNotRender(t *testing.T) { | |||
| 378 | 378 | r.EXPECT().Close().Return().Once() | |
| 379 | 379 | scenes := make(chan ports.Scene, 1) | |
| 380 | 380 | scenes <- ports.Scene{Off: true} | |
| 381 | - | presented := make(chan ports.OutputPresented, 1) | |
| 381 | + | sink, presented := presentedSink(t, 1) | |
| 382 | 382 | changes := make(chan ports.SecurityState, 1) | |
| 383 | 383 | proofs := make(chan ports.SecurityBackendEvent, 1) | |
| 384 | 384 | ctx, cancel := context.WithCancel(context.Background()) | |
| 385 | 385 | done := make(chan error, 1) | |
| 386 | 386 | go func() { | |
| 387 | - | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 387 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: sink, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil) | |
| 388 | 388 | }() | |
| 389 | 389 | securityReceive(t, presented) // Off has actually been applied by the owner. | |
| 390 | 390 | locked := ports.SecurityState{Generation: 4, Protected: true} | |
| @@ -442,7 +442,7 @@ func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing | |||
| 442 | 442 | defer cancel() | |
| 443 | 443 | done := make(chan error, 1) | |
| 444 | 444 | go func() { | |
| 445 | - | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |
| 445 | + | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming) | |
| 446 | 446 | }() | |
| 447 | 447 | file, err := os.CreateTemp(t.TempDir(), "capture") | |
| 448 | 448 | if err != nil { | |
M internal/adapters/logging/logging.go +7 -2
| @@ -28,8 +28,13 @@ var components = map[string]bool{ | |||
| 28 | 28 | // categories are debug switches narrower than a component, only on when | |
| 29 | 29 | // named: "all" leaves them off. input-motion logs every pointer motion, | |
| 30 | 30 | // input-keys every key (typed text), drm-flip every frame and commit | |
| 31 | - | // completion with its timing. Naming one turns on debug for its component. | |
| 32 | - | var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"} | |
| 31 | + | // completion with its timing, wayland-damage every fresh client commit with | |
| 32 | + | // its damage rects, render-damage every rendered frame with its redrawn | |
| 33 | + | // area. Naming one turns on debug for its component. | |
| 34 | + | var categories = map[string]string{ | |
| 35 | + | "input-motion": "input", "input-keys": "input", "drm-flip": "drm", | |
| 36 | + | "wayland-damage": "wayland", "render-damage": "render", | |
| 37 | + | } | |
| 33 | 38 | ||
| 34 | 39 | type debugKey struct{} | |
| 35 | 40 | type levelKey struct{} | |
M internal/adapters/logging/logging_test.go +6 -1
| @@ -168,8 +168,13 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) { | |||
| 168 | 168 | ||
| 169 | 169 | // Narrow categories are on only when named, never through "all". | |
| 170 | 170 | func TestEnabledCategories(t *testing.T) { | |
| 171 | - | for _, category := range []string{"input-motion", "input-keys", "drm-flip"} { | |
| 171 | + | // The component each narrow category logs under; "" is not asserted. | |
| 172 | + | owners := map[string]string{"input-motion": "", "input-keys": "", "drm-flip": "", "wayland-damage": "wayland", "render-damage": "render"} | |
| 173 | + | for category, owner := range owners { | |
| 172 | 174 | component := categories[category] | |
| 175 | + | if owner != "" && component != owner { | |
| 176 | + | t.Errorf("%s belongs to %q, want %q", category, component, owner) | |
| 177 | + | } | |
| 173 | 178 | for _, tc := range []struct { | |
| 174 | 179 | debug string | |
| 175 | 180 | want bool | |
A internal/adapters/outputkit/corewake/corewake.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // Package corewake is how an output asks core for the next frame of a running | |
| 2 | + | // animation. | |
| 3 | + | package corewake | |
| 4 | + | ||
| 5 | + | import "github.com/bnema/neferwl/internal/ports" | |
| 6 | + | ||
| 7 | + | // Send tells core the next frame of a running animation of output is due. | |
| 8 | + | // A frame core has not read yet is enough, so the owner never waits: a full | |
| 9 | + | // channel drops the wake. A nil ch (tests, nobody to wake) does nothing. | |
| 10 | + | // It allocates nothing. | |
| 11 | + | func Send(ch chan<- ports.OutputFrame, output string) { | |
| 12 | + | if ch == nil { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | select { | |
| 16 | + | case ch <- ports.OutputFrame{Output: output}: | |
| 17 | + | default: | |
| 18 | + | } | |
| 19 | + | } | |
A internal/adapters/outputkit/corewake/corewake_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package corewake | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Send never blocks: a frame core has not read is enough. | |
| 11 | + | func TestSend(t *testing.T) { | |
| 12 | + | ch := make(chan ports.OutputFrame, 1) | |
| 13 | + | Send(ch, "DP-1") | |
| 14 | + | Send(ch, "DP-1") // full: dropped, not blocked | |
| 15 | + | require.Equal(t, []ports.OutputFrame{{Output: "DP-1"}}, []ports.OutputFrame{<-ch}) | |
| 16 | + | require.Empty(t, ch) | |
| 17 | + | require.NotPanics(t, func() { Send(nil, "DP-1") }) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestSendAllocations(t *testing.T) { | |
| 21 | + | ch := make(chan ports.OutputFrame, 1) | |
| 22 | + | require.Zero(t, testing.AllocsPerRun(100, func() { | |
| 23 | + | Send(ch, "DP-1") | |
| 24 | + | select { | |
| 25 | + | case <-ch: | |
| 26 | + | default: | |
| 27 | + | } | |
| 28 | + | Send(ch, "DP-1") | |
| 29 | + | })) | |
| 30 | + | } | |
M internal/adapters/outputkit/presented/queue.go +20 -48
| @@ -3,22 +3,19 @@ | |||
| 3 | 3 | package presented | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "context" | |
| 7 | 6 | "maps" | |
| 8 | - | "slices" | |
| 9 | 7 | ||
| 10 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 11 | 9 | ) | |
| 12 | 10 | ||
| 13 | - | // MaxUnsent bounds reports waiting for wayland; past it the oldest flip is | |
| 14 | - | // merged into the next one. | |
| 15 | - | const MaxUnsent = 16 | |
| 16 | - | ||
| 17 | - | // Queue holds reports wayland has not read yet. Its owner goroutine is the | |
| 18 | - | // only user. The Seen and ChildReads maps of a queued report are immutable | |
| 19 | - | // snapshots: wayland may still read an earlier report on another goroutine, | |
| 20 | - | // so a snapshot is replaced by a clone when its content changes and an | |
| 21 | - | // unchanged report allocates nothing. | |
| 11 | + | // Queue holds the reports an output has not pushed to wayland yet. The owner | |
| 12 | + | // goroutine flushes it every loop turn, so it holds the few reports of one | |
| 13 | + | // turn; the sink alone bounds what waits for the display loop (and merges | |
| 14 | + | // flips when that stalls). Its owner goroutine is the only user. The Seen and | |
| 15 | + | // ChildReads maps of a queued report are immutable snapshots: wayland may | |
| 16 | + | // still read an earlier report on another goroutine, so a snapshot is | |
| 17 | + | // replaced by a clone when its content changes and an unchanged report | |
| 18 | + | // allocates nothing. | |
| 22 | 19 | type Queue struct { | |
| 23 | 20 | unsent []ports.OutputPresented | |
| 24 | 21 | seen, reads map[ports.WindowID]uint64 | |
| @@ -26,9 +23,8 @@ type Queue struct { | |||
| 26 | 23 | ||
| 27 | 24 | // Push queues r. Its Seen and ChildReads may be the caller's scratch maps: | |
| 28 | 25 | // the queue keeps snapshots. A flip-less report replaces the newest queued | |
| 29 | - | // one when that one has no flip, or keeps its flip; flips are merged only | |
| 30 | - | // past MaxUnsent. Push reports whether a flip was merged that way. | |
| 31 | - | func (q *Queue) Push(r ports.OutputPresented) (merged bool) { | |
| 26 | + | // one when that one has no flip, or keeps its flip; every flip is queued. | |
| 27 | + | func (q *Queue) Push(r ports.OutputPresented) { | |
| 32 | 28 | if !maps.Equal(q.seen, r.Seen) { | |
| 33 | 29 | q.seen = maps.Clone(r.Seen) | |
| 34 | 30 | } | |
| @@ -41,22 +37,15 @@ func (q *Queue) Push(r ports.OutputPresented) (merged bool) { | |||
| 41 | 37 | r.Flip = q.unsent[n-1].Flip | |
| 42 | 38 | } | |
| 43 | 39 | q.unsent[n-1] = r | |
| 44 | - | return false | |
| 40 | + | return | |
| 45 | 41 | } | |
| 46 | 42 | q.unsent = append(q.unsent, r) | |
| 47 | - | if len(q.unsent) <= MaxUnsent { | |
| 48 | - | return false | |
| 49 | - | } | |
| 50 | - | if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil { | |
| 51 | - | mergeFlip(old, next) | |
| 52 | - | } | |
| 53 | - | q.unsent = slices.Delete(q.unsent, 0, 1) | |
| 54 | - | return true | |
| 55 | 43 | } | |
| 56 | 44 | ||
| 57 | - | // mergeFlip folds old into next: next counts it as merged and shows what | |
| 45 | + | // Merge folds old into next: next counts it as merged and shows what | |
| 58 | 46 | // old showed, so feedbacks waiting on old are answered (as discarded). | |
| 59 | - | func mergeFlip(old, next *ports.FlipInfo) { | |
| 47 | + | // Wayland's presentation sink uses it when its ring overflows. | |
| 48 | + | func Merge(old, next *ports.FlipInfo) { | |
| 60 | 49 | next.Merged += 1 + old.Merged | |
| 61 | 50 | // A snapshot can be shared with previously sent flips. Clone only when | |
| 62 | 51 | // a merge actually needs to add an absent window. | |
| @@ -76,30 +65,13 @@ func mergeFlip(old, next *ports.FlipInfo) { | |||
| 76 | 65 | } | |
| 77 | 66 | } | |
| 78 | 67 | ||
| 79 | - | // Flush sends queued reports without blocking. | |
| 80 | - | func (q *Queue) Flush(ch chan<- ports.OutputPresented) { | |
| 81 | - | for len(q.unsent) > 0 { | |
| 82 | - | select { | |
| 83 | - | case ch <- q.unsent[0]: | |
| 84 | - | q.unsent = slices.Delete(q.unsent, 0, 1) | |
| 85 | - | default: | |
| 86 | - | return | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Drain sends every queued report, blocking until wayland reads them or ctx | |
| 92 | - | // ends. | |
| 93 | - | func (q *Queue) Drain(ctx context.Context, ch chan<- ports.OutputPresented) error { | |
| 94 | - | for len(q.unsent) > 0 { | |
| 95 | - | select { | |
| 96 | - | case ch <- q.unsent[0]: | |
| 97 | - | q.unsent = slices.Delete(q.unsent, 0, 1) | |
| 98 | - | case <-ctx.Done(): | |
| 99 | - | return ctx.Err() | |
| 100 | - | } | |
| 68 | + | // Flush pushes every queued report to sink, oldest first, and empties the | |
| 69 | + | // queue. Push never blocks. | |
| 70 | + | func (q *Queue) Flush(sink ports.PresentationSink) { | |
| 71 | + | for _, r := range q.unsent { | |
| 72 | + | sink.Push(r) | |
| 101 | 73 | } | |
| 102 | - | return nil | |
| 74 | + | q.Reset() | |
| 103 | 75 | } | |
| 104 | 76 | ||
| 105 | 77 | // Len is the number of queued reports. | |
M internal/adapters/outputkit/presented/queue_test.go +31 -28
| @@ -1,11 +1,12 @@ | |||
| 1 | 1 | package presented | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "reflect" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| 7 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -55,44 +56,46 @@ func TestQueueKeepsEveryFlip(t *testing.T) { | |||
| 55 | 56 | require.Equal(t, uint64(3), u[1].Seen[1]) | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 58 | - | // Past MaxUnsent the oldest flip folds into the next: counted as merged and | |
| 59 | - | // its shown windows carried, without mutating a shared Shows snapshot. | |
| 60 | - | func TestQueueMergesOldestFlipPastBound(t *testing.T) { | |
| 61 | - | var q Queue | |
| 59 | + | // Merge folds a flip into the next: counted as merged and its shown windows | |
| 60 | + | // carried, without mutating a shared Shows snapshot. | |
| 61 | + | func TestMergeCarriesShowsWithoutMutatingSharedSnapshot(t *testing.T) { | |
| 62 | 62 | shared := map[ports.WindowID]uint64{2: 5} | |
| 63 | - | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1, Shows: map[ports.WindowID]uint64{1: 4}}}) | |
| 64 | - | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2, Shows: shared}}) | |
| 65 | - | merged := false | |
| 66 | - | for i := range MaxUnsent - 1 { | |
| 67 | - | merged = q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: uint64(10 + i)}}) || merged | |
| 63 | + | old := &ports.FlipInfo{Seq: 1, Merged: 2, Shows: map[ports.WindowID]uint64{1: 4}} | |
| 64 | + | next := &ports.FlipInfo{Seq: 2, Shows: shared} | |
| 65 | + | Merge(old, next) | |
| 66 | + | require.Equal(t, 3, next.Merged) | |
| 67 | + | require.Equal(t, map[ports.WindowID]uint64{1: 4, 2: 5}, next.Shows) | |
| 68 | + | require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // The queue never merges flips: every flip of a turn is pushed to the sink. | |
| 72 | + | func TestQueueDoesNotBoundFlips(t *testing.T) { | |
| 73 | + | var q Queue | |
| 74 | + | for i := range 40 { | |
| 75 | + | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: uint64(i + 1)}}) | |
| 68 | 76 | } | |
| 69 | - | require.True(t, merged) | |
| 70 | 77 | u := q.Unsent() | |
| 71 | - | require.Len(t, u, MaxUnsent) | |
| 72 | - | require.Equal(t, uint64(2), u[0].Flip.Seq) | |
| 73 | - | require.Equal(t, 1, u[0].Flip.Merged) | |
| 74 | - | require.Equal(t, map[ports.WindowID]uint64{1: 4, 2: 5}, u[0].Flip.Shows) | |
| 75 | - | require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched") | |
| 78 | + | require.Len(t, u, 40) | |
| 79 | + | require.Zero(t, u[0].Flip.Merged) | |
| 80 | + | require.Equal(t, uint64(1), u[0].Flip.Seq) | |
| 76 | 81 | } | |
| 77 | 82 | ||
| 78 | - | func TestQueueFlushStopsWhenFull(t *testing.T) { | |
| 83 | + | func TestQueueFlushPushesInOrderAndEmpties(t *testing.T) { | |
| 79 | 84 | var q Queue | |
| 80 | 85 | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}}) | |
| 81 | 86 | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}}) | |
| 82 | - | ch := make(chan ports.OutputPresented, 1) | |
| 83 | - | q.Flush(ch) | |
| 84 | - | require.Equal(t, 1, q.Len()) | |
| 85 | - | require.Equal(t, uint64(1), (<-ch).Flip.Seq) | |
| 86 | - | require.NoError(t, q.Drain(context.Background(), ch)) | |
| 87 | + | var got []ports.OutputPresented | |
| 88 | + | sink := portsmocks.NewMockPresentationSink(t) | |
| 89 | + | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) }) | |
| 90 | + | q.Flush(sink) | |
| 87 | 91 | require.Zero(t, q.Len()) | |
| 88 | - | require.Equal(t, uint64(2), (<-ch).Flip.Seq) | |
| 92 | + | require.Len(t, got, 2) | |
| 93 | + | require.Equal(t, uint64(1), got[0].Flip.Seq) | |
| 94 | + | require.Equal(t, uint64(2), got[1].Flip.Seq) | |
| 95 | + | q.Flush(sink) // empty: nothing more is pushed | |
| 96 | + | require.Len(t, got, 2) | |
| 89 | 97 | ||
| 90 | 98 | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 3}}) | |
| 91 | - | q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 4}}) | |
| 92 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 93 | - | cancel() | |
| 94 | - | ch <- ports.OutputPresented{} | |
| 95 | - | require.ErrorIs(t, q.Drain(ctx, ch), context.Canceled) | |
| 96 | 99 | q.Reset() | |
| 97 | 100 | require.Zero(t, q.Len()) | |
| 98 | 101 | } | |
M internal/adapters/outputkit/surfaces/surfaces.go +69 -5
| @@ -4,6 +4,8 @@ | |||
| 4 | 4 | package surfaces | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | + | "maps" | |
| 8 | + | ||
| 7 | 9 | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 8 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 11 | ) | |
| @@ -106,13 +108,75 @@ func Shown(scene ports.Scene, id ports.WindowID, kept func(ports.WindowID) bool) | |||
| 106 | 108 | return scene.Shows(id) && (kept == nil || !kept(id)) | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | - | // FillShown clears dst and fills it with the windows of seen that scene | |
| 110 | - | // showed to their client (Shown), at their seen Seq. | |
| 111 | - | func FillShown(dst, seen map[ports.WindowID]uint64, scene ports.Scene, kept func(ports.WindowID) bool) { | |
| 112 | - | clear(dst) | |
| 111 | + | // Apply takes every content of b into the table, as the output's owner does | |
| 112 | + | // when it receives a batch: Update per item against scene, seen advanced to | |
| 113 | + | // the highest Seq per window, then the batch reset and handed back through | |
| 114 | + | // ret. Reset-then-return is the ownership rule of ports.ContentBatch; b is | |
| 115 | + | // not the caller's after Apply. | |
| 116 | + | // | |
| 117 | + | // dirty reports a content of a window scene draws (windows on other outputs | |
| 118 | + | // or workspaces need no frame). seenChanged reports a window whose seen Seq | |
| 119 | + | // moved forward: a new report is due. | |
| 120 | + | func (t *Table) Apply(b *ports.ContentBatch, scene ports.Scene, seen map[ports.WindowID]uint64, ret ports.ContentReturn) (dirty, seenChanged bool) { | |
| 121 | + | for i := range b.Items { | |
| 122 | + | c := b.Items[i] | |
| 123 | + | t.Update(c, scene) | |
| 124 | + | if c.Seq > seen[c.ID] { | |
| 125 | + | seen[c.ID] = c.Seq | |
| 126 | + | seenChanged = true | |
| 127 | + | } | |
| 128 | + | dirty = dirty || capture.Shows(scene, c.ID) | |
| 129 | + | } | |
| 130 | + | b.Reset() | |
| 131 | + | ret.Return(b) | |
| 132 | + | return dirty, seenChanged | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // ShowsSnapshot builds the Shows of successive flips. Its owner goroutine is | |
| 136 | + | // the only user. A flip's Shows map is an immutable snapshot: wayland may | |
| 137 | + | // still read the one of an earlier flip on another goroutine, so the scratch | |
| 138 | + | // map is never sent and a snapshot is replaced by a clone only when it | |
| 139 | + | // changed. The zero value is ready; an unchanged result allocates nothing. | |
| 140 | + | type ShowsSnapshot struct { | |
| 141 | + | scratch, snapshot map[ports.WindowID]uint64 | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // FromSeen returns the windows of seen that scene showed to their client | |
| 145 | + | // (Shown), at their seen Seq. The result is shared with earlier flips of the | |
| 146 | + | // same content: never modify it. | |
| 147 | + | func (s *ShowsSnapshot) FromSeen(scene ports.Scene, seen map[ports.WindowID]uint64, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 { | |
| 148 | + | s.begin() | |
| 113 | 149 | for id, seq := range seen { | |
| 114 | 150 | if Shown(scene, id, kept) { | |
| 115 | - | dst[id] = seq | |
| 151 | + | s.scratch[id] = seq | |
| 116 | 152 | } | |
| 117 | 153 | } | |
| 154 | + | return s.settle() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // FromContents returns the windows of contents that scene showed to their | |
| 158 | + | // client (Shown), at their content Seq. The result is shared with earlier | |
| 159 | + | // flips of the same content: never modify it. | |
| 160 | + | func (s *ShowsSnapshot) FromContents(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 { | |
| 161 | + | s.begin() | |
| 162 | + | for id, c := range contents { | |
| 163 | + | if Shown(scene, id, kept) { | |
| 164 | + | s.scratch[id] = c.Seq | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return s.settle() | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (s *ShowsSnapshot) begin() { | |
| 171 | + | if s.scratch == nil { | |
| 172 | + | s.scratch = make(map[ports.WindowID]uint64) | |
| 173 | + | } | |
| 174 | + | clear(s.scratch) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (s *ShowsSnapshot) settle() map[ports.WindowID]uint64 { | |
| 178 | + | if s.snapshot == nil || !maps.Equal(s.snapshot, s.scratch) { | |
| 179 | + | s.snapshot = maps.Clone(s.scratch) | |
| 180 | + | } | |
| 181 | + | return s.snapshot | |
| 118 | 182 | } | |
M internal/adapters/outputkit/surfaces/surfaces_test.go +64 -5
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -125,14 +126,72 @@ func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) { | |||
| 125 | 126 | ||
| 126 | 127 | // Both backends report a window shown only when the scene draws it and its | |
| 127 | 128 | // content is not kept. | |
| 128 | - | func TestFillShown(t *testing.T) { | |
| 129 | + | func TestShowsSnapshotSkipsKept(t *testing.T) { | |
| 129 | 130 | kept := func(id ports.WindowID) bool { return id == 2 } | |
| 130 | - | dst := map[ports.WindowID]uint64{9: 9} | |
| 131 | - | FillShown(dst, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, shows(1, 2), kept) | |
| 132 | - | if len(dst) != 1 || dst[1] != 3 { | |
| 133 | - | t.Fatalf("shown %v", dst) | |
| 131 | + | var snap ShowsSnapshot | |
| 132 | + | got := snap.FromSeen(shows(1, 2), map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, kept) | |
| 133 | + | if len(got) != 1 || got[1] != 3 { | |
| 134 | + | t.Fatalf("shown from seen %v", got) | |
| 135 | + | } | |
| 136 | + | var fromContents ShowsSnapshot | |
| 137 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Seq: 3}, 2: {ID: 2, Seq: 4}, 3: {ID: 3, Seq: 5}} | |
| 138 | + | got = fromContents.FromContents(shows(1, 2), contents, kept) | |
| 139 | + | if len(got) != 1 || got[1] != 3 { | |
| 140 | + | t.Fatalf("shown from contents %v", got) | |
| 134 | 141 | } | |
| 135 | 142 | if !Shown(shows(2), 2, nil) { | |
| 136 | 143 | t.Fatal("nil kept counts nothing as kept") | |
| 137 | 144 | } | |
| 138 | 145 | } | |
| 146 | + | ||
| 147 | + | // An unchanged snapshot is reused without allocating; a changed one never | |
| 148 | + | // mutates a map already handed out. | |
| 149 | + | func TestShowsSnapshotImmutable(t *testing.T) { | |
| 150 | + | var snap ShowsSnapshot | |
| 151 | + | scene := shows(1) | |
| 152 | + | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |
| 153 | + | first := snap.FromSeen(scene, seen, nil) | |
| 154 | + | if allocs := testing.AllocsPerRun(100, func() { snap.FromSeen(scene, seen, nil) }); allocs != 0 { | |
| 155 | + | t.Errorf("unchanged shows: %.1f allocs, want 0", allocs) | |
| 156 | + | } | |
| 157 | + | seen[1] = 3 | |
| 158 | + | second := snap.FromSeen(scene, seen, nil) | |
| 159 | + | if first[1] != 1 || second[1] != 3 { | |
| 160 | + | t.Fatalf("snapshot mutated: old %v new %v", first, second) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Apply takes the batch into the table, advances seen, reports what needs a | |
| 165 | + | // frame or a report, then resets the batch before giving it back. | |
| 166 | + | func TestApply(t *testing.T) { | |
| 167 | + | tb := New() | |
| 168 | + | seen := map[ports.WindowID]uint64{1: 5} | |
| 169 | + | b := &ports.ContentBatch{Items: []ports.SurfaceContent{ | |
| 170 | + | {ID: 1, Seq: 4, SHM: &ports.SHMBuffer{Pool: 1}}, // older than seen | |
| 171 | + | {ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, | |
| 172 | + | }} | |
| 173 | + | ret := portsmocks.NewMockContentReturn(t) | |
| 174 | + | ret.EXPECT().Return(b).Run(func(got *ports.ContentBatch) { | |
| 175 | + | if len(got.Items) != 0 { | |
| 176 | + | t.Errorf("batch returned before its reset: %d items", len(got.Items)) | |
| 177 | + | } | |
| 178 | + | }).Once() | |
| 179 | + | dirty, changed := tb.Apply(b, shows(2), seen, ret) | |
| 180 | + | if !dirty || !changed { | |
| 181 | + | t.Fatalf("dirty %v changed %v, want both", dirty, changed) | |
| 182 | + | } | |
| 183 | + | if seen[1] != 5 || seen[2] != 1 { | |
| 184 | + | t.Fatalf("seen %v", seen) | |
| 185 | + | } | |
| 186 | + | if len(tb.Map()) != 2 { | |
| 187 | + | t.Fatalf("table %v", tb.Map()) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // A window no scene draws and no newer Seq: nothing to do. | |
| 191 | + | b = &ports.ContentBatch{Items: []ports.SurfaceContent{{ID: 1, Seq: 5, SHM: &ports.SHMBuffer{Pool: 1}}}} | |
| 192 | + | ret.EXPECT().Return(b).Once() | |
| 193 | + | dirty, changed = tb.Apply(b, shows(2), seen, ret) | |
| 194 | + | if dirty || changed { | |
| 195 | + | t.Fatalf("dirty %v changed %v, want neither", dirty, changed) | |
| 196 | + | } | |
| 197 | + | } | |
M internal/adapters/vulkan/compose.go +4 -3
| @@ -413,12 +413,13 @@ func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]ui | |||
| 413 | 413 | clear[i] = math.Float32bits(float32(srgbToLinear(float64(bg[i]) / 255))) | |
| 414 | 414 | } | |
| 415 | 415 | } | |
| 416 | - | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear} | |
| 416 | + | attachment, info := &r.call.attach, &r.call.render | |
| 417 | + | *attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear} | |
| 417 | 418 | if keep { | |
| 418 | 419 | attachment.LoadOp = vk.AttachmentLoadOpLoad | |
| 419 | 420 | } | |
| 420 | - | info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | |
| 421 | - | d.CmdBeginRendering(cmd, &info) | |
| 421 | + | *info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: attachment} | |
| 422 | + | d.CmdBeginRendering(cmd, info) | |
| 422 | 423 | pipeline := c.pipeline | |
| 423 | 424 | if r.hdrNits > 0 { | |
| 424 | 425 | pipeline = c.hdrPipeline | |
M internal/adapters/vulkan/damage.go +2 -0
| @@ -265,6 +265,8 @@ var ( | |||
| 265 | 265 | // separator present in only one scene adds its own. A new Scene field | |
| 266 | 266 | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | |
| 267 | 267 | // new SceneWindow field is checked by TestSceneWindowFieldCount. | |
| 268 | + | // Scene.Animating is not compared: it schedules the next frame, it draws | |
| 269 | + | // nothing. | |
| 268 | 270 | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | |
| 269 | 271 | if old.Security != cur.Security || old.Output != cur.Output || | |
| 270 | 272 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
M internal/adapters/vulkan/damage_delta_test.go +3 -1
| @@ -17,7 +17,9 @@ import ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | - | const fields = 21 | |
| 20 | + | // 22 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | + | // schedules the next frame, it draws nothing. | |
| 22 | + | const fields = 22 | |
| 21 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 22 | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 23 | 25 | } | |
M internal/adapters/vulkan/frame.go +47 -17
| @@ -32,6 +32,27 @@ type frameSlot struct { | |||
| 32 | 32 | busy bool // submitted, fence not yet seen signalled | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | // callScratch holds the structs Render and its record helpers pass to Vulkan | |
| 36 | + | // by pointer. The purego call makes a pointer to a local escape, so a local | |
| 37 | + | // would be a heap allocation per frame; these live in the Renderer instead | |
| 38 | + | // (owned by one goroutine, and Vulkan reads each only during its call). Every | |
| 39 | + | // field is set in full before use; two uses that overlap in time never share a | |
| 40 | + | // field. | |
| 41 | + | type callScratch struct { | |
| 42 | + | cmd vk.CommandBuffer | |
| 43 | + | begin vk.CommandBufferBeginInfo | |
| 44 | + | submit vk.SubmitInfo | |
| 45 | + | target vk.ImageMemoryBarrier // the render target's, kept to the frame's end | |
| 46 | + | client vk.ImageMemoryBarrier // a client image's (ownership) | |
| 47 | + | attach vk.RenderingAttachmentInfo | |
| 48 | + | render vk.RenderingInfo | |
| 49 | + | getFD vk.SemaphoreGetFdInfoKHR | |
| 50 | + | fd int32 | |
| 51 | + | hdrIn vk.ImageMemoryBarrier // recordHDR | |
| 52 | + | hdrOut vk.ImageMemoryBarrier | |
| 53 | + | hdrNits float32 | |
| 54 | + | } | |
| 55 | + | ||
| 35 | 56 | // deferredFree runs once frame completed. | |
| 36 | 57 | type deferredFree struct { | |
| 37 | 58 | frame uint64 | |
| @@ -186,7 +207,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 186 | 207 | dmg.finish() | |
| 187 | 208 | r.dropPools() | |
| 188 | 209 | partial := !dmg.all() | |
| 210 | + | redrawn := sw * sh | |
| 189 | 211 | if partial { | |
| 212 | + | redrawn = dmg.area.Dx() * dmg.area.Dy() | |
| 190 | 213 | // The pass keeps the image: repaint the background under the | |
| 191 | 214 | // region, then everything clipped to it. | |
| 192 | 215 | // Keep the background first without allocating a second draw slice. | |
| @@ -195,23 +218,27 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 195 | 218 | ds[0] = r.fillDraw(dmg.area, parseColor(s.Background)) | |
| 196 | 219 | ds = dmg.clip(ds) | |
| 197 | 220 | r.scratchDraws = ds | |
| 198 | - | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | |
| 199 | - | } else { | |
| 200 | - | r.redrawn += sw * sh | |
| 221 | + | } | |
| 222 | + | r.redrawn += redrawn | |
| 223 | + | if r.traceDamage { | |
| 224 | + | r.log.Debug().Int("redrawn", redrawn).Int("draws", len(ds)).Bool("partial", partial).Msg("frame damage") | |
| 201 | 225 | } | |
| 202 | 226 | r.orient(ds, s.Transform) | |
| 203 | 227 | d := r.dd | |
| 228 | + | sc := &r.call | |
| 204 | 229 | cmd := slot.cmd | |
| 230 | + | sc.cmd = cmd | |
| 205 | 231 | if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil { | |
| 206 | 232 | return nil, err | |
| 207 | 233 | } | |
| 208 | - | begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 209 | - | if err := checked("vkBeginCommandBuffer", d.BeginCommandBuffer(cmd, &begin)); err != nil { | |
| 234 | + | sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 235 | + | if err := checked("vkBeginCommandBuffer", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil { | |
| 210 | 236 | return nil, err | |
| 211 | 237 | } | |
| 212 | 238 | // A full redraw clears the target: its old contents never matter | |
| 213 | 239 | // (UNDEFINED). A partial one keeps them. | |
| 214 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: tg.image, SubresourceRange: colorRange} | |
| 240 | + | b := &sc.target | |
| 241 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: tg.image, SubresourceRange: colorRange} | |
| 215 | 242 | if partial { | |
| 216 | 243 | b.OldLayout = tg.layout | |
| 217 | 244 | b.DstAccessMask |= vk.AccessColorAttachmentReadBit | |
| @@ -220,7 +247,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 220 | 247 | // Acquire from the display (foreign queue family). | |
| 221 | 248 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family | |
| 222 | 249 | } | |
| 223 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |
| 250 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) | |
| 224 | 251 | // Client buffers come from the foreign queue family (their driver) | |
| 225 | 252 | // and go back to it after the frame, so the next frame acquires them. | |
| 226 | 253 | dmas := r.frameDMAs[:0] | |
| @@ -252,10 +279,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 252 | 279 | // Release to the display: KMS scans it out after the fence. | |
| 253 | 280 | b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0 | |
| 254 | 281 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 255 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 282 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b) | |
| 256 | 283 | } else { | |
| 257 | 284 | b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit | |
| 258 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 285 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b) | |
| 259 | 286 | } | |
| 260 | 287 | } | |
| 261 | 288 | if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil { | |
| @@ -279,7 +306,8 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 279 | 306 | sem := sem | |
| 280 | 307 | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | |
| 281 | 308 | } | |
| 282 | - | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | |
| 309 | + | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | |
| 310 | + | submit := &sc.submit | |
| 283 | 311 | if cap(r.frameStages) < len(waits) { | |
| 284 | 312 | r.frameStages = make([]vk.PipelineStageFlags, len(waits)) | |
| 285 | 313 | } | |
| @@ -293,7 +321,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 293 | 321 | if r.syncFD { | |
| 294 | 322 | submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &slot.done | |
| 295 | 323 | } | |
| 296 | - | if err := checked("vkQueueSubmit", d.QueueSubmit(r.queue, 1, &submit, slot.fence)); err != nil { | |
| 324 | + | if err := checked("vkQueueSubmit", d.QueueSubmit(r.queue, 1, submit, slot.fence)); err != nil { | |
| 297 | 325 | return nil, err | |
| 298 | 326 | } | |
| 299 | 327 | runtime.KeepAlive(stages) | |
| @@ -318,9 +346,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 318 | 346 | if !r.syncFD { | |
| 319 | 347 | return nil, r.waitFrame(frame) | |
| 320 | 348 | } | |
| 321 | - | var fd int32 | |
| 322 | - | get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: slot.done, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 323 | - | if d.GetSemaphoreFdKHR(r.device, &get, &fd) != vk.Success { | |
| 349 | + | sc.fd = 0 | |
| 350 | + | sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: slot.done, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 351 | + | if d.GetSemaphoreFdKHR(r.device, &sc.getFD, &sc.fd) != vk.Success { | |
| 324 | 352 | // The frame is drawn: finish it on the CPU. The semaphore stays | |
| 325 | 353 | // signalled: replace it, so the slot's next submit signals a | |
| 326 | 354 | // fresh one. | |
| @@ -331,6 +359,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 331 | 359 | slot.done = 0 | |
| 332 | 360 | return nil, r.newDone(slot) | |
| 333 | 361 | } | |
| 362 | + | fd := sc.fd | |
| 334 | 363 | if fd < 0 { | |
| 335 | 364 | // Already signalled: the driver may return -1. | |
| 336 | 365 | return nil, r.waitFrame(frame) | |
| @@ -346,7 +375,8 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 346 | 375 | // family, GENERAL: their contents survive) and the fragment shader. | |
| 347 | 376 | func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) { | |
| 348 | 377 | for _, im := range dmas { | |
| 349 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange} | |
| 378 | + | b := &r.call.client | |
| 379 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange} | |
| 350 | 380 | if im.yuv { | |
| 351 | 381 | b.SubresourceRange.AspectMask = aspectPlane0 | aspectPlane1 | |
| 352 | 382 | } | |
| @@ -356,12 +386,12 @@ func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire boo | |||
| 356 | 386 | b.DstAccessMask = vk.AccessShaderReadBit | |
| 357 | 387 | // The frame waits on the client's fence at the fragment | |
| 358 | 388 | // stage: the transfer must come after that wait. | |
| 359 | - | r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &b) | |
| 389 | + | r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, b) | |
| 360 | 390 | } else { | |
| 361 | 391 | b.OldLayout, b.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutGeneral | |
| 362 | 392 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 363 | 393 | b.SrcAccessMask = vk.AccessShaderReadBit | |
| 364 | - | r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 394 | + | r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b) | |
| 365 | 395 | } | |
| 366 | 396 | } | |
| 367 | 397 | } | |
M internal/adapters/vulkan/hdr.go +13 -11
| @@ -172,26 +172,28 @@ func (r *Renderer) destroyHDR() { | |||
| 172 | 172 | // recordHDR converts the fully composed linear HDR image in the same command buffer. | |
| 173 | 173 | func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) { | |
| 174 | 174 | d, h := r.dd, &r.hdr | |
| 175 | - | in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | |
| 176 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | |
| 177 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange} | |
| 178 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |
| 179 | - | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 180 | - | info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | |
| 181 | - | d.CmdBeginRendering(cmd, &info) | |
| 175 | + | in, b := &r.call.hdrIn, &r.call.hdrOut | |
| 176 | + | attachment, info := &r.call.attach, &r.call.render | |
| 177 | + | *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | |
| 178 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in) | |
| 179 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange} | |
| 180 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) | |
| 181 | + | *attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 182 | + | *info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: attachment} | |
| 183 | + | d.CmdBeginRendering(cmd, info) | |
| 182 | 184 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline) | |
| 183 | 185 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil) | |
| 184 | - | nits := float32(r.hdrNits) | |
| 185 | - | d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits)) | |
| 186 | + | r.call.hdrNits = float32(r.hdrNits) | |
| 187 | + | d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&r.call.hdrNits)) | |
| 186 | 188 | d.CmdDraw(cmd, 3, 1, 0, 0) | |
| 187 | 189 | d.CmdEndRendering(cmd) | |
| 188 | 190 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0 | |
| 189 | 191 | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral | |
| 190 | 192 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 191 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 193 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b) | |
| 192 | 194 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | |
| 193 | 195 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | |
| 194 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in) | |
| 196 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in) | |
| 195 | 197 | } | |
| 196 | 198 | ||
| 197 | 199 | // dropHDROwn frees the HDR composition image with the capture pass that | |
M internal/adapters/vulkan/renderer.go +8 -0
| @@ -41,6 +41,8 @@ type Renderer struct { | |||
| 41 | 41 | modPlanes map[uint64]uint32 | |
| 42 | 42 | // log reports the chosen target modifier; the zero value discards. | |
| 43 | 43 | log zerowrap.Logger | |
| 44 | + | // traceDamage logs every frame's redrawn area (--debug=render-damage). | |
| 45 | + | traceDamage bool | |
| 44 | 46 | // hdrReadback makes exported HDR targets transfer sources, for GPU tests | |
| 45 | 47 | // and headless --screenshot-raw (SetHDRReadback); never DRM. | |
| 46 | 48 | hdrReadback bool | |
| @@ -108,6 +110,8 @@ type Renderer struct { | |||
| 108 | 110 | frameAcquires map[*imported]*os.File | |
| 109 | 111 | frameWaits []vk.Semaphore | |
| 110 | 112 | frameStages []vk.PipelineStageFlags | |
| 113 | + | // call holds the structs a per-frame Vulkan call is handed by pointer. | |
| 114 | + | call callScratch | |
| 111 | 115 | } | |
| 112 | 116 | ||
| 113 | 117 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | |
| @@ -359,6 +363,10 @@ func (r *Renderer) TakeRedrawn() int { | |||
| 359 | 363 | // events. Not on the port; the zero logger discards. | |
| 360 | 364 | func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l } | |
| 361 | 365 | ||
| 366 | + | // SetTraceDamage logs the redrawn pixels, draw count and partial flag of | |
| 367 | + | // every frame at debug level (--debug=render-damage). Off by default. | |
| 368 | + | func (r *Renderer) SetTraceDamage(on bool) { r.traceDamage = on } | |
| 369 | + | ||
| 362 | 370 | // QueuePriority is the global priority the queue got: realtime, high or | |
| 363 | 371 | // default. | |
| 364 | 372 | func (r *Renderer) QueuePriority() string { return r.queuePriority } | |
M internal/adapters/vulkan/scene_perf_test.go +53 -2
| @@ -3,6 +3,7 @@ package vulkan | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "image/color" | |
| 6 | + | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -40,8 +41,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 40 | 41 | } | |
| 41 | 42 | } | |
| 42 | 43 | frame() | |
| 43 | - | if allocs := testing.AllocsPerRun(20, frame); allocs > 13 { | |
| 44 | - | t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs) | |
| 44 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 3 { | |
| 45 | + | t.Errorf("small steady-state Render: %.1f allocs/frame, want <=3 (syncFD path: %v)", allocs, r.syncFD) | |
| 45 | 46 | } else { | |
| 46 | 47 | t.Logf("small steady-state Render: %.1f allocs/frame", allocs) | |
| 47 | 48 | } | |
| @@ -136,6 +137,56 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 136 | 137 | } | |
| 137 | 138 | } | |
| 138 | 139 | ||
| 140 | + | // A steady frame that draws a client dmabuf also records the ownership | |
| 141 | + | // barriers (acquire and release) of its image, which allocate nothing. The | |
| 142 | + | // frame still allocates more than the SHM scene: importing the client's | |
| 143 | + | // read fence and its retire closure (about 4 per frame). Skipped without a | |
| 144 | + | // GPU or /dev/udmabuf. | |
| 145 | + | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |
| 146 | + | r, err := New(80, 32) | |
| 147 | + | if err != nil { | |
| 148 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 149 | + | } | |
| 150 | + | defer r.Close() | |
| 151 | + | linear := ports.DMABufFormat{Format: fourcc('A', 'R', '2', '4'), Modifier: 0} | |
| 152 | + | if !slices.Contains(r.DMABuf().Formats, linear) { | |
| 153 | + | t.Skipf("linear ARGB8888 not importable") | |
| 154 | + | } | |
| 155 | + | f := udmabuf(t, 64, 16, func(int, int) [4]byte { return [4]byte{0, 0, 255, 255} }) | |
| 156 | + | buf := &ports.DMABuf{ID: 7, Width: 64, Height: 16, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | |
| 157 | + | scene := ports.Scene{Seq: 1, Background: "#000000", Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 64, H: 16}}}} | |
| 158 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Surface: 1, Seq: 1, Version: 1, Width: 64, Height: 16, DMABuf: buf}} | |
| 159 | + | frame := func() { | |
| 160 | + | if err := render(r, scene, contents); err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | frame() | |
| 165 | + | if len(r.imports) != 1 { | |
| 166 | + | t.Fatalf("imports %d: the frame does not take the dmabuf path", len(r.imports)) | |
| 167 | + | } | |
| 168 | + | // Each new scene Seq draws the dmabuf again, with its barriers. | |
| 169 | + | var moving [4]ports.Scene | |
| 170 | + | for i := range moving { | |
| 171 | + | moving[i] = scene | |
| 172 | + | moving[i].Seq = uint64(10 + i) | |
| 173 | + | moving[i].Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4 + i, Y: 4, W: 64, H: 16}}} | |
| 174 | + | } | |
| 175 | + | n := 0 | |
| 176 | + | moved := func() { | |
| 177 | + | n++ | |
| 178 | + | if err := render(r, moving[n%len(moving)], contents); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | moved() | |
| 183 | + | allocs := testing.AllocsPerRun(20, moved) | |
| 184 | + | t.Logf("dmabuf Render: %.1f allocs/frame", allocs) | |
| 185 | + | if allocs > 7 { | |
| 186 | + | t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 139 | 190 | // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged | |
| 140 | 191 | // subsurface tree. Each child has its own real wl_shm pool. | |
| 141 | 192 | func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |
M internal/adapters/wayland/alloc_hotpath_test.go +11 -37
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -24,7 +23,12 @@ func TestReleaseHeldAllocations(t *testing.T) { | |||
| 24 | 23 | } | |
| 25 | 24 | ||
| 26 | 25 | func TestCommitDamageAllocations(t *testing.T) { | |
| 27 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 26 | + | if raceEnabled { | |
| 27 | + | // AllocsPerRun counts every goroutine, and the race runtime | |
| 28 | + | // allocates on its own now and then. perf-check runs it without. | |
| 29 | + | t.Skip("the race detector allocates in the background") | |
| 30 | + | } | |
| 31 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}} | |
| 28 | 32 | surf := &surface{server: srv} | |
| 29 | 33 | commit := func() { | |
| 30 | 34 | surf.Damage(nil, 0, 0, 4, 4) | |
| @@ -55,7 +59,7 @@ func TestCommitDamageAllocations(t *testing.T) { | |||
| 55 | 59 | func TestCommitSyncStorageIsPerUpdate(t *testing.T) { | |
| 56 | 60 | dev := newMocksyncobjDevice(t) | |
| 57 | 61 | dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil) | |
| 58 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 62 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}} | |
| 59 | 63 | sw, err := newSyncWaiter(func() {}) | |
| 60 | 64 | if err != nil { | |
| 61 | 65 | t.Fatal(err) | |
| @@ -88,18 +92,16 @@ func TestCommitSyncStorageIsPerUpdate(t *testing.T) { | |||
| 88 | 92 | ||
| 89 | 93 | func TestEmitContentAllocations(t *testing.T) { | |
| 90 | 94 | s := &Server{ | |
| 91 | - | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |
| 92 | - | contents: map[ports.WindowID]ports.SurfaceContent{}, | |
| 93 | - | contentSeq: map[ports.WindowID]uint64{}, | |
| 94 | - | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 95 | - | contentReady: make(chan struct{}, 1), | |
| 95 | + | contentSeq: map[ports.WindowID]uint64{}, | |
| 96 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 97 | + | latest: map[ports.WindowID]ports.SurfaceContent{}, | |
| 96 | 98 | } | |
| 97 | 99 | c := ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}} | |
| 98 | 100 | var held []ports.SurfaceContent // readers retaining published histories | |
| 99 | 101 | var snapshots [][]ports.SeqDamage | |
| 100 | 102 | for range 3 * damageHistoryCap { | |
| 101 | 103 | s.emitContent(c, damage{full: true}) | |
| 102 | - | got := s.contents[1] | |
| 104 | + | got := s.latest[1] | |
| 103 | 105 | held = append(held, got) | |
| 104 | 106 | snapshots = append(snapshots, append([]ports.SeqDamage(nil), got.DamageHistory...)) | |
| 105 | 107 | } | |
| @@ -123,31 +125,3 @@ func TestEmitContentAllocations(t *testing.T) { | |||
| 123 | 125 | } | |
| 124 | 126 | t.Logf("emitContent: %.2f allocs", allocs) | |
| 125 | 127 | } | |
| 126 | - | ||
| 127 | - | // A forwarder blocked on a stale frame is woken by the next publication | |
| 128 | - | // and sends the newer frame instead. | |
| 129 | - | func TestForwardContentsSendsNewerFrame(t *testing.T) { | |
| 130 | - | contents := make(chan ports.SurfaceContent) | |
| 131 | - | s := &Server{ | |
| 132 | - | channels: Channels{Contents: contents}, | |
| 133 | - | contents: map[ports.WindowID]ports.SurfaceContent{}, | |
| 134 | - | contentSeq: map[ports.WindowID]uint64{}, | |
| 135 | - | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 136 | - | contentReady: make(chan struct{}, 1), | |
| 137 | - | } | |
| 138 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 139 | - | done := make(chan struct{}) | |
| 140 | - | go func() { defer close(done); s.forwardContents(ctx) }() | |
| 141 | - | defer func() { cancel(); <-done }() | |
| 142 | - | s.emitContent(ports.SurfaceContent{ID: 1, Seq: 1, Solid: &ports.SolidColor{}}, damage{full: true}) | |
| 143 | - | time.Sleep(10 * time.Millisecond) // let the forwarder block on seq 1 | |
| 144 | - | s.emitContent(ports.SurfaceContent{ID: 1, Seq: 2, Solid: &ports.SolidColor{}}, damage{full: true}) | |
| 145 | - | select { | |
| 146 | - | case c := <-contents: | |
| 147 | - | if c.Seq != 2 { | |
| 148 | - | t.Fatalf("stale frame %d forwarded, want 2", c.Seq) | |
| 149 | - | } | |
| 150 | - | case <-time.After(2 * time.Second): | |
| 151 | - | t.Fatal("no frame forwarded") | |
| 152 | - | } | |
| 153 | - | } | |
M internal/adapters/wayland/commit_lifecycle_test.go +4 -4
| @@ -18,7 +18,7 @@ import ( | |||
| 18 | 18 | // Applying an older captured commit must never transiently own newer requests. | |
| 19 | 19 | // The next commit captures those requests without losing its one-shot state. | |
| 20 | 20 | func TestCapturedCommitKeepsLaterRequests(t *testing.T) { | |
| 21 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}} | |
| 21 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, constraints: map[*surface]*constraint{}} | |
| 22 | 22 | surf := &surface{server: srv, xdg: &xdgSurface{}} | |
| 23 | 23 | cons := &constraint{surface: surf} | |
| 24 | 24 | srv.constraints[surf] = cons | |
| @@ -313,7 +313,7 @@ func TestQueuedDestructionDropsUpdates(t *testing.T) { | |||
| 313 | 313 | ||
| 314 | 314 | // A dropped update must be cleared before a different surface reuses it. | |
| 315 | 315 | func TestDroppedUpdateRecycledWithoutState(t *testing.T) { | |
| 316 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 316 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}} | |
| 317 | 317 | old := &surface{server: srv, next: pendingCommit{scale: 2, barrier: true, damage: []ports.Rect{{W: 2}}}} | |
| 318 | 318 | old.queueUpdate() | |
| 319 | 319 | old.destroyed = true | |
| @@ -329,7 +329,7 @@ func TestDroppedUpdateRecycledWithoutState(t *testing.T) { | |||
| 329 | 329 | // BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's | |
| 330 | 330 | // in-process queue/apply loop. It excludes protocol dispatch and real buffers. | |
| 331 | 331 | func BenchmarkEmptySurfaceQueueApply(b *testing.B) { | |
| 332 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 332 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}} | |
| 333 | 333 | surf := &surface{server: srv} | |
| 334 | 334 | surf.queueUpdate() | |
| 335 | 335 | surf.queue[0].applyGraph() | |
| @@ -381,7 +381,7 @@ func TestCapturedViewportApplyAllocations(t *testing.T) { | |||
| 381 | 381 | } | |
| 382 | 382 | ||
| 383 | 383 | func TestCapturedCommitApplyAllocations(t *testing.T) { | |
| 384 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 384 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}} | |
| 385 | 385 | surf := &surface{server: srv} | |
| 386 | 386 | apply := func() { | |
| 387 | 387 | surf.queueUpdate() | |
A internal/adapters/wayland/contentlink.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // batchesPerLink is how many batches circulate between wayland and one | |
| 11 | + | // output: one wayland fills, one waits in the channel, one the output reads. | |
| 12 | + | const batchesPerLink = 3 | |
| 13 | + | ||
| 14 | + | // batchItems is the initial capacity of a batch: windows beyond it grow the | |
| 15 | + | // batch once, and it keeps the capacity. | |
| 16 | + | const batchItems = 8 | |
| 17 | + | ||
| 18 | + | // contentLink is the content path to one running output. The display | |
| 19 | + | // goroutine owns cur and the sending side of in; the output owns the batch it | |
| 20 | + | // received until it returns it. free holds the batches nobody uses: it has | |
| 21 | + | // room for all of them, so Return never blocks. | |
| 22 | + | // | |
| 23 | + | // The link is also the ports.ContentReturn handed to the output, and the | |
| 24 | + | // handle DetachOutput takes: a detach removes this link and no other, even | |
| 25 | + | // when a later attach of the same name replaced it. | |
| 26 | + | type contentLink struct { | |
| 27 | + | s *Server | |
| 28 | + | name string | |
| 29 | + | in chan *ports.ContentBatch | |
| 30 | + | free chan *ports.ContentBatch | |
| 31 | + | cur *ports.ContentBatch | |
| 32 | + | // pending: cur holds contents that could not be sent yet (the channel is | |
| 33 | + | // full, or every batch is out). Return only wakes the display when it is | |
| 34 | + | // set, so a steady stream of frames costs the display no extra wake. | |
| 35 | + | pending atomic.Bool | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Return gives a consumed (reset) batch back and, when contents wait for one, | |
| 39 | + | // has the display goroutine send them. Safe from any goroutine, also after | |
| 40 | + | // the link was detached: the batch only goes back to its own link. | |
| 41 | + | func (l *contentLink) Return(b *ports.ContentBatch) { | |
| 42 | + | l.free <- b // never full: the link owns batchesPerLink batches | |
| 43 | + | // The display stores pending before it looks at free once more, so | |
| 44 | + | // either it sees this batch or this load sees pending. | |
| 45 | + | if l.pending.Load() && l.s.flushPosted.CompareAndSwap(false, true) { | |
| 46 | + | l.s.display.Post(l.s.flushLinksFn) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // add puts c in the batch being filled, replacing the window's earlier | |
| 51 | + | // content: the output only needs the newest one (see ports.ContentBatch). | |
| 52 | + | func (l *contentLink) add(c ports.SurfaceContent) { | |
| 53 | + | items := l.cur.Items | |
| 54 | + | for i := range items { | |
| 55 | + | if items[i].ID == c.ID { | |
| 56 | + | items[i] = c | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | l.cur.Items = append(items, c) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // try sends the batch being filled when the channel is empty and a batch is | |
| 64 | + | // free to fill next. Display goroutine only. | |
| 65 | + | func (l *contentLink) try() bool { | |
| 66 | + | if len(l.in) != 0 { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | select { | |
| 70 | + | case next := <-l.free: | |
| 71 | + | l.in <- l.cur // empty, and only this goroutine sends | |
| 72 | + | l.cur = next | |
| 73 | + | return true | |
| 74 | + | default: | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // flushLink sends l's batch when the output can take it; otherwise l keeps | |
| 80 | + | // filling it and asks Return for a wake. | |
| 81 | + | func (s *Server) flushLink(l *contentLink) { | |
| 82 | + | if len(l.cur.Items) == 0 { | |
| 83 | + | l.pending.Store(false) | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | if l.try() { | |
| 87 | + | l.pending.Store(false) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | l.pending.Store(true) | |
| 91 | + | if l.try() { // a Return may have slipped in before pending was set | |
| 92 | + | l.pending.Store(false) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // flushLinks retries every link that waits for a free batch. Display | |
| 97 | + | // goroutine; posted by Return. | |
| 98 | + | func (s *Server) flushLinks() { | |
| 99 | + | s.flushPosted.Store(false) | |
| 100 | + | for _, l := range s.links { | |
| 101 | + | if l.pending.Load() { | |
| 102 | + | s.flushLink(l) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // attachOutput starts feeding the output called name; a link of the same name | |
| 108 | + | // is replaced (dropped from the feed: its handle then detaches nothing). The | |
| 109 | + | // returned ContentReturn is the link, and the handle for detachOutput. The | |
| 110 | + | // first batch holds the latest content of every live window, | |
| 111 | + | // so the output starts with all of them drawn. Display goroutine, or before | |
| 112 | + | // Run. | |
| 113 | + | func (s *Server) attachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |
| 114 | + | for i, old := range s.links { | |
| 115 | + | if old.name == name { | |
| 116 | + | s.links = slices.Delete(s.links, i, i+1) | |
| 117 | + | break | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | l := &contentLink{ | |
| 121 | + | s: s, | |
| 122 | + | name: name, | |
| 123 | + | in: make(chan *ports.ContentBatch, 1), | |
| 124 | + | free: make(chan *ports.ContentBatch, batchesPerLink), | |
| 125 | + | } | |
| 126 | + | for range batchesPerLink - 1 { | |
| 127 | + | l.free <- &ports.ContentBatch{Items: make([]ports.SurfaceContent, 0, batchItems)} | |
| 128 | + | } | |
| 129 | + | l.cur = &ports.ContentBatch{Items: make([]ports.SurfaceContent, 0, max(batchItems, len(s.latest)))} | |
| 130 | + | for _, c := range s.latest { | |
| 131 | + | l.add(c) | |
| 132 | + | } | |
| 133 | + | s.links = append(s.links, l) | |
| 134 | + | s.flushLink(l) | |
| 135 | + | return l.in, l | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // detachOutput stops feeding the link ret, the handle attachOutput returned. | |
| 139 | + | // A handle whose link was replaced by a later attach of its name, or that is | |
| 140 | + | // not a link, detaches nothing: a newer link is never removed. Display | |
| 141 | + | // goroutine, or before Run. The output may still Return its last batch. | |
| 142 | + | func (s *Server) detachOutput(ret ports.ContentReturn) { | |
| 143 | + | l, ok := ret.(*contentLink) | |
| 144 | + | if !ok { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | if i := slices.Index(s.links, l); i >= 0 { | |
| 148 | + | s.links = slices.Delete(s.links, i, i+1) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // AttachOutput starts feeding the output called name with the contents of | |
| 153 | + | // the windows, as batches on the returned channel, starting with the current | |
| 154 | + | // content of every window. The output hands each batch back, reset, to the | |
| 155 | + | // returned ContentReturn, which is also the handle for DetachOutput. | |
| 156 | + | // Attaching a name that already has a feed replaces that feed. Call it from | |
| 157 | + | // outside the display goroutine. It returns a nil channel and handle when | |
| 158 | + | // the server is stopped. | |
| 159 | + | func (s *Server) AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |
| 160 | + | var in <-chan *ports.ContentBatch | |
| 161 | + | var ret ports.ContentReturn | |
| 162 | + | if !s.display.Do(func() { in, ret = s.attachOutput(name) }) { | |
| 163 | + | return nil, nil | |
| 164 | + | } | |
| 165 | + | return in, ret | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // DetachOutput stops the feed ret, returned by AttachOutput. Only that feed | |
| 169 | + | // stops: if a later AttachOutput of the same name replaced it, the newer | |
| 170 | + | // feed keeps running. Call it from outside the display goroutine, once the | |
| 171 | + | // output no longer reads its channel. A nil ret does nothing. | |
| 172 | + | func (s *Server) DetachOutput(ret ports.ContentReturn) { | |
| 173 | + | if ret == nil { | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | s.display.Do(func() { s.detachOutput(ret) }) | |
| 177 | + | } | |
A internal/adapters/wayland/contentlink_test.go +355 -0
| @@ -0,0 +1,355 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // linkServer is a running server with no client, to publish contents on its | |
| 14 | + | // display goroutine. | |
| 15 | + | func linkServer(t *testing.T) *Server { | |
| 16 | + | t.Helper() | |
| 17 | + | s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland")) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 22 | + | done := make(chan error, 1) | |
| 23 | + | go func() { done <- s.Run(ctx) }() | |
| 24 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 25 | + | return s | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func solid(id ports.WindowID) ports.SurfaceContent { | |
| 29 | + | return ports.SurfaceContent{ID: id, Solid: &ports.SolidColor{}} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (s *Server) publish(t *testing.T, contents ...ports.SurfaceContent) { | |
| 33 | + | t.Helper() | |
| 34 | + | if !s.display.Do(func() { | |
| 35 | + | for _, c := range contents { | |
| 36 | + | s.emitContent(c, damage{full: true}) | |
| 37 | + | } | |
| 38 | + | }) { | |
| 39 | + | t.Fatal("display stopped") | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func nextBatch(t *testing.T, in <-chan *ports.ContentBatch) *ports.ContentBatch { | |
| 44 | + | t.Helper() | |
| 45 | + | select { | |
| 46 | + | case b := <-in: | |
| 47 | + | return b | |
| 48 | + | case <-time.After(2 * time.Second): | |
| 49 | + | t.Fatal("no batch") | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // noBatch asserts that no batch is on the channel once the display goroutine | |
| 55 | + | // went through everything posted so far (a Return's flush included): sends | |
| 56 | + | // happen only there, so there is nothing left to wait for. | |
| 57 | + | func noBatch(t *testing.T, s *Server, in <-chan *ports.ContentBatch) { | |
| 58 | + | t.Helper() | |
| 59 | + | s.display.Do(func() {}) | |
| 60 | + | select { | |
| 61 | + | case b := <-in: | |
| 62 | + | t.Fatalf("unexpected batch %+v", b.Items) | |
| 63 | + | default: | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Contents published while the output has not taken the previous batch | |
| 68 | + | // replace each other per window: the output gets the first batch, then one | |
| 69 | + | // with only the newest content, once it returned the first. (Ordering per | |
| 70 | + | // window holds because a batch holds one entry per window and batches are | |
| 71 | + | // FIFO; the release accounting needs only the newest Seq.) | |
| 72 | + | func TestContentBatchKeepsLatestPerWindow(t *testing.T) { | |
| 73 | + | s := linkServer(t) | |
| 74 | + | in, ret := s.AttachOutput("A") | |
| 75 | + | s.publish(t, solid(1)) // sent at once | |
| 76 | + | s.publish(t, solid(1), solid(2), solid(1)) // wait for the output | |
| 77 | + | first := nextBatch(t, in) | |
| 78 | + | if len(first.Items) != 1 || first.Items[0].ID != 1 || first.Items[0].Seq != 1 { | |
| 79 | + | t.Fatalf("first batch %+v", first.Items) | |
| 80 | + | } | |
| 81 | + | noBatch(t, s, in) // nothing moves before the output returns a batch... | |
| 82 | + | first.Reset() | |
| 83 | + | ret.Return(first) // ...which sends the waiting contents | |
| 84 | + | second := nextBatch(t, in) | |
| 85 | + | if len(second.Items) != 2 { | |
| 86 | + | t.Fatalf("second batch %+v", second.Items) | |
| 87 | + | } | |
| 88 | + | for _, c := range second.Items { | |
| 89 | + | switch c.ID { | |
| 90 | + | case 1: | |
| 91 | + | if c.Seq != 3 { | |
| 92 | + | t.Errorf("window 1 seq %d, want the newest (3)", c.Seq) | |
| 93 | + | } | |
| 94 | + | case 2: | |
| 95 | + | if c.Seq != 1 { | |
| 96 | + | t.Errorf("window 2 seq %d, want 1", c.Seq) | |
| 97 | + | } | |
| 98 | + | default: | |
| 99 | + | t.Errorf("unexpected window %d", c.ID) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // An output attached later starts with the latest content of every live | |
| 105 | + | // window and nothing for windows that were emptied. | |
| 106 | + | func TestContentBatchAttachSeedsLiveWindows(t *testing.T) { | |
| 107 | + | s := linkServer(t) | |
| 108 | + | s.publish(t, solid(1), solid(2), solid(3), ports.SurfaceContent{ID: 2}, solid(1)) | |
| 109 | + | in, _ := s.AttachOutput("A") | |
| 110 | + | b := nextBatch(t, in) | |
| 111 | + | got := map[ports.WindowID]uint64{} | |
| 112 | + | for _, c := range b.Items { | |
| 113 | + | got[c.ID] = c.Seq | |
| 114 | + | } | |
| 115 | + | if len(got) != 2 || len(b.Items) != 2 || got[1] != 2 || got[3] != 1 { | |
| 116 | + | t.Fatalf("seed batch %+v", b.Items) | |
| 117 | + | } | |
| 118 | + | // An attach with nothing to show sends nothing. | |
| 119 | + | s.display.Do(func() { clear(s.latest) }) | |
| 120 | + | in2, _ := s.AttachOutput("B") | |
| 121 | + | noBatch(t, s, in2) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // An empty content reaches the attached outputs (it releases the window's | |
| 125 | + | // buffers) and removes the window from what a later output starts with. | |
| 126 | + | func TestContentBatchDeliversEmpty(t *testing.T) { | |
| 127 | + | s := linkServer(t) | |
| 128 | + | in, ret := s.AttachOutput("A") | |
| 129 | + | s.publish(t, solid(1)) | |
| 130 | + | b := nextBatch(t, in) | |
| 131 | + | b.Reset() | |
| 132 | + | ret.Return(b) | |
| 133 | + | s.publish(t, ports.SurfaceContent{ID: 1}) | |
| 134 | + | b = nextBatch(t, in) | |
| 135 | + | if len(b.Items) != 1 || b.Items[0].ID != 1 || !b.Items[0].Empty() || b.Items[0].Seq != 2 { | |
| 136 | + | t.Fatalf("empty not delivered: %+v", b.Items) | |
| 137 | + | } | |
| 138 | + | var left int | |
| 139 | + | s.display.Do(func() { left = len(s.latest) }) | |
| 140 | + | if left != 0 { | |
| 141 | + | t.Fatalf("latest keeps %d emptied windows", left) | |
| 142 | + | } | |
| 143 | + | // Superseded by an empty before the output looked: the empty wins. | |
| 144 | + | b.Reset() | |
| 145 | + | ret.Return(b) | |
| 146 | + | s.publish(t, solid(1)) // sent | |
| 147 | + | s.publish(t, solid(1), ports.SurfaceContent{ID: 1}) // pending: last one is empty | |
| 148 | + | first := nextBatch(t, in) | |
| 149 | + | first.Reset() | |
| 150 | + | ret.Return(first) | |
| 151 | + | b = nextBatch(t, in) | |
| 152 | + | if len(b.Items) != 1 || !b.Items[0].Empty() || b.Items[0].Seq != 5 { | |
| 153 | + | t.Fatalf("emptied window: %+v", b.Items) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Every attached output gets its own batches; detaching one stops its feed | |
| 158 | + | // only, and its output may still Return what it holds. | |
| 159 | + | func TestContentBatchDetach(t *testing.T) { | |
| 160 | + | s := linkServer(t) | |
| 161 | + | inA, retA := s.AttachOutput("A") | |
| 162 | + | inB, _ := s.AttachOutput("B") | |
| 163 | + | s.publish(t, solid(1)) | |
| 164 | + | a, b := nextBatch(t, inA), nextBatch(t, inB) | |
| 165 | + | if len(a.Items) != 1 || len(b.Items) != 1 || a == b { | |
| 166 | + | t.Fatalf("batches %+v %+v", a.Items, b.Items) | |
| 167 | + | } | |
| 168 | + | s.DetachOutput(retA) | |
| 169 | + | // Waiting contents of a detached link are never sent; Return is safe. | |
| 170 | + | a.Reset() | |
| 171 | + | retA.Return(a) | |
| 172 | + | s.publish(t, solid(1)) | |
| 173 | + | noBatch(t, s, inA) | |
| 174 | + | if got := nextBatch(t, inB); len(got.Items) != 1 { | |
| 175 | + | t.Fatalf("B batch %+v", got.Items) | |
| 176 | + | } | |
| 177 | + | // Attaching under a known name replaces the link. | |
| 178 | + | inA2, _ := s.AttachOutput("A") | |
| 179 | + | if got := nextBatch(t, inA2); len(got.Items) != 1 { | |
| 180 | + | t.Fatalf("re-attached batch %+v", got.Items) | |
| 181 | + | } | |
| 182 | + | var links int | |
| 183 | + | s.display.Do(func() { links = len(s.links) }) | |
| 184 | + | if links != 2 { | |
| 185 | + | t.Fatalf("%d links, want 2", links) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Detaching a feed by its handle never removes a newer feed of the same | |
| 190 | + | // name: A attached again replaced the first link, and detaching the old | |
| 191 | + | // handle leaves the new one fed. | |
| 192 | + | func TestDetachOldHandleKeepsReplacedLink(t *testing.T) { | |
| 193 | + | s := linkServer(t) | |
| 194 | + | _, oldRet := s.AttachOutput("A") | |
| 195 | + | in, _ := s.AttachOutput("A") | |
| 196 | + | s.DetachOutput(oldRet) | |
| 197 | + | var links int | |
| 198 | + | s.display.Do(func() { links = len(s.links) }) | |
| 199 | + | if links != 1 { | |
| 200 | + | t.Fatalf("%d links, want the new one only", links) | |
| 201 | + | } | |
| 202 | + | s.publish(t, solid(1)) | |
| 203 | + | if b := nextBatch(t, in); len(b.Items) != 1 { | |
| 204 | + | t.Fatalf("new link batch %+v", b.Items) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Emit, send, consume and return a batch without allocating once the | |
| 209 | + | // batches have their capacity. The consumer runs inline, like an output that | |
| 210 | + | // keeps up. | |
| 211 | + | func TestContentBatchCycleAllocations(t *testing.T) { | |
| 212 | + | s := &Server{ | |
| 213 | + | contentSeq: map[ports.WindowID]uint64{}, | |
| 214 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 215 | + | latest: map[ports.WindowID]ports.SurfaceContent{}, | |
| 216 | + | } | |
| 217 | + | in, ret := s.attachOutput("A") | |
| 218 | + | c := solid(1) | |
| 219 | + | other := solid(2) | |
| 220 | + | cycle := func() { | |
| 221 | + | for _, c := range []ports.SurfaceContent{c, other} { | |
| 222 | + | s.emitContent(c, damage{full: true}) | |
| 223 | + | select { | |
| 224 | + | case b := <-in: | |
| 225 | + | for i := range b.Items { | |
| 226 | + | _ = b.Items[i].Seq | |
| 227 | + | } | |
| 228 | + | b.Reset() | |
| 229 | + | ret.Return(b) | |
| 230 | + | default: | |
| 231 | + | panic("no batch sent") | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | for range 3 * damageHistoryCap { | |
| 236 | + | cycle() | |
| 237 | + | } | |
| 238 | + | if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 { | |
| 239 | + | t.Errorf("content batch cycle: %.2f allocs, want 0", allocs) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // A late-attached output gets all the live windows in one batch, however | |
| 244 | + | // many there are, and AttachOutput returns without the output reading. | |
| 245 | + | func TestContentBatchAttachReplaysLargeSet(t *testing.T) { | |
| 246 | + | s := linkServer(t) | |
| 247 | + | const windows = 100 | |
| 248 | + | all := make([]ports.SurfaceContent, 0, windows) | |
| 249 | + | for id := 1; id <= windows; id++ { | |
| 250 | + | all = append(all, solid(ports.WindowID(id))) | |
| 251 | + | } | |
| 252 | + | s.publish(t, all...) | |
| 253 | + | attached := make(chan (<-chan *ports.ContentBatch), 1) | |
| 254 | + | go func() { in, _ := s.AttachOutput("A"); attached <- in }() | |
| 255 | + | var in <-chan *ports.ContentBatch | |
| 256 | + | select { | |
| 257 | + | case in = <-attached: | |
| 258 | + | case <-time.After(2 * time.Second): | |
| 259 | + | t.Fatal("AttachOutput waits for the output to read") | |
| 260 | + | } | |
| 261 | + | b := nextBatch(t, in) | |
| 262 | + | ids := map[ports.WindowID]struct{}{} | |
| 263 | + | for _, c := range b.Items { | |
| 264 | + | ids[c.ID] = struct{}{} | |
| 265 | + | } | |
| 266 | + | if len(b.Items) != windows || len(ids) != windows { | |
| 267 | + | t.Fatalf("replay batch holds %d items, %d distinct windows, want %d", len(b.Items), len(ids), windows) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // The output returns batches in bursts and holds some while content keeps | |
| 272 | + | // coming, so the channel is full and no batch is free when emitContent runs; | |
| 273 | + | // Returns race with it from another goroutine. Whatever the interleaving, the | |
| 274 | + | // output ends up with the newest Seq of every window (the pending/flushPosted | |
| 275 | + | // handshake never strands content). Run with -race -count=20. | |
| 276 | + | func TestContentBatchHandshakeNeverStrandsContent(t *testing.T) { | |
| 277 | + | s := linkServer(t) | |
| 278 | + | in, ret := s.AttachOutput("A") | |
| 279 | + | const windows, rounds = 4, 3000 | |
| 280 | + | var last [windows + 1]atomic.Uint64 | |
| 281 | + | stop, ended := make(chan struct{}), make(chan struct{}) | |
| 282 | + | t.Cleanup(func() { close(stop); <-ended }) | |
| 283 | + | go func() { | |
| 284 | + | defer close(ended) | |
| 285 | + | var held []*ports.ContentBatch | |
| 286 | + | giveBack := func(n int) { | |
| 287 | + | for ; n > 0 && len(held) > 0; n-- { | |
| 288 | + | b := held[0] | |
| 289 | + | held = held[1:] | |
| 290 | + | b.Reset() | |
| 291 | + | ret.Return(b) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | for n := 0; ; n++ { | |
| 295 | + | var b *ports.ContentBatch | |
| 296 | + | select { | |
| 297 | + | case b = <-in: | |
| 298 | + | default: | |
| 299 | + | // Nothing waiting: hand everything back, as an idle | |
| 300 | + | // output does, then block. | |
| 301 | + | giveBack(len(held)) | |
| 302 | + | select { | |
| 303 | + | case b = <-in: | |
| 304 | + | case <-stop: | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | for _, c := range b.Items { | |
| 309 | + | if c.Seq > last[c.ID].Load() { | |
| 310 | + | last[c.ID].Store(c.Seq) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | held = append(held, b) | |
| 314 | + | if len(held) >= 2 { | |
| 315 | + | giveBack(1 + n%2) // a burst of one or two | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | }() | |
| 319 | + | for range rounds { | |
| 320 | + | s.publishRound(windows) | |
| 321 | + | } | |
| 322 | + | var want [windows + 1]uint64 | |
| 323 | + | s.display.Do(func() { | |
| 324 | + | for id := 1; id <= windows; id++ { | |
| 325 | + | want[id] = s.contentSeq[ports.WindowID(id)] | |
| 326 | + | } | |
| 327 | + | }) | |
| 328 | + | for deadline := time.Now().Add(5 * time.Second); ; { | |
| 329 | + | missing := 0 | |
| 330 | + | for id := 1; id <= windows; id++ { | |
| 331 | + | if last[id].Load() != want[id] { | |
| 332 | + | missing++ | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | if missing == 0 { | |
| 336 | + | return | |
| 337 | + | } | |
| 338 | + | if time.Now().After(deadline) { | |
| 339 | + | for id := 1; id <= windows; id++ { | |
| 340 | + | t.Errorf("window %d: output saw seq %d, newest is %d", id, last[id].Load(), want[id]) | |
| 341 | + | } | |
| 342 | + | t.Fatal("content stranded") | |
| 343 | + | } | |
| 344 | + | time.Sleep(time.Millisecond) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // publishRound publishes one content per window in one display turn. | |
| 349 | + | func (s *Server) publishRound(windows int) { | |
| 350 | + | s.display.Do(func() { | |
| 351 | + | for id := 1; id <= windows; id++ { | |
| 352 | + | s.emitContent(solid(ports.WindowID(id)), damage{full: true}) | |
| 353 | + | } | |
| 354 | + | }) | |
| 355 | + | } | |
A internal/adapters/wayland/contentrelay_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // relayContents attaches an output to s before Run and returns the contents it | |
| 10 | + | // receives, one by one. It consumes batches like an output does: apply, then | |
| 11 | + | // Return. The server must not be running yet. | |
| 12 | + | func relayContents(t *testing.T, s *Server) <-chan ports.SurfaceContent { | |
| 13 | + | t.Helper() | |
| 14 | + | in, ret := s.attachOutput("TEST-1") | |
| 15 | + | out := make(chan ports.SurfaceContent, 64) | |
| 16 | + | stop := make(chan struct{}) | |
| 17 | + | t.Cleanup(func() { close(stop) }) | |
| 18 | + | go func() { | |
| 19 | + | for { | |
| 20 | + | select { | |
| 21 | + | case <-stop: | |
| 22 | + | return | |
| 23 | + | case b := <-in: | |
| 24 | + | for _, c := range b.Items { | |
| 25 | + | select { | |
| 26 | + | case out <- c: | |
| 27 | + | case <-stop: | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | b.Reset() | |
| 32 | + | ret.Return(b) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | }() | |
| 36 | + | return out | |
| 37 | + | } | |
A internal/adapters/wayland/damage_log_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // --debug=wayland-damage logs one "client damage" entry per fresh commit | |
| 14 | + | // with the damage in buffer pixels; without it nothing is logged. | |
| 15 | + | func TestClientDamageLog(t *testing.T) { | |
| 16 | + | for _, on := range []bool{true, false} { | |
| 17 | + | t.Run(fmt.Sprintf("LogDamage=%v", on), func(t *testing.T) { | |
| 18 | + | var out lockedBuffer | |
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland") | |
| 20 | + | s, events, _, contents, dir := contentServerWith(t, Options{LogDamage: on}, log) | |
| 21 | + | c := protocolClient(t, s, dir) | |
| 22 | + | // A mapped window (its 1×1 first buffer is not under test), then | |
| 23 | + | // the 16×16 buffer the commits below attach. | |
| 24 | + | _, surf, _ := surfaceMapperAt(t, c, events, 4)() | |
| 25 | + | drainContents(contents) | |
| 26 | + | buf := shmBufferSized(t, c, 16, 16) | |
| 27 | + | commit := func(rects ...[4]int32) { | |
| 28 | + | t.Helper() | |
| 29 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 30 | + | for _, r := range rects { | |
| 31 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, r[0], r[1], r[2], r[3]) | |
| 32 | + | } | |
| 33 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 34 | + | if err := c.Roundtrip(); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | select { | |
| 38 | + | case <-contents: | |
| 39 | + | case <-time.After(2 * time.Second): | |
| 40 | + | t.Fatal("no content") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | // First buffer: a new size, full. Then a small rect, then one | |
| 44 | + | // covering the whole buffer. | |
| 45 | + | commit([4]int32{0, 0, 4, 4}) | |
| 46 | + | commit([4]int32{2, 3, 4, 5}) | |
| 47 | + | commit([4]int32{0, 0, 16, 16}) | |
| 48 | + | var damage []string | |
| 49 | + | for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") { | |
| 50 | + | if strings.Contains(l, `"client damage"`) { | |
| 51 | + | damage = append(damage, l) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | if !on { | |
| 55 | + | if len(damage) != 0 { | |
| 56 | + | t.Fatalf("logged with LogDamage off: %q", damage) | |
| 57 | + | } | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | // The mapping commit logged one entry of its own. | |
| 61 | + | if len(damage) != 4 { | |
| 62 | + | t.Fatalf("%d client damage entries, want 4 (mapping commit and 3 commits): %q", len(damage), damage) | |
| 63 | + | } | |
| 64 | + | damage = damage[1:] | |
| 65 | + | for i, want := range []string{`"full_buffer":true`, `"full_buffer":false`, `"full_buffer":true`} { | |
| 66 | + | if !strings.Contains(damage[i], want) { | |
| 67 | + | t.Errorf("entry %d lacks %s: %s", i, want, damage[i]) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | for _, want := range []string{`"component":"wayland"`, `"bw":16`, `"bh":16`, `"rects":1`, `"window":`, `"first_rects":[{"x":2,"y":3,"w":4,"h":5}]`} { | |
| 71 | + | if !strings.Contains(damage[1], want) { | |
| 72 | + | t.Errorf("entry 1 lacks %s: %s", want, damage[1]) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
M internal/adapters/wayland/dmabuf_test.go +3 -3
| @@ -15,15 +15,15 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | var linearARGB = ports.DMABufFormat{Format: 'A' | 'R'<<8 | '2'<<16 | '4'<<24} | |
| 17 | 17 | ||
| 18 | - | func dmabufServer(t *testing.T, sup ports.DMABufSupport) (*Server, chan ports.ClientEvent, chan ports.SurfaceContent, string) { | |
| 18 | + | func dmabufServer(t *testing.T, sup ports.DMABufSupport) (*Server, chan ports.ClientEvent, <-chan ports.SurfaceContent, string) { | |
| 19 | 19 | t.Helper() | |
| 20 | 20 | dir := t.TempDir() | |
| 21 | 21 | events := make(chan ports.ClientEvent, 16) | |
| 22 | - | contents := make(chan ports.SurfaceContent, 16) | |
| 23 | - | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland")) | |
| 22 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup}, Channels{Events: events}, logging.For(context.Background(), "wayland")) | |
| 24 | 23 | if err != nil { | |
| 25 | 24 | t.Fatal(err) | |
| 26 | 25 | } | |
| 26 | + | contents := relayContents(t, s) | |
| 27 | 27 | ctx, cancel := context.WithCancel(context.Background()) | |
| 28 | 28 | done := make(chan error, 1) | |
| 29 | 29 | go func() { done <- s.Run(ctx) }() | |
M internal/adapters/wayland/fifo.go +6 -6
| @@ -757,8 +757,7 @@ func sameBuffer(a, b *wayland.Buffer) bool { | |||
| 757 | 757 | ||
| 758 | 758 | // wakePacer makes the pacer look at its queues. Display goroutine only. | |
| 759 | 759 | // While a commit or a tick runs (paceDepth) it only records the request: the | |
| 760 | - | // outermost caller then ticks inline (paceNow), saving the round trip of | |
| 761 | - | // waking the pacer goroutine just so that it calls Do back. | |
| 760 | + | // outermost caller then ticks inline (paceNow), saving a posted tick. | |
| 762 | 761 | func (s *Server) wakePacer() { | |
| 763 | 762 | if s.paceDepth > 0 { | |
| 764 | 763 | s.paceDirty = true | |
| @@ -767,10 +766,11 @@ func (s *Server) wakePacer() { | |||
| 767 | 766 | s.signalPacer() | |
| 768 | 767 | } | |
| 769 | 768 | ||
| 770 | - | // signalPacer wakes the pacer goroutine; safe from any goroutine. | |
| 769 | + | // signalPacer posts a tick to the display loop; safe from any goroutine. | |
| 770 | + | // Signals coalesce until the tick runs. | |
| 771 | 771 | func (s *Server) signalPacer() { | |
| 772 | - | select { | |
| 773 | - | case s.frameReady <- struct{}{}: | |
| 774 | - | default: | |
| 772 | + | if !s.paceSignal.CompareAndSwap(false, true) { | |
| 773 | + | return | |
| 775 | 774 | } | |
| 775 | + | s.display.Post(s.paceSignalFn) | |
| 776 | 776 | } | |
M internal/adapters/wayland/fifo_test.go +11 -7
| @@ -29,7 +29,10 @@ func nextContent(t *testing.T, contents <-chan ports.SurfaceContent, id ports.Wi | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | // shmBuffer creates a 1x1 wl_shm buffer. | |
| 32 | - | func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 { | |
| 32 | + | func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 { return shmBufferSized(t, c, 1, 1) } | |
| 33 | + | ||
| 34 | + | // shmBufferSized is a w×h ARGB8888 wl_shm buffer. | |
| 35 | + | func shmBufferSized(t *testing.T, c *wlturbo.Display, w, h int32) uint32 { | |
| 33 | 36 | t.Helper() | |
| 34 | 37 | shm := bindProtocol(t, c, "wl_shm") | |
| 35 | 38 | registerProtocol(t, c, shm) | |
| @@ -38,16 +41,17 @@ func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 { | |||
| 38 | 41 | t.Fatal(err) | |
| 39 | 42 | } | |
| 40 | 43 | defer unix.Close(fd) | |
| 41 | - | if err := unix.Ftruncate(fd, 4); err != nil { | |
| 44 | + | size := w * h * 4 | |
| 45 | + | if err := unix.Ftruncate(fd, int64(size)); err != nil { | |
| 42 | 46 | t.Fatal(err) | |
| 43 | 47 | } | |
| 44 | 48 | pool, buffer := c.AllocateID(), c.AllocateID() | |
| 45 | 49 | registerProtocol(t, c, pool) | |
| 46 | 50 | registerProtocol(t, c, buffer) | |
| 47 | - | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | |
| 51 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | |
| 48 | 52 | t.Fatal(err) | |
| 49 | 53 | } | |
| 50 | - | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | |
| 54 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), w, h, w*4, uint32(0)) | |
| 51 | 55 | return buffer | |
| 52 | 56 | } | |
| 53 | 57 | ||
| @@ -154,7 +158,7 @@ func TestMonotonicTimeClamps(t *testing.T) { | |||
| 154 | 158 | // A barrier on an output that flips clears on its next flip. | |
| 155 | 159 | func TestTickFifoClearsBarrierOnFlip(t *testing.T) { | |
| 156 | 160 | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 157 | - | s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)} | |
| 161 | + | s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}} | |
| 158 | 162 | surf := &surface{server: s, xdg: &xdgSurface{window: &window{last: ports.ConfigureWindow{Output: "DP-2"}}}} | |
| 159 | 163 | now := time.Unix(100, 0) | |
| 160 | 164 | s.lastFlip["DP-2"] = now.Add(-time.Millisecond) | |
| @@ -217,7 +221,7 @@ func TestQueuedParentRetainsRetiredChild(t *testing.T) { | |||
| 217 | 221 | name = "dropped" | |
| 218 | 222 | } | |
| 219 | 223 | t.Run(name, func(t *testing.T) { | |
| 220 | - | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |
| 224 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{})} | |
| 221 | 225 | parent := &surface{server: srv} | |
| 222 | 226 | child := &surface{server: srv} | |
| 223 | 227 | child.sub.parent, child.sub.synced = parent, true | |
| @@ -277,7 +281,7 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) { | |||
| 277 | 281 | ||
| 278 | 282 | func TestInvisibleFifoPeriod(t *testing.T) { | |
| 279 | 283 | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 280 | - | s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)} | |
| 284 | + | s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}} | |
| 281 | 285 | w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}} | |
| 282 | 286 | surf := &surface{server: s, xdg: &xdgSurface{window: w}} | |
| 283 | 287 | now := time.Unix(100, 0) | |
M internal/adapters/wayland/frames.go +79 -62
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 4 | + | "errors" | |
| 5 | 5 | "slices" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| @@ -16,8 +16,10 @@ import ( | |||
| 16 | 16 | // frame. They are paced per output: on its page flip when it flips, else | |
| 17 | 17 | // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a | |
| 18 | 18 | // second and a V-Sync game follows the screen. Cursors and unplaced | |
| 19 | - | // surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. The | |
| 20 | - | // pacer sleeps while no callback waits. | |
| 19 | + | // surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. Pacing runs | |
| 20 | + | // on the display loop: output reports arrive through the presentation sink | |
| 21 | + | // (presented.go) and deadlines through a timerfd; the timer sleeps while no | |
| 22 | + | // callback waits. | |
| 21 | 23 | ||
| 22 | 24 | // defaultFramePeriod paces surfaces without an output (60 Hz). | |
| 23 | 25 | const defaultFramePeriod = time.Second / 60 | |
| @@ -25,69 +27,87 @@ const suspendedFramePeriod = time.Second | |||
| 25 | 27 | const suspendedFrameQueue = "\x00suspended" // never a wl_output name | |
| 26 | 28 | ||
| 27 | 29 | // maxPacePasses bounds the passes one tick repeats while a pass queues | |
| 28 | - | // more work; the rest goes back to the pacer goroutine. | |
| 30 | + | // more work; the rest is posted to the display loop. | |
| 29 | 31 | const maxPacePasses = 3 | |
| 30 | 32 | ||
| 31 | - | // pace sleeps until a flip, a wake or the deadline armed by the last tick, | |
| 32 | - | // then runs a tick on the display goroutine. The timer is only armed from | |
| 33 | - | // ticks, which all run there, so a commit that ticks inline (paceNow) never | |
| 34 | - | // races the pacer for it. | |
| 35 | - | func (s *Server) pace(ctx context.Context) { | |
| 36 | - | timer := s.paceTimer // created by Run before the pacer starts | |
| 37 | - | defer timer.Stop() | |
| 38 | - | presented := s.channels.Presented | |
| 39 | - | if s.flipped == nil { | |
| 40 | - | s.flipped = make(map[string]bool) | |
| 41 | - | } | |
| 42 | - | // One method value for the whole loop: no closure per wake. | |
| 43 | - | tick := s.paceFromPacer | |
| 44 | - | for { | |
| 45 | - | clear(s.flipped) | |
| 46 | - | flipped := s.flipped | |
| 47 | - | s.frameReports = s.frameReports[:0] | |
| 48 | - | reports := s.frameReports | |
| 49 | - | select { | |
| 50 | - | case <-ctx.Done(): | |
| 51 | - | return | |
| 52 | - | case <-s.display.Stopped(): | |
| 53 | - | return | |
| 54 | - | case p := <-presented: | |
| 55 | - | flipped[p.Output] = flipped[p.Output] || paces(p) | |
| 56 | - | reports = append(reports, p) | |
| 57 | - | case <-s.frameReady: | |
| 58 | - | case <-timer.C: | |
| 59 | - | } | |
| 60 | - | // Coalesce flips queued while the display was busy: one burst of | |
| 61 | - | // callbacks per output, not one per stale flip. | |
| 62 | - | drain: | |
| 63 | - | for { | |
| 64 | - | select { | |
| 65 | - | case p := <-presented: | |
| 66 | - | flipped[p.Output] = flipped[p.Output] || paces(p) | |
| 67 | - | reports = append(reports, p) | |
| 68 | - | default: | |
| 69 | - | break drain | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | s.frameReports = reports | |
| 73 | - | if !s.display.Do(tick) { | |
| 33 | + | // paceNow runs a tick from code already on the display goroutine (a client | |
| 34 | + | // commit). | |
| 35 | + | func (s *Server) paceNow() { s.paceTick(nil, nil) } | |
| 36 | + | ||
| 37 | + | // paceTolerance is how much earlier than the armed deadline a new one may be | |
| 38 | + | // and still reuse it: the ticks of one frame compute the same deadline from | |
| 39 | + | // clock reads a few microseconds apart. | |
| 40 | + | const paceTolerance = 50 * time.Microsecond | |
| 41 | + | ||
| 42 | + | // armPace schedules the next flip-less tick d from now on the pace timerfd | |
| 43 | + | // (nanosecond precision, one-shot). A deadline at most paceTolerance before | |
| 44 | + | // the armed one keeps the timer as it is: a frame's commit, flip and timer | |
| 45 | + | // ticks mostly end on the same deadline. The timer never fires before the | |
| 46 | + | // requested deadline, since the tick's checks are strict and an early tick | |
| 47 | + | // would push due work back by their minimum re-arm. The tolerance is measured | |
| 48 | + | // against the armed deadline, so it never accumulates. Display goroutine only. | |
| 49 | + | func (s *Server) armPace(d time.Duration) { s.armPaceFrom(time.Now(), d) } | |
| 50 | + | ||
| 51 | + | // armPaceFrom is armPace with the clock read at now. | |
| 52 | + | func (s *Server) armPaceFrom(now time.Time, d time.Duration) { | |
| 53 | + | deadline := now.Add(max(d, 0)) | |
| 54 | + | if s.paceArmed { | |
| 55 | + | if diff := deadline.Sub(s.paceDeadline); diff >= -paceTolerance && diff <= 0 { | |
| 74 | 56 | return | |
| 75 | 57 | } | |
| 76 | 58 | } | |
| 59 | + | spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(max(d, time.Nanosecond)))} | |
| 60 | + | err := unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | |
| 61 | + | s.warnPace("arm", err) | |
| 62 | + | // A failed arm leaves the old state unknown: arm again next time. | |
| 63 | + | s.paceArmed, s.paceDeadline = err == nil, deadline | |
| 77 | 64 | } | |
| 78 | 65 | ||
| 79 | - | // paceFromPacer runs the pacer's collected flips (flipped, frameReports) | |
| 80 | - | // on the display goroutine. | |
| 81 | - | func (s *Server) paceFromPacer() { s.paceTick(s.flipped, s.frameReports) } | |
| 66 | + | // stopPace disarms the pace timerfd, unless it is not armed (never armed, or | |
| 67 | + | // its one-shot expiry was consumed by onPaceTimer). Display goroutine only. | |
| 68 | + | func (s *Server) stopPace() { | |
| 69 | + | if !s.paceArmed { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | var spec unix.ItimerSpec | |
| 73 | + | err := unix.TimerfdSettime(s.paceFD, 0, &spec, nil) | |
| 74 | + | s.warnPace("stop", err) | |
| 75 | + | // A failed stop keeps the state: the next stop tries again. | |
| 76 | + | s.paceArmed = err != nil | |
| 77 | + | } | |
| 82 | 78 | ||
| 83 | - | // paceNow runs a tick from code already on the display goroutine (a client | |
| 84 | - | // commit): the pacer goroutine would only call Do to get back here. Without | |
| 85 | - | // a running pacer (no timer) it falls back to waking it. | |
| 86 | - | func (s *Server) paceNow() { | |
| 87 | - | if s.paceTimer == nil { | |
| 88 | - | s.signalPacer() | |
| 79 | + | // warnPace logs the first failure of a pace timerfd operation (later ones | |
| 80 | + | // would repeat at frame rate). A failed arm leaves frame callbacks waiting | |
| 81 | + | // for the next commit or flip. Display goroutine only. | |
| 82 | + | func (s *Server) warnPace(op string, err error) { | |
| 83 | + | if err == nil || s.paceWarned { | |
| 89 | 84 | return | |
| 90 | 85 | } | |
| 86 | + | s.paceWarned = true | |
| 87 | + | s.log.Warn().Err(err).Str("op", op).Msg("pace timer") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // onPaceTimer is the display loop's callback for the pace timerfd: the | |
| 91 | + | // deadline armed by the last tick passed. | |
| 92 | + | func (s *Server) onPaceTimer() { | |
| 93 | + | var buf [8]byte | |
| 94 | + | // Level-triggered: consume the expiry. EAGAIN: the timer was disarmed | |
| 95 | + | // after the loop saw it readable. | |
| 96 | + | n, err := unix.Read(s.paceFD, buf[:]) | |
| 97 | + | switch { | |
| 98 | + | case err == nil && n == len(buf): | |
| 99 | + | // The one-shot expired: the timer is disarmed until the next arm. | |
| 100 | + | s.paceArmed = false | |
| 101 | + | case err != nil && !errors.Is(err, unix.EAGAIN): | |
| 102 | + | s.warnPace("read", err) | |
| 103 | + | } | |
| 104 | + | s.paceTick(nil, nil) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // paceSignalled is the posted tick: a wake (fence, overflow) made while the | |
| 108 | + | // display goroutine was elsewhere. | |
| 109 | + | func (s *Server) paceSignalled() { | |
| 110 | + | s.paceSignal.Store(false) | |
| 91 | 111 | s.paceTick(nil, nil) | |
| 92 | 112 | } | |
| 93 | 113 | ||
| @@ -109,7 +129,7 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent | |||
| 109 | 129 | } | |
| 110 | 130 | // The pass queued work behind its own checks (applied commits | |
| 111 | 131 | // queue callbacks, released buffers): look again, as a flip-less | |
| 112 | - | // tick, instead of waking the pacer for it. | |
| 132 | + | // tick, instead of posting one. | |
| 113 | 133 | flipped, reports = nil, nil | |
| 114 | 134 | if pass+1 == maxPacePasses { | |
| 115 | 135 | s.paceDirty = false | |
| @@ -117,16 +137,13 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent | |||
| 117 | 137 | break | |
| 118 | 138 | } | |
| 119 | 139 | } | |
| 120 | - | if s.paceTimer == nil { | |
| 121 | - | return | |
| 122 | - | } | |
| 123 | 140 | if idle { | |
| 124 | 141 | // Nothing waits: sleep until a commit queues a callback | |
| 125 | 142 | // (wakePacer) or a flip. | |
| 126 | - | s.paceTimer.Stop() | |
| 143 | + | s.stopPace() | |
| 127 | 144 | return | |
| 128 | 145 | } | |
| 129 | - | s.paceTimer.Reset(wait) | |
| 146 | + | s.armPace(wait) | |
| 130 | 147 | } | |
| 131 | 148 | ||
| 132 | 149 | // pacePass is one pass of paceTick; it returns the time to the next | |
M internal/adapters/wayland/frames_test.go +2 -2
| @@ -88,7 +88,7 @@ func TestDueFrames(t *testing.T) { | |||
| 88 | 88 | ||
| 89 | 89 | func TestInvisibleFramePacingAndMigration(t *testing.T) { | |
| 90 | 90 | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 91 | - | s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)} | |
| 91 | + | s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}} | |
| 92 | 92 | w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}} | |
| 93 | 93 | root := &surface{xdg: &xdgSurface{window: w}} | |
| 94 | 94 | child := &surface{sub: subState{parent: root}} | |
| @@ -128,7 +128,7 @@ func TestInvisibleFramePacingAndMigration(t *testing.T) { | |||
| 128 | 128 | // output's queue and move when Captured changes. | |
| 129 | 129 | func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) { | |
| 130 | 130 | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 131 | - | s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)} | |
| 131 | + | s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}} | |
| 132 | 132 | w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}} | |
| 133 | 133 | root := &surface{xdg: &xdgSurface{window: w}} | |
| 134 | 134 | a := new(wayland.Callback) | |
A internal/adapters/wayland/norace_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !race | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | const raceEnabled = false | |
M internal/adapters/wayland/pace_inline_test.go +108 -36
| @@ -6,13 +6,24 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // paceFixture is a server whose surface waits on an output whose next | |
| 12 | - | // frame is an hour away: a commit queues its callback and nothing fires. | |
| 13 | - | func paceFixture(timer bool) (*Server, *surface) { | |
| 14 | + | // frame is an hour away: a commit queues its callback and nothing fires. It | |
| 15 | + | // has what New gives a server for pacing: a display (never run, so posted | |
| 16 | + | // ticks stay queued) and the registered pace timer. | |
| 17 | + | func paceFixture(t *testing.T) (*Server, *surface) { | |
| 18 | + | t.Helper() | |
| 19 | + | display, err := server.NewDisplay() | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | t.Cleanup(display.Close) | |
| 14 | 24 | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 15 | 25 | s := &Server{ | |
| 26 | + | display: display, | |
| 16 | 27 | outputs: []*output{out}, | |
| 17 | 28 | awaiting: map[string][]*wayland.Callback{}, | |
| 18 | 29 | frameDue: map[string]time.Time{"DP-2": time.Now().Add(time.Hour)}, | |
| @@ -20,12 +31,12 @@ func paceFixture(timer bool) (*Server, *surface) { | |||
| 20 | 31 | lastFlip: map[string]time.Time{}, | |
| 21 | 32 | flips: map[string]ports.FlipInfo{}, | |
| 22 | 33 | reports: map[string]ports.OutputPresented{}, | |
| 23 | - | frameReady: make(chan struct{}, 1), | |
| 24 | 34 | } | |
| 25 | - | if timer { | |
| 26 | - | s.paceTimer = time.NewTimer(time.Hour) | |
| 27 | - | s.paceTimer.Stop() | |
| 35 | + | s.paceSignalFn = s.paceSignalled | |
| 36 | + | if err := s.openPaceTimer(); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 28 | 38 | } | |
| 39 | + | t.Cleanup(func() { unix.Close(s.paceFD) }) | |
| 29 | 40 | w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}} | |
| 30 | 41 | surf := &surface{server: s, xdg: &xdgSurface{window: w}} | |
| 31 | 42 | surf.next.callbacks = []*wayland.Callback{new(wayland.Callback)} | |
| @@ -33,16 +44,16 @@ func paceFixture(timer bool) (*Server, *surface) { | |||
| 33 | 44 | } | |
| 34 | 45 | ||
| 35 | 46 | // A commit on the display goroutine ticks the pacer inline: it arms the | |
| 36 | - | // pacer's timer itself and does not wake the pacer goroutine, which would | |
| 37 | - | // only call display.Do to get back to the display goroutine. | |
| 47 | + | // pace timer itself and posts no tick, which would only run later on the | |
| 48 | + | // display goroutine it is already on. | |
| 38 | 49 | func TestCommitTicksPacerInline(t *testing.T) { | |
| 39 | - | s, surf := paceFixture(true) | |
| 50 | + | s, surf := paceFixture(t) | |
| 40 | 51 | surf.queueAndTick() | |
| 41 | 52 | if len(s.awaiting["DP-2"]) != 1 { | |
| 42 | 53 | t.Fatalf("callback not queued: %+v", s.awaiting) | |
| 43 | 54 | } | |
| 44 | - | if len(s.frameReady) != 0 { | |
| 45 | - | t.Fatal("commit woke the pacer goroutine") | |
| 55 | + | if s.paceSignal.Load() { | |
| 56 | + | t.Fatal("commit posted a tick") | |
| 46 | 57 | } | |
| 47 | 58 | waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline") | |
| 48 | 59 | if s.paceDepth != 0 || s.paceDirty { | |
| @@ -50,54 +61,48 @@ func TestCommitTicksPacerInline(t *testing.T) { | |||
| 50 | 61 | } | |
| 51 | 62 | } | |
| 52 | 63 | ||
| 53 | - | // Without a running pacer (no timer) the wake falls back to the channel. | |
| 54 | - | func TestCommitWakesPacerWithoutTimer(t *testing.T) { | |
| 55 | - | s, surf := paceFixture(false) | |
| 56 | - | surf.queueAndTick() | |
| 57 | - | if len(s.frameReady) != 1 { | |
| 58 | - | t.Fatal("pacer not woken") | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Wakes inside a tick coalesce into one more pass, not one channel wake each. | |
| 64 | + | // Wakes inside a tick coalesce into one more pass, not one posted tick each. | |
| 63 | 65 | func TestWakePacerDefersInsideTick(t *testing.T) { | |
| 64 | - | s, _ := paceFixture(true) | |
| 66 | + | s, _ := paceFixture(t) | |
| 65 | 67 | s.paceDepth = 1 | |
| 66 | 68 | s.wakePacer() | |
| 67 | 69 | s.wakePacer() | |
| 68 | - | if len(s.frameReady) != 0 || !s.paceDirty { | |
| 69 | - | t.Fatalf("wake inside a tick: queued %d dirty %v", len(s.frameReady), s.paceDirty) | |
| 70 | + | if s.paceSignal.Load() || !s.paceDirty { | |
| 71 | + | t.Fatalf("wake inside a tick: signalled %v dirty %v", s.paceSignal.Load(), s.paceDirty) | |
| 70 | 72 | } | |
| 71 | 73 | s.paceDepth = 0 | |
| 72 | 74 | s.wakePacer() | |
| 73 | - | if len(s.frameReady) != 1 { | |
| 75 | + | if !s.paceSignal.Load() { | |
| 74 | 76 | t.Fatal("wake outside a tick lost") | |
| 75 | 77 | } | |
| 76 | 78 | } | |
| 77 | 79 | ||
| 78 | 80 | // A commit that waits (a fifo barrier) is timed by the inline tick. | |
| 79 | 81 | func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) { | |
| 80 | - | s, surf := paceFixture(true) | |
| 82 | + | s, surf := paceFixture(t) | |
| 81 | 83 | surf.next.callbacks = nil | |
| 82 | 84 | surf.barrier, surf.barrierAt = true, time.Now() | |
| 83 | 85 | surf.next.wait = true | |
| 84 | 86 | surf.queueAndTick() | |
| 85 | - | if len(surf.queue) != 1 || len(s.frameReady) != 0 { | |
| 86 | - | t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady)) | |
| 87 | + | if len(surf.queue) != 1 || s.paceSignal.Load() { | |
| 88 | + | t.Fatalf("queued %d, tick signalled %v", len(surf.queue), s.paceSignal.Load()) | |
| 87 | 89 | } | |
| 88 | 90 | waitArmed(t, s, "timer not armed for the barrier deadline") | |
| 89 | 91 | } | |
| 90 | 92 | ||
| 91 | - | // waitArmed asserts the timer was armed: it is pending, or it already | |
| 92 | - | // fired and holds its value. | |
| 93 | + | // waitArmed asserts the timerfd was armed: it is pending, or it already | |
| 94 | + | // fired and holds its expiry. | |
| 93 | 95 | func waitArmed(t *testing.T, s *Server, msg string) { | |
| 94 | 96 | t.Helper() | |
| 95 | - | if s.paceTimer.Stop() { | |
| 97 | + | var cur unix.ItimerSpec | |
| 98 | + | if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | if cur.Value != (unix.Timespec{}) { | |
| 96 | 102 | return | |
| 97 | 103 | } | |
| 98 | - | select { | |
| 99 | - | case <-s.paceTimer.C: | |
| 100 | - | default: | |
| 104 | + | var buf [8]byte | |
| 105 | + | if n, _ := unix.Read(s.paceFD, buf[:]); n != 8 { | |
| 101 | 106 | t.Fatal(msg) | |
| 102 | 107 | } | |
| 103 | 108 | } | |
| @@ -105,14 +110,81 @@ func waitArmed(t *testing.T, s *Server, msg string) { | |||
| 105 | 110 | // A synchronized subsurface commit waits for its parent's commit: it does | |
| 106 | 111 | // not tick the pacer, so a burst of child commits costs no ticks. | |
| 107 | 112 | func TestSyncedCommitDoesNotTick(t *testing.T) { | |
| 108 | - | s, surf := paceFixture(true) | |
| 113 | + | s, surf := paceFixture(t) | |
| 109 | 114 | surf.sub.parent = &surface{server: s} | |
| 110 | 115 | surf.sub.synced = true | |
| 111 | 116 | surf.queueAndTick() | |
| 112 | 117 | if len(surf.queue) != 1 || !surf.queue[0].synced { | |
| 113 | 118 | t.Fatalf("synced commit not queued: %d", len(surf.queue)) | |
| 114 | 119 | } | |
| 115 | - | if len(s.frameReady) != 0 || s.paceTimer.Stop() { | |
| 120 | + | var cur unix.ItimerSpec | |
| 121 | + | if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | if s.paceSignal.Load() || cur.Value != (unix.Timespec{}) { | |
| 116 | 125 | t.Fatal("synced commit ticked the pacer") | |
| 117 | 126 | } | |
| 118 | 127 | } | |
| 128 | + | ||
| 129 | + | // An arm for the deadline already armed (within paceTolerance) keeps the | |
| 130 | + | // timer; another deadline re-arms it; an expiry consumed by onPaceTimer, or a | |
| 131 | + | // stop, leaves it disarmed, and a stop of a disarmed timer does nothing. | |
| 132 | + | func TestArmPaceSkipsUnchangedDeadline(t *testing.T) { | |
| 133 | + | s, _ := paceFixture(t) | |
| 134 | + | remaining := func() time.Duration { | |
| 135 | + | var cur unix.ItimerSpec | |
| 136 | + | if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | return time.Duration(cur.Value.Nano()) | |
| 140 | + | } | |
| 141 | + | s.stopPace() | |
| 142 | + | if s.paceArmed { | |
| 143 | + | t.Fatal("stop of a never armed timer armed it") | |
| 144 | + | } | |
| 145 | + | now := time.Now() | |
| 146 | + | s.armPaceFrom(now, time.Hour) | |
| 147 | + | first := s.paceDeadline | |
| 148 | + | if !s.paceArmed || remaining() <= 59*time.Minute { | |
| 149 | + | t.Fatalf("not armed: %v", remaining()) | |
| 150 | + | } | |
| 151 | + | // A deadline a little before the armed one keeps the timer. | |
| 152 | + | s.armPaceFrom(now, time.Hour-paceTolerance/2) | |
| 153 | + | if !s.paceDeadline.Equal(first) { | |
| 154 | + | t.Fatalf("an earlier deadline within tolerance re-armed the timer: %v -> %v", first, s.paceDeadline) | |
| 155 | + | } | |
| 156 | + | // A later deadline, however close, re-arms: the timer must never fire | |
| 157 | + | // before the requested deadline. | |
| 158 | + | s.armPaceFrom(now, time.Hour+time.Nanosecond) | |
| 159 | + | if !s.paceDeadline.After(first) { | |
| 160 | + | t.Fatalf("a later deadline kept the earlier timer: %v -> %v", first, s.paceDeadline) | |
| 161 | + | } | |
| 162 | + | first = s.paceDeadline | |
| 163 | + | s.armPace(time.Hour - time.Minute) | |
| 164 | + | if s.paceDeadline.Equal(first) || remaining() > 59*time.Minute { | |
| 165 | + | t.Fatalf("a new deadline kept the old timer: remaining %v", remaining()) | |
| 166 | + | } | |
| 167 | + | s.stopPace() | |
| 168 | + | if s.paceArmed || remaining() != 0 { | |
| 169 | + | t.Fatalf("stop left the timer armed: %v", remaining()) | |
| 170 | + | } | |
| 171 | + | // One-shot expiry: the fired timer is disarmed, so a stop is a no-op and | |
| 172 | + | // the next arm sets the timer again even for the same deadline. | |
| 173 | + | s.armPace(time.Nanosecond) | |
| 174 | + | fds := []unix.PollFd{{Fd: int32(s.paceFD), Events: unix.POLLIN}} | |
| 175 | + | if n, err := unix.Poll(fds, 5000); n != 1 { | |
| 176 | + | t.Fatalf("the timer did not fire: %d, %v", n, err) | |
| 177 | + | } | |
| 178 | + | s.onPaceTimer() | |
| 179 | + | if s.paceArmed { | |
| 180 | + | t.Fatal("expiry did not disarm") | |
| 181 | + | } | |
| 182 | + | s.stopPace() // no syscall to observe: it must not re-arm anything | |
| 183 | + | if remaining() != 0 { | |
| 184 | + | t.Fatalf("stop after the expiry: %v", remaining()) | |
| 185 | + | } | |
| 186 | + | s.armPace(time.Hour) | |
| 187 | + | if !s.paceArmed || remaining() <= 59*time.Minute { | |
| 188 | + | t.Fatalf("not re-armed after the expiry: %v", remaining()) | |
| 189 | + | } | |
| 190 | + | } | |
M internal/adapters/wayland/presentation.go +0 -4
| @@ -81,9 +81,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb | |||
| 81 | 81 | root := s.root() | |
| 82 | 82 | win := root.windowID() | |
| 83 | 83 | if win != 0 && fresh { | |
| 84 | - | srv.contentMu.Lock() | |
| 85 | 84 | seq := srv.contentSeq[win] | |
| 86 | - | srv.contentMu.Unlock() | |
| 87 | 85 | for i := range srv.feedbacks { | |
| 88 | 86 | if w := &srv.feedbacks[i]; w.surf == s && w.replaced == 0 && seq > w.seq { | |
| 89 | 87 | w.replaced = seq | |
| @@ -102,9 +100,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb | |||
| 102 | 100 | } | |
| 103 | 101 | return | |
| 104 | 102 | } | |
| 105 | - | srv.contentMu.Lock() | |
| 106 | 103 | seq := srv.contentSeq[win] | |
| 107 | - | srv.contentMu.Unlock() | |
| 108 | 104 | for _, fb := range pending { | |
| 109 | 105 | srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()}) | |
| 110 | 106 | } | |
M internal/adapters/wayland/presentation_test.go +11 -6
| @@ -46,22 +46,27 @@ func (p *feedbackEvents) Dispatch(e *wlturbo.Event) { | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | // presentServer is a server whose flips the test sends. | |
| 49 | - | func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, chan ports.OutputPresented, string) { | |
| 49 | + | func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) { | |
| 50 | + | t.Helper() | |
| 51 | + | return presentServerOn(t, testOutputs) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // presentServerOn is presentServer with its own output layout. | |
| 55 | + | func presentServerOn(t *testing.T, layout ports.Layout) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) { | |
| 50 | 56 | t.Helper() | |
| 51 | 57 | dir := t.TempDir() | |
| 52 | 58 | events := make(chan ports.ClientEvent, 16) | |
| 53 | 59 | commands := make(chan ports.ClientCommand, 16) | |
| 54 | - | contents := make(chan ports.SurfaceContent, 64) | |
| 55 | - | presented := make(chan ports.OutputPresented, 16) | |
| 56 | - | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents, Presented: presented}, logging.For(context.Background(), "wayland")) | |
| 60 | + | s, err := New(Options{RuntimeDir: dir, Outputs: layout}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland")) | |
| 57 | 61 | if err != nil { | |
| 58 | 62 | t.Fatal(err) | |
| 59 | 63 | } | |
| 64 | + | contents := relayContents(t, s) | |
| 60 | 65 | ctx, cancel := context.WithCancel(context.Background()) | |
| 61 | 66 | done := make(chan error, 1) | |
| 62 | 67 | go func() { done <- s.Run(ctx) }() | |
| 63 | 68 | t.Cleanup(func() { cancel(); <-done }) | |
| 64 | - | return s, events, commands, contents, presented, dir | |
| 69 | + | return s, events, commands, contents, s.Presentation(), dir | |
| 65 | 70 | } | |
| 66 | 71 | ||
| 67 | 72 | func TestPresentationFeedback(t *testing.T) { | |
| @@ -156,7 +161,7 @@ func TestPresentationFeedback(t *testing.T) { | |||
| 156 | 161 | } | |
| 157 | 162 | } | |
| 158 | 163 | flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) { | |
| 159 | - | presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}} | |
| 164 | + | presented.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}) | |
| 160 | 165 | } | |
| 161 | 166 | // With no flip, hiding must discard a waiting feedback immediately. | |
| 162 | 167 | hidden := feedback() | |
A internal/adapters/wayland/presented.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/presented" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // maxPresentRing bounds the reports waiting for the display goroutine. Output | |
| 13 | + | // queues are flushed every loop turn and hold no backlog, so the ring only | |
| 14 | + | // fills when the display loop is stalled; this sink alone then merges flips. | |
| 15 | + | const maxPresentRing = 64 | |
| 16 | + | ||
| 17 | + | // mergeWarnEvery spaces the warnings about merged flips. | |
| 18 | + | const mergeWarnEvery = 10 * time.Second | |
| 19 | + | ||
| 20 | + | // presentSink is the ports.PresentationSink wayland offers to the outputs: | |
| 21 | + | // output owner goroutines Push reports, the display goroutine drains them and | |
| 22 | + | // paces frames. The ring is a transport queue between those goroutines, not | |
| 23 | + | // window state: the mutex guards only the slices, the pending flag and the | |
| 24 | + | // merge counters. | |
| 25 | + | type presentSink struct { | |
| 26 | + | s *Server | |
| 27 | + | ||
| 28 | + | mu sync.Mutex | |
| 29 | + | ring, spare []ports.OutputPresented | |
| 30 | + | pending bool // a drain is posted and has not taken the ring yet | |
| 31 | + | // merged counts flips merged since the last warning; | |
| 32 | + | // lastWarn is when that warning was logged. | |
| 33 | + | merged int | |
| 34 | + | lastWarn time.Time | |
| 35 | + | drainFn func() | |
| 36 | + | scratchFlips map[string]bool | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func newPresentSink(s *Server) *presentSink { | |
| 40 | + | p := &presentSink{ | |
| 41 | + | s: s, | |
| 42 | + | ring: make([]ports.OutputPresented, 0, maxPresentRing), | |
| 43 | + | spare: make([]ports.OutputPresented, 0, maxPresentRing), | |
| 44 | + | scratchFlips: make(map[string]bool), | |
| 45 | + | } | |
| 46 | + | p.drainFn = p.drain | |
| 47 | + | return p | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Presentation returns the sink outputs report to. Push never blocks. | |
| 51 | + | func (s *Server) Presentation() ports.PresentationSink { return s.sink } | |
| 52 | + | ||
| 53 | + | // Push queues a report for the display goroutine and wakes it; one wake | |
| 54 | + | // serves every report pushed before the drain runs. When the ring is full the | |
| 55 | + | // flip of the oldest report is carried or merged forward; merges are counted | |
| 56 | + | // and warned about at most every mergeWarnEvery. | |
| 57 | + | func (p *presentSink) Push(r ports.OutputPresented) { | |
| 58 | + | p.mu.Lock() | |
| 59 | + | warn, merged := p.enqueueLocked(r) | |
| 60 | + | post := !p.pending | |
| 61 | + | p.pending = true | |
| 62 | + | p.mu.Unlock() | |
| 63 | + | if warn { | |
| 64 | + | p.s.log.Warn().Int("merged", merged).Msg("outputs report faster than the display loop drains; flips merged") | |
| 65 | + | } | |
| 66 | + | if post { | |
| 67 | + | p.s.display.Post(p.drainFn) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // enqueueLocked appends r, making room first when the ring is full. It | |
| 72 | + | // reports whether to warn now and how many flips were merged since the last | |
| 73 | + | // warning. | |
| 74 | + | func (p *presentSink) enqueueLocked(r ports.OutputPresented) (warn bool, merged int) { | |
| 75 | + | if len(p.ring) >= maxPresentRing { | |
| 76 | + | if p.makeRoomLocked(&r) { | |
| 77 | + | p.merged++ | |
| 78 | + | } | |
| 79 | + | if now := p.s.clock.Now(); p.merged > 0 && now.Sub(p.lastWarn) >= mergeWarnEvery { | |
| 80 | + | warn, merged = true, p.merged | |
| 81 | + | p.merged, p.lastWarn = 0, now | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | p.ring = append(p.ring, r) | |
| 85 | + | return warn, merged | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // makeRoomLocked frees a ring slot for incoming without losing a flip: the | |
| 89 | + | // oldest report that a later report of the same output follows (in the ring, | |
| 90 | + | // or incoming itself) is removed and its flip carried into that report. A | |
| 91 | + | // flip-less successor takes the flip over; a successor with a flip merges it | |
| 92 | + | // (presented.Merge: the older feedback is discarded), which is what the | |
| 93 | + | // result counts. | |
| 94 | + | // | |
| 95 | + | // When no report has a successor, every ring entry and incoming belong to | |
| 96 | + | // distinct outputs: only possible with more than maxPresentRing outputs. | |
| 97 | + | // Nothing is dropped then; the ring grows by one, bounded by the number of | |
| 98 | + | // outputs, because the next report of any of them finds a successor. | |
| 99 | + | func (p *presentSink) makeRoomLocked(incoming *ports.OutputPresented) (merged bool) { | |
| 100 | + | for i := range p.ring { | |
| 101 | + | next := p.successorLocked(i, incoming) | |
| 102 | + | if next == nil { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | switch old := p.ring[i].Flip; { | |
| 106 | + | case old == nil: | |
| 107 | + | case next.Flip == nil: | |
| 108 | + | next.Flip = old | |
| 109 | + | default: | |
| 110 | + | presented.Merge(old, next.Flip) | |
| 111 | + | merged = true | |
| 112 | + | } | |
| 113 | + | p.ring = slices.Delete(p.ring, i, i+1) | |
| 114 | + | return merged | |
| 115 | + | } | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // successorLocked returns the next report of ring[i]'s output: a later ring | |
| 120 | + | // entry, else incoming; nil when none follows. | |
| 121 | + | func (p *presentSink) successorLocked(i int, incoming *ports.OutputPresented) *ports.OutputPresented { | |
| 122 | + | for j := i + 1; j < len(p.ring); j++ { | |
| 123 | + | if p.ring[j].Output == p.ring[i].Output { | |
| 124 | + | return &p.ring[j] | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | if incoming.Output == p.ring[i].Output { | |
| 128 | + | return incoming | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // drain runs on the display goroutine: it paces frames for every report that | |
| 134 | + | // arrived since the last drain as one tick, so a burst of flips fires one | |
| 135 | + | // burst of frame callbacks per output. | |
| 136 | + | func (p *presentSink) drain() { | |
| 137 | + | p.mu.Lock() | |
| 138 | + | batch := p.ring | |
| 139 | + | p.ring, p.spare = p.spare[:0], nil | |
| 140 | + | p.pending = false | |
| 141 | + | p.mu.Unlock() | |
| 142 | + | flipped := p.scratchFlips | |
| 143 | + | clear(flipped) | |
| 144 | + | for _, r := range batch { | |
| 145 | + | flipped[r.Output] = flipped[r.Output] || paces(r) | |
| 146 | + | } | |
| 147 | + | if len(batch) > 0 { | |
| 148 | + | p.s.paceTick(flipped, batch) | |
| 149 | + | } | |
| 150 | + | // The reports carry maps: do not retain them in the spare batch. | |
| 151 | + | clear(batch) | |
| 152 | + | p.mu.Lock() | |
| 153 | + | p.spare = batch[:0] | |
| 154 | + | p.mu.Unlock() | |
| 155 | + | } | |
A internal/adapters/wayland/presented_test.go +444 -0
| @@ -0,0 +1,444 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/go-wayland-bindings/server/fifo" | |
| 11 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 12 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 13 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |
| 15 | + | "github.com/bnema/wlturbo" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // slowOutputs has two outputs refreshing once a second: their deadlines never | |
| 21 | + | // fire inside a test, so only flips pace their frame callbacks. | |
| 22 | + | var slowOutputs = ports.Layout{ | |
| 23 | + | {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 1000}, Width: 1920, Height: 1080, Scale: 1}, | |
| 24 | + | {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1920, Height: 1080, RefreshMilli: 1000}, X: 1920, Width: 1920, Height: 1080, Scale: 1}, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // frameDoneProxy records the time of a wl_callback.done. | |
| 28 | + | type frameDoneProxy struct { | |
| 29 | + | wlturbo.BaseProxy | |
| 30 | + | done chan uint32 | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (p *frameDoneProxy) Dispatch(e *wlturbo.Event) { | |
| 34 | + | if e.Opcode == uint16(wayland.CallbackEventDone) { | |
| 35 | + | p.done <- binary.NativeEndian.Uint32(e.Data()) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // requestFrame commits the surface with a new frame callback. | |
| 40 | + | func requestFrame(t *testing.T, c *wlturbo.Display, surf uint32) *frameDoneProxy { | |
| 41 | + | t.Helper() | |
| 42 | + | cb := c.AllocateID() | |
| 43 | + | p := &frameDoneProxy{done: make(chan uint32, 2)} | |
| 44 | + | p.SetID(cb) | |
| 45 | + | registerWireProxy(c, p) | |
| 46 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestFrame, cb) | |
| 47 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 48 | + | if err := c.Roundtrip(); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | return p | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // awaitFrame waits for the callback to be answered and returns its time. | |
| 55 | + | func awaitFrame(t *testing.T, c *wlturbo.Display, p *frameDoneProxy) uint32 { | |
| 56 | + | t.Helper() | |
| 57 | + | for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { | |
| 58 | + | if err := c.Roundtrip(); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | select { | |
| 62 | + | case ms := <-p.done: | |
| 63 | + | return ms | |
| 64 | + | case <-time.After(2 * time.Millisecond): | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | t.Fatal("frame callback not answered") | |
| 68 | + | return 0 | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // placeWindow puts the window on an output and waits until wayland applied it. | |
| 72 | + | func placeWindow(t *testing.T, s *Server, commands chan<- ports.ClientCommand, w ports.WindowMapped, output string) { | |
| 73 | + | t.Helper() | |
| 74 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: output, Visible: true} | |
| 75 | + | for deadline := time.Now().Add(2 * time.Second); ; { | |
| 76 | + | var got string | |
| 77 | + | s.display.Do(func() { got = s.frameOutput(s.windows[w.ID].xdg.surface) }) | |
| 78 | + | if got == output { | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | if time.Now().After(deadline) { | |
| 82 | + | t.Fatalf("window not placed on %s", output) | |
| 83 | + | } | |
| 84 | + | time.Sleep(time.Millisecond) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Flips pushed while the display goroutine is busy are paced by one tick per | |
| 89 | + | // output, not one per stale flip. The frame callback of each output is | |
| 90 | + | // answered once, with the time of that output's last flip: a tick per flip | |
| 91 | + | // would have answered it with the first flip's time. | |
| 92 | + | func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) { | |
| 93 | + | s, events, commands, _, sink, dir := presentServerOn(t, slowOutputs) | |
| 94 | + | c := protocolClient(t, s, dir) | |
| 95 | + | mk := surfaceMapper(t, c, events) | |
| 96 | + | outputs := []string{"HEADLESS-1", "HEADLESS-2"} | |
| 97 | + | surfs := make([]uint32, len(outputs)) | |
| 98 | + | for i, name := range outputs { | |
| 99 | + | w, surf, _ := mk() | |
| 100 | + | placeWindow(t, s, commands, w, name) | |
| 101 | + | surfs[i] = surf | |
| 102 | + | // The first callback of an idle output is answered at once and | |
| 103 | + | // starts its one-second period: the next one waits for a flip. | |
| 104 | + | awaitFrame(t, c, requestFrame(t, c, surf)) | |
| 105 | + | } | |
| 106 | + | waiting := make([]*frameDoneProxy, len(outputs)) | |
| 107 | + | for i := range outputs { | |
| 108 | + | waiting[i] = requestFrame(t, c, surfs[i]) | |
| 109 | + | select { | |
| 110 | + | case ms := <-waiting[i].done: | |
| 111 | + | t.Fatalf("%s answered before any flip (at %d ms)", outputs[i], ms) | |
| 112 | + | default: | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | var base time.Duration | |
| 116 | + | release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 117 | + | go func() { | |
| 118 | + | s.display.Do(func() { base = monotonic(s.started); close(started); <-release }) | |
| 119 | + | close(finished) | |
| 120 | + | }() | |
| 121 | + | <-started | |
| 122 | + | const flips = 5 | |
| 123 | + | const step = 100 * time.Millisecond | |
| 124 | + | for i := range flips { | |
| 125 | + | for _, name := range outputs { | |
| 126 | + | sink.Push(ports.OutputPresented{Output: name, Flip: &ports.FlipInfo{When: base + time.Duration(i+1)*step, Seq: uint64(i + 1), HardwareClock: true}}) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | close(release) | |
| 130 | + | <-finished | |
| 131 | + | last := uint32((flips * step).Milliseconds()) | |
| 132 | + | for i, name := range outputs { | |
| 133 | + | ms := awaitFrame(t, c, waiting[i]) | |
| 134 | + | // base is read on the display goroutine a little before the server | |
| 135 | + | // answers: a few ms of slack, far below one step. | |
| 136 | + | if ms < last-5 || ms > last+5 { | |
| 137 | + | t.Fatalf("%s answered at %d ms, want the last flip's %d ms (the first flip is at %d)", name, ms, last, step.Milliseconds()) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | var seq uint64 | |
| 141 | + | var reported bool | |
| 142 | + | s.display.Do(func() { | |
| 143 | + | seq = s.flips["HEADLESS-1"].Seq | |
| 144 | + | _, reported = s.reports["HEADLESS-1"] | |
| 145 | + | }) | |
| 146 | + | if !reported || seq != flips { | |
| 147 | + | t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func ringOf(outputs ...string) *presentSink { | |
| 152 | + | p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing+1)} | |
| 153 | + | for _, o := range outputs { | |
| 154 | + | p.ring = append(p.ring, ports.OutputPresented{Output: o}) | |
| 155 | + | } | |
| 156 | + | return p | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // fill pads the ring with reports of distinct outputs up to the bound. | |
| 160 | + | func (p *presentSink) fill() { | |
| 161 | + | for i := 0; len(p.ring) < maxPresentRing; i++ { | |
| 162 | + | p.ring = append(p.ring, ports.OutputPresented{Output: "pad" + string(rune('A'+i/26)) + string(rune('a'+i%26))}) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func flipOf(seq uint64, shows ...ports.WindowID) *ports.FlipInfo { | |
| 167 | + | f := &ports.FlipInfo{Seq: seq} | |
| 168 | + | if len(shows) > 0 { | |
| 169 | + | f.Shows = map[ports.WindowID]uint64{} | |
| 170 | + | for _, id := range shows { | |
| 171 | + | f.Shows[id] = seq | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return f | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // makeRoomLocked on a full ring: which report it merges or moves, and what it | |
| 178 | + | // reports. | |
| 179 | + | func TestPresentationRingMakeRoom(t *testing.T) { | |
| 180 | + | tests := []struct { | |
| 181 | + | name string | |
| 182 | + | ring func() *presentSink | |
| 183 | + | in ports.OutputPresented | |
| 184 | + | wantMerged bool | |
| 185 | + | check func(t *testing.T, p *presentSink, in *ports.OutputPresented) | |
| 186 | + | }{ | |
| 187 | + | { | |
| 188 | + | // A full ring merges the oldest flip of an output into its next flip. | |
| 189 | + | name: "merges past the bound", | |
| 190 | + | ring: func() *presentSink { | |
| 191 | + | p := ringOf() | |
| 192 | + | for i := range maxPresentRing { | |
| 193 | + | p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))}) | |
| 194 | + | } | |
| 195 | + | return p | |
| 196 | + | }, | |
| 197 | + | in: ports.OutputPresented{Output: "A", Flip: flipOf(maxPresentRing + 1)}, | |
| 198 | + | wantMerged: true, | |
| 199 | + | check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) { | |
| 200 | + | if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 { | |
| 201 | + | t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged) | |
| 202 | + | } | |
| 203 | + | }, | |
| 204 | + | }, | |
| 205 | + | { | |
| 206 | + | // A flip followed by a flip-less report of the same output is not | |
| 207 | + | // lost: the later report carries it. | |
| 208 | + | name: "keeps a flip before a flip-less report", | |
| 209 | + | ring: func() *presentSink { | |
| 210 | + | p := ringOf("A", "A") | |
| 211 | + | p.ring[0].Flip = flipOf(7, 3) | |
| 212 | + | p.fill() | |
| 213 | + | p.ring = p.ring[:maxPresentRing] | |
| 214 | + | return p | |
| 215 | + | }, | |
| 216 | + | in: ports.OutputPresented{Output: "B", Flip: flipOf(9)}, | |
| 217 | + | check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) { | |
| 218 | + | if len(p.ring) != maxPresentRing-1 || p.ring[0].Output != "A" || p.ring[0].Flip == nil || p.ring[0].Flip.Seq != 7 || p.ring[0].Flip.Merged != 0 { | |
| 219 | + | t.Fatalf("flip lost: ring %d, first %+v", len(p.ring), p.ring[0]) | |
| 220 | + | } | |
| 221 | + | }, | |
| 222 | + | }, | |
| 223 | + | { | |
| 224 | + | // With several outputs interleaved, the victim is the oldest report | |
| 225 | + | // that has a successor of its own output; other outputs' reports are | |
| 226 | + | // untouched. | |
| 227 | + | name: "interleaved outputs", | |
| 228 | + | ring: func() *presentSink { | |
| 229 | + | p := ringOf("A", "B", "A", "B") | |
| 230 | + | p.ring[0].Flip, p.ring[1].Flip = flipOf(1, 10), flipOf(2, 20) | |
| 231 | + | p.ring[2].Flip, p.ring[3].Flip = flipOf(3, 11), nil | |
| 232 | + | p.fill() | |
| 233 | + | return p | |
| 234 | + | }, | |
| 235 | + | in: ports.OutputPresented{Output: "C"}, | |
| 236 | + | wantMerged: true, | |
| 237 | + | check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) { | |
| 238 | + | if p.ring[0].Output != "B" || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 0 { | |
| 239 | + | t.Fatalf("B's flip touched: %+v", p.ring[0]) | |
| 240 | + | } | |
| 241 | + | a := p.ring[1] | |
| 242 | + | if a.Output != "A" || a.Flip.Seq != 3 || a.Flip.Merged != 1 || a.Flip.Shows[10] != 1 || a.Flip.Shows[11] != 3 { | |
| 243 | + | t.Fatalf("A's flips not merged: %+v", a.Flip) | |
| 244 | + | } | |
| 245 | + | if p.ring[2].Output != "B" || p.ring[2].Flip != nil { | |
| 246 | + | t.Fatalf("B's flip-less report moved: %+v", p.ring[2]) | |
| 247 | + | } | |
| 248 | + | }, | |
| 249 | + | }, | |
| 250 | + | { | |
| 251 | + | // The incoming report counts as the successor of a ring report. | |
| 252 | + | name: "incoming takes over a flip", | |
| 253 | + | ring: func() *presentSink { | |
| 254 | + | p := ringOf("A") | |
| 255 | + | p.ring[0].Flip = flipOf(4) | |
| 256 | + | p.fill() | |
| 257 | + | return p | |
| 258 | + | }, | |
| 259 | + | in: ports.OutputPresented{Output: "A"}, | |
| 260 | + | check: func(t *testing.T, p *presentSink, in *ports.OutputPresented) { | |
| 261 | + | if in.Flip == nil || in.Flip.Seq != 4 || len(p.ring) != maxPresentRing-1 { | |
| 262 | + | t.Fatalf("incoming %+v, ring %d", in.Flip, len(p.ring)) | |
| 263 | + | } | |
| 264 | + | }, | |
| 265 | + | }, | |
| 266 | + | { | |
| 267 | + | // With no successor for any report (more outputs than ring slots) | |
| 268 | + | // nothing is dropped: the ring grows by one and every flip is still | |
| 269 | + | // delivered. | |
| 270 | + | name: "without a successor keeps everything", | |
| 271 | + | ring: func() *presentSink { | |
| 272 | + | p := ringOf() | |
| 273 | + | for i := range maxPresentRing { | |
| 274 | + | p.ring = append(p.ring, ports.OutputPresented{Output: "o" + string(rune('A'+i/26)) + string(rune('a'+i%26)), Flip: flipOf(uint64(i + 1))}) | |
| 275 | + | } | |
| 276 | + | return p | |
| 277 | + | }, | |
| 278 | + | in: ports.OutputPresented{Output: "other", Flip: flipOf(99)}, | |
| 279 | + | check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) { | |
| 280 | + | if len(p.ring) != maxPresentRing { | |
| 281 | + | t.Fatalf("merged or dropped: ring %d", len(p.ring)) | |
| 282 | + | } | |
| 283 | + | }, | |
| 284 | + | }, | |
| 285 | + | } | |
| 286 | + | for _, tt := range tests { | |
| 287 | + | t.Run(tt.name, func(t *testing.T) { | |
| 288 | + | p := tt.ring() | |
| 289 | + | in := tt.in | |
| 290 | + | if merged := p.makeRoomLocked(&in); merged != tt.wantMerged { | |
| 291 | + | t.Fatalf("merged %v, want %v", merged, tt.wantMerged) | |
| 292 | + | } | |
| 293 | + | tt.check(t, p, &in) | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // Merges are counted, and warned about at most once per mergeWarnEvery. | |
| 299 | + | func TestPresentationRingWarnsRateLimited(t *testing.T) { | |
| 300 | + | now := time.Unix(1000, 0) | |
| 301 | + | clk := portsmocks.NewMockClock(t) | |
| 302 | + | clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }) | |
| 303 | + | p := ringOf() | |
| 304 | + | p.s = &Server{clock: clk} | |
| 305 | + | for i := range maxPresentRing { | |
| 306 | + | p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))}) | |
| 307 | + | } | |
| 308 | + | push := func(seq uint64) (bool, int) { | |
| 309 | + | return p.enqueueLocked(ports.OutputPresented{Output: "A", Flip: flipOf(seq)}) | |
| 310 | + | } | |
| 311 | + | if warn, n := push(100); !warn || n != 1 { | |
| 312 | + | t.Fatalf("first merge: warn %v merged %d", warn, n) | |
| 313 | + | } | |
| 314 | + | for i := range 3 { | |
| 315 | + | now = now.Add(time.Second) | |
| 316 | + | if warn, _ := push(uint64(101 + i)); warn { | |
| 317 | + | t.Fatalf("warned again after %v", time.Duration(i+1)*time.Second) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | now = now.Add(mergeWarnEvery) | |
| 321 | + | if warn, n := push(200); !warn || n != 4 { | |
| 322 | + | t.Fatalf("after the interval: warn %v merged %d, want the 4 merges since", warn, n) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // A stalled display loop makes outputs merge flips; wayland says so once. | |
| 327 | + | func TestPresentationMergeWarnsInLog(t *testing.T) { | |
| 328 | + | var out lockedBuffer | |
| 329 | + | log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland") | |
| 330 | + | s, _, _, _, _ := contentServerWith(t, Options{}, log) | |
| 331 | + | release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) | |
| 332 | + | go func() { | |
| 333 | + | s.display.Do(func() { close(started); <-release }) | |
| 334 | + | close(finished) | |
| 335 | + | }() | |
| 336 | + | <-started | |
| 337 | + | for i := range 3 * maxPresentRing { | |
| 338 | + | s.Presentation().Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}}) | |
| 339 | + | } | |
| 340 | + | close(release) | |
| 341 | + | <-finished | |
| 342 | + | s.display.Do(func() {}) | |
| 343 | + | got := out.String() | |
| 344 | + | if n := strings.Count(got, `"level":"warn"`); n != 1 { | |
| 345 | + | t.Fatalf("%d warnings: %s", n, got) | |
| 346 | + | } | |
| 347 | + | if !strings.Contains(got, `"component":"wayland"`) || !strings.Contains(got, `"merged":`) { | |
| 348 | + | t.Fatalf("warning lacks fields: %s", got) | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // Pushing a report and draining it on the display goroutine allocates nothing | |
| 353 | + | // in steady state. The count includes the display goroutine: AllocsPerRun | |
| 354 | + | // reads the global malloc counter. | |
| 355 | + | func TestPresentationPushAllocations(t *testing.T) { | |
| 356 | + | if raceEnabled { | |
| 357 | + | t.Skip("the race detector makes the purego wake callback allocate") | |
| 358 | + | } | |
| 359 | + | s, _, _, _, sink, _ := presentServer(t) | |
| 360 | + | r := ports.OutputPresented{Output: "HEADLESS-1"} | |
| 361 | + | // Posts run in order: once the marker ran, the drain before it did. | |
| 362 | + | // The marker is bound once and the channel is reused, so waiting for it | |
| 363 | + | // allocates nothing. | |
| 364 | + | ran := make(chan struct{}, 1) | |
| 365 | + | marker := func() { ran <- struct{}{} } | |
| 366 | + | for range 4 { | |
| 367 | + | sink.Push(r) | |
| 368 | + | s.display.Post(marker) | |
| 369 | + | <-ran | |
| 370 | + | } | |
| 371 | + | allocs := testing.AllocsPerRun(200, func() { | |
| 372 | + | sink.Push(r) | |
| 373 | + | s.display.Post(marker) | |
| 374 | + | <-ran | |
| 375 | + | }) | |
| 376 | + | if allocs != 0 { | |
| 377 | + | t.Errorf("push + drain: %.1f allocs, want 0", allocs) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // A fifo barrier on an output that does not flip clears at barrierAt+period, | |
| 382 | + | // driven by the pace timerfd. On a one-second output (so nothing expires during | |
| 383 | + | // the test) the timerfd must be armed, no later than that deadline (other waits, | |
| 384 | + | // like held buffers, may be earlier); and its callback, run once the deadline | |
| 385 | + | // has passed, applies the queued commit. | |
| 386 | + | func TestPaceTimerfdArmedForBarrierDeadline(t *testing.T) { | |
| 387 | + | s, events, commands, _, dir := contentServerWith(t, Options{Outputs: slowOutputs}, testLog()) | |
| 388 | + | c := protocolClient(t, s, dir) | |
| 389 | + | w, surf, _ := surfaceMapper(t, c, events)() | |
| 390 | + | placeWindow(t, s, commands, w, "HEADLESS-1") | |
| 391 | + | fifoQueue(t, c, surf) | |
| 392 | + | var remaining, want, period time.Duration | |
| 393 | + | var queued, left int | |
| 394 | + | var barrier bool | |
| 395 | + | var err error | |
| 396 | + | s.display.Do(func() { | |
| 397 | + | root := s.windows[w.ID].xdg.surface | |
| 398 | + | period, queued = s.outputPeriod(s.fifoOutput(root)), len(root.queue) | |
| 399 | + | var spec unix.ItimerSpec | |
| 400 | + | err = unix.TimerfdGettime(s.paceFD, &spec) | |
| 401 | + | remaining = time.Duration(spec.Value.Nano()) | |
| 402 | + | want = time.Until(root.barrierAt.Add(period)) | |
| 403 | + | // The deadline passes: the timerfd callback clears the barrier. | |
| 404 | + | root.barrierAt = time.Now().Add(-period) | |
| 405 | + | s.onPaceTimer() | |
| 406 | + | barrier, left = root.barrier, len(root.queue) | |
| 407 | + | }) | |
| 408 | + | if err != nil { | |
| 409 | + | t.Fatal(err) | |
| 410 | + | } | |
| 411 | + | if queued != 1 || period != time.Second { | |
| 412 | + | t.Fatalf("queued %d commits, period %v: the second commit must wait for the barrier", queued, period) | |
| 413 | + | } | |
| 414 | + | if remaining <= 0 || remaining > want+50*time.Millisecond { | |
| 415 | + | t.Fatalf("pace timerfd fires in %v, want armed at or before barrierAt+period (%v)", remaining, want) | |
| 416 | + | } | |
| 417 | + | if barrier || left != 0 { | |
| 418 | + | t.Fatalf("timer callback after the deadline: barrier %v, %d queued", barrier, left) | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func testLog() zerowrap.Logger { | |
| 423 | + | return logging.For(context.Background(), "wayland") | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // fifoQueue sets a barrier with one commit and queues a second commit behind | |
| 427 | + | // wait_barrier. | |
| 428 | + | func fifoQueue(t *testing.T, c *wlturbo.Display, surf uint32) { | |
| 429 | + | t.Helper() | |
| 430 | + | manager := bindProtocol(t, c, "wp_fifo_manager_v1") | |
| 431 | + | f := c.AllocateID() | |
| 432 | + | registerProtocol(t, c, f) | |
| 433 | + | requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf) | |
| 434 | + | buf := shmBuffer(t, c) | |
| 435 | + | requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier) | |
| 436 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 437 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 438 | + | requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier) | |
| 439 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 440 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 441 | + | if err := c.Roundtrip(); err != nil { | |
| 442 | + | t.Fatal(err) | |
| 443 | + | } | |
| 444 | + | } | |
M internal/adapters/wayland/protocol_test.go +8 -1
| @@ -627,7 +627,14 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client | |||
| 627 | 627 | // xdg_surface IDs. | |
| 628 | 628 | func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32, uint32) { | |
| 629 | 629 | t.Helper() | |
| 630 | - | comp := bindProtocol(t, c, "wl_compositor") | |
| 630 | + | return surfaceMapperAt(t, c, events, 1) | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // surfaceMapperAt is surfaceMapper with wl_compositor bound at compositorVersion | |
| 634 | + | // (damage_buffer needs 4). | |
| 635 | + | func surfaceMapperAt(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent, compositorVersion uint32) func() (ports.WindowMapped, uint32, uint32) { | |
| 636 | + | t.Helper() | |
| 637 | + | comp := bindVersion(t, c, "wl_compositor", compositorVersion) | |
| 631 | 638 | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 632 | 639 | shm := bindProtocol(t, c, "wl_shm") | |
| 633 | 640 | registerProtocol(t, c, shm) | |
A internal/adapters/wayland/race_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | //go:build race | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | // raceEnabled: the race detector makes purego callbacks (the display loop wake) | |
| 6 | + | // allocate, which allocation budgets that cross the display loop cannot count. | |
| 7 | + | const raceEnabled = true | |
M internal/adapters/wayland/release.go +0 -4
| @@ -47,9 +47,7 @@ func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold) | |||
| 47 | 47 | if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok { | |
| 48 | 48 | h.id = d.buf.ID | |
| 49 | 49 | } | |
| 50 | - | s.contentMu.Lock() | |
| 51 | 50 | h.after = s.contentSeq[window] | |
| 52 | - | s.contentMu.Unlock() | |
| 53 | 51 | s.held = append(s.held, h) | |
| 54 | 52 | // Wake the pacer: it releases the buffer on the next output report. | |
| 55 | 53 | s.wakePacer() | |
| @@ -78,9 +76,7 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) { | |||
| 78 | 76 | s.releaseSync(sync) | |
| 79 | 77 | return | |
| 80 | 78 | } | |
| 81 | - | s.contentMu.Lock() | |
| 82 | 79 | after := s.contentSeq[window] | |
| 83 | - | s.contentMu.Unlock() | |
| 84 | 80 | s.held = append(s.held, heldBuffer{sync: sync, window: window, after: after, at: time.Now()}) | |
| 85 | 81 | s.wakePacer() | |
| 86 | 82 | } | |
M internal/adapters/wayland/scale_test.go +14 -3
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 14 | 14 | "github.com/bnema/neferwl/internal/ports" | |
| 15 | 15 | "github.com/bnema/wlturbo" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 16 | 17 | "golang.org/x/sys/unix" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -51,16 +52,26 @@ func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) { | |||
| 51 | 52 | } | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | - | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) { | |
| 55 | + | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, string) { | |
| 56 | + | t.Helper() | |
| 57 | + | return contentServerWith(t, Options{}, logging.For(context.Background(), "wayland")) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // contentServerWith is contentServer with extra options and a logger. | |
| 61 | + | func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, string) { | |
| 55 | 62 | t.Helper() | |
| 56 | 63 | dir := t.TempDir() | |
| 57 | 64 | events := make(chan ports.ClientEvent, 16) | |
| 58 | 65 | commands := make(chan ports.ClientCommand, 16) | |
| 59 | - | contents := make(chan ports.SurfaceContent, 16) | |
| 60 | - | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland")) | |
| 66 | + | opts.RuntimeDir = dir | |
| 67 | + | if opts.Outputs == nil { | |
| 68 | + | opts.Outputs = testOutputs | |
| 69 | + | } | |
| 70 | + | s, err := New(opts, Channels{Events: events, Commands: commands}, log) | |
| 61 | 71 | if err != nil { | |
| 62 | 72 | t.Fatal(err) | |
| 63 | 73 | } | |
| 74 | + | contents := relayContents(t, s) | |
| 64 | 75 | ctx, cancel := context.WithCancel(context.Background()) | |
| 65 | 76 | done := make(chan error, 1) | |
| 66 | 77 | go func() { done <- s.Run(ctx) }() | |
M internal/adapters/wayland/server.go +75 -77
| @@ -48,6 +48,9 @@ type Options struct { | |||
| 48 | 48 | // nil lets every client capture, unless the session is protected. The | |
| 49 | 49 | // caller keeps it current (captureallow.Store.Run). | |
| 50 | 50 | CaptureAllow *captureallow.Store | |
| 51 | + | // LogDamage logs the damage of every fresh client commit at debug level | |
| 52 | + | // (--debug=wayland-damage). | |
| 53 | + | LogDamage bool | |
| 51 | 54 | // syncDev replaces the render node's syncobj interface (tests). | |
| 52 | 55 | syncDev syncobjDevice | |
| 53 | 56 | // peer replaces the peer executable lookup (tests). | |
| @@ -63,14 +66,10 @@ type Channels struct { | |||
| 63 | 66 | LeaseRequests chan<- ports.LeaseMessage | |
| 64 | 67 | LeaseEvents <-chan ports.LeaseMessage | |
| 65 | 68 | Events chan<- ports.ClientEvent | |
| 66 | - | Contents chan<- ports.SurfaceContent | |
| 67 | 69 | Commands <-chan ports.ClientCommand | |
| 68 | 70 | Workspaces <-chan ports.Workspaces | |
| 69 | 71 | // Cursors receives the cursor the client under the pointer asks for. | |
| 70 | 72 | Cursors chan<- ports.CursorChange | |
| 71 | - | // Presented paces frame callbacks on the outputs' page flips; outputs | |
| 72 | - | // that do not flip (idle, headless) are paced at their refresh rate. | |
| 73 | - | Presented <-chan ports.OutputPresented | |
| 74 | 73 | // Captures carries accepted capture requests, never blocking. Each | |
| 75 | 74 | // accepted request holds one of ports.MaxCaptureInflight credits until its | |
| 76 | 75 | // CaptureDone arrives on Captured, whether or not the client is still | |
| @@ -144,24 +143,32 @@ type Server struct { | |||
| 144 | 143 | name string | |
| 145 | 144 | cleanup func() | |
| 146 | 145 | log zerowrap.Logger | |
| 146 | + | logDamage bool | |
| 147 | 147 | channels Channels | |
| 148 | 148 | // awaiting holds frame callbacks by output name, due at its next | |
| 149 | 149 | // frame (frameDue, or its page flip). | |
| 150 | 150 | awaiting map[string][]*wayland.Callback | |
| 151 | 151 | frameOwners map[*wayland.Callback]*surface | |
| 152 | 152 | frameDue map[string]time.Time | |
| 153 | - | frameReady chan struct{} | |
| 154 | - | // paceTimer is the pacer's deadline, armed by ticks on the display | |
| 155 | - | // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or | |
| 156 | - | // tick (display goroutine only). | |
| 157 | - | paceTimer *time.Timer | |
| 158 | - | paceDepth int | |
| 159 | - | paceDirty bool | |
| 160 | - | // Pacer scratch: periods is used on the display goroutine, while | |
| 161 | - | // flipped and frameReports belong to pace and its synchronous Do call. | |
| 153 | + | // paceFD is the pace deadline timerfd, armed by ticks on the display | |
| 154 | + | // goroutine (paceReady: registered with the display loop); | |
| 155 | + | // paceDepth/paceDirty defer wakePacer inside a commit or tick (display | |
| 156 | + | // goroutine only). paceSignal coalesces posted ticks (paceSignalFn). | |
| 157 | + | paceFD int | |
| 158 | + | paceReady bool | |
| 159 | + | paceWarned bool // a pace timer failure was logged (once) | |
| 160 | + | // paceArmed: the timerfd is armed for paceDeadline (display goroutine | |
| 161 | + | // only); armPace and stopPace skip a syscall that would change nothing. | |
| 162 | + | paceArmed bool | |
| 163 | + | paceDeadline time.Time | |
| 164 | + | paceSignal atomic.Bool | |
| 165 | + | paceSignalFn func() | |
| 166 | + | paceDepth int | |
| 167 | + | paceDirty bool | |
| 168 | + | // sink receives the outputs' reports (presented.go). | |
| 169 | + | sink *presentSink | |
| 170 | + | // framePeriods is the tick's scratch (display goroutine only). | |
| 162 | 171 | framePeriods map[string]time.Duration | |
| 163 | - | flipped map[string]bool | |
| 164 | - | frameReports []ports.OutputPresented | |
| 165 | 172 | // held buffers wait until no output reads them (release.go); reports | |
| 166 | 173 | // are the outputs' latest ports.OutputPresented. | |
| 167 | 174 | held []heldBuffer | |
| @@ -209,11 +216,16 @@ type Server struct { | |||
| 209 | 216 | eventMu sync.Mutex | |
| 210 | 217 | events []ports.ClientEvent | |
| 211 | 218 | eventReady chan struct{} | |
| 212 | - | contentMu sync.Mutex | |
| 213 | - | contents map[ports.WindowID]ports.SurfaceContent | |
| 219 | + | // contentSeq counts each window's contents and damage is its recent | |
| 220 | + | // damage; latest is its current content. All three belong to the display | |
| 221 | + | // goroutine, as do the outputs' content links (contentlink.go). | |
| 214 | 222 | contentSeq map[ports.WindowID]uint64 | |
| 215 | - | // damage is each window's recent damage (contentMu). | |
| 216 | - | damage map[ports.WindowID][]ports.SeqDamage | |
| 223 | + | damage map[ports.WindowID][]ports.SeqDamage | |
| 224 | + | latest map[ports.WindowID]ports.SurfaceContent | |
| 225 | + | links []*contentLink | |
| 226 | + | // flushPosted: a flushLinks is posted and has not started (Return). | |
| 227 | + | flushPosted atomic.Bool | |
| 228 | + | flushLinksFn func() | |
| 217 | 229 | // feedbacks wait for the flip that shows their content (presentation.go). | |
| 218 | 230 | feedbacks []feedbackWait | |
| 219 | 231 | // Explicit sync (syncobj.go): the render node, imported timelines by | |
| @@ -221,7 +233,7 @@ type Server struct { | |||
| 221 | 233 | syncDev syncobjDevice | |
| 222 | 234 | timelines map[*server.Resource]*timeline | |
| 223 | 235 | syncWait *syncWaiter | |
| 224 | - | // flips is each output's latest flip (pacer, under Do). | |
| 236 | + | // flips is each output's latest flip (display goroutine). | |
| 225 | 237 | flips map[string]ports.FlipInfo | |
| 226 | 238 | // inhibitors are the live inhibitor objects (inhibit.go); | |
| 227 | 239 | // shortcutWindows and idleWindows what core was told. | |
| @@ -235,7 +247,6 @@ type Server struct { | |||
| 235 | 247 | clock ports.Clock | |
| 236 | 248 | powers []*outputPower | |
| 237 | 249 | outputsOff map[string]bool | |
| 238 | - | contentReady chan struct{} | |
| 239 | 250 | outputs []*output | |
| 240 | 251 | outputManagers []*outputManager | |
| 241 | 252 | workspaceManagers []*workspaceManager | |
| @@ -315,12 +326,12 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 315 | 326 | display: d, | |
| 316 | 327 | awaiting: map[string][]*wayland.Callback{}, | |
| 317 | 328 | frameDue: map[string]time.Time{}, | |
| 318 | - | frameReady: make(chan struct{}, 1), | |
| 319 | 329 | reports: map[string]ports.OutputPresented{}, | |
| 320 | 330 | env: linuxProcEnv{}, | |
| 321 | 331 | name: name, | |
| 322 | 332 | cleanup: cleanup, | |
| 323 | 333 | log: log, | |
| 334 | + | logDamage: opts.LogDamage, | |
| 324 | 335 | channels: ch, | |
| 325 | 336 | surfaces: make(map[*server.Resource]*surface), | |
| 326 | 337 | buffers: make(map[*server.Resource]clientBuffer), | |
| @@ -328,10 +339,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 328 | 339 | layers: make(map[ports.WindowID]*layerSurface), | |
| 329 | 340 | nextWindow: 1, | |
| 330 | 341 | eventReady: make(chan struct{}, 1), | |
| 331 | - | contents: make(map[ports.WindowID]ports.SurfaceContent), | |
| 332 | 342 | contentSeq: make(map[ports.WindowID]uint64), | |
| 333 | 343 | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 334 | - | contentReady: make(chan struct{}, 1), | |
| 344 | + | latest: make(map[ports.WindowID]ports.SurfaceContent), | |
| 335 | 345 | cursorReady: make(chan struct{}, 1), | |
| 336 | 346 | dataSources: map[*server.Resource]*clipSource{}, | |
| 337 | 347 | primarySources: map[*server.Resource]*clipSource{}, | |
| @@ -343,6 +353,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 343 | 353 | positioners: map[*server.Resource]*positioner{}, | |
| 344 | 354 | seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}, | |
| 345 | 355 | } | |
| 356 | + | // Bound once, before anything can post them (the sync waiter calls | |
| 357 | + | // signalPacer from its own goroutine as soon as it exists). | |
| 358 | + | s.paceSignalFn = s.paceSignalled | |
| 359 | + | s.flushLinksFn = s.flushLinks | |
| 360 | + | s.sink = newPresentSink(s) | |
| 346 | 361 | s.leaseDevices = map[string]*leaseDevice{} | |
| 347 | 362 | s.pendingLeases = map[uint64]*leaseObject{} | |
| 348 | 363 | s.pendingLeaseCards = map[uint64]string{} | |
| @@ -416,6 +431,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 416 | 431 | } | |
| 417 | 432 | s.syncWait.log = log | |
| 418 | 433 | s.cleanup = func() { | |
| 434 | + | if s.paceReady { | |
| 435 | + | unix.Close(s.paceFD) | |
| 436 | + | } | |
| 419 | 437 | if s.securityStop >= 0 { | |
| 420 | 438 | unix.Close(s.securityStop) | |
| 421 | 439 | } | |
| @@ -436,6 +454,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 436 | 454 | d.Close() | |
| 437 | 455 | return nil, err | |
| 438 | 456 | } | |
| 457 | + | if err = s.openPaceTimer(); err != nil { | |
| 458 | + | s.cleanup() | |
| 459 | + | d.Close() | |
| 460 | + | return nil, err | |
| 461 | + | } | |
| 439 | 462 | if err = registerGlobals(d, opts, s); err != nil { | |
| 440 | 463 | s.cleanup() | |
| 441 | 464 | d.Close() | |
| @@ -446,6 +469,24 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 446 | 469 | } | |
| 447 | 470 | return s, nil | |
| 448 | 471 | } | |
| 472 | + | ||
| 473 | + | // openPaceTimer creates the pace timerfd and registers it with the display | |
| 474 | + | // loop (before Run, as AddFD requires). | |
| 475 | + | func (s *Server) openPaceTimer() error { | |
| 476 | + | fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC) | |
| 477 | + | if err != nil { | |
| 478 | + | return err | |
| 479 | + | } | |
| 480 | + | // libwayland duplicates fd: ours stays open for TimerfdSettime and is | |
| 481 | + | // closed by cleanup; the source goes with the display. | |
| 482 | + | if _, err = s.display.AddFD(fd, s.onPaceTimer); err != nil { | |
| 483 | + | unix.Close(fd) | |
| 484 | + | return err | |
| 485 | + | } | |
| 486 | + | s.paceFD, s.paceReady = fd, true | |
| 487 | + | return nil | |
| 488 | + | } | |
| 489 | + | ||
| 449 | 490 | func (s *Server) SocketName() string { return s.name } | |
| 450 | 491 | func (s *Server) Run(ctx context.Context) error { | |
| 451 | 492 | defer s.cleanup() | |
| @@ -459,9 +500,8 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 459 | 500 | s.log.Info().Str("socket", s.name).Msg("starting wayland") | |
| 460 | 501 | s.started = time.Now() | |
| 461 | 502 | s.ctx = ctx | |
| 462 | - | s.paceTimer = time.NewTimer(defaultFramePeriod) | |
| 463 | 503 | var wg sync.WaitGroup | |
| 464 | - | wg.Add(15) | |
| 504 | + | wg.Add(13) | |
| 465 | 505 | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | |
| 466 | 506 | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | |
| 467 | 507 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | |
| @@ -469,7 +509,6 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 469 | 509 | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | |
| 470 | 510 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 471 | 511 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 472 | - | go func() { defer wg.Done(); s.forwardContents(ctx) }() | |
| 473 | 512 | go func() { | |
| 474 | 513 | defer wg.Done() | |
| 475 | 514 | for { | |
| @@ -502,7 +541,6 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 502 | 541 | } | |
| 503 | 542 | } | |
| 504 | 543 | }() | |
| 505 | - | go func() { defer wg.Done(); s.pace(ctx) }() | |
| 506 | 544 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | |
| 507 | 545 | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | |
| 508 | 546 | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | |
| @@ -636,11 +674,10 @@ func (s *Server) forward(ctx context.Context) { | |||
| 636 | 674 | } | |
| 637 | 675 | } | |
| 638 | 676 | ||
| 677 | + | // emitContent publishes a window's content on the display goroutine: it | |
| 678 | + | // becomes the window's latest content and joins the batch of every attached | |
| 679 | + | // output (contentlink.go). | |
| 639 | 680 | func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |
| 640 | - | if s.channels.Contents == nil { | |
| 641 | - | return | |
| 642 | - | } | |
| 643 | - | s.contentMu.Lock() | |
| 644 | 681 | s.contentSeq[c.ID]++ | |
| 645 | 682 | c.Seq = s.contentSeq[c.ID] | |
| 646 | 683 | // Published histories are immutable: an output may hold one for as long | |
| @@ -658,56 +695,17 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 658 | 695 | old = grown | |
| 659 | 696 | } | |
| 660 | 697 | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | |
| 698 | + | c.DamageHistory = h[:len(h):len(h)] | |
| 661 | 699 | if c.Empty() { | |
| 662 | 700 | delete(s.damage, c.ID) | |
| 701 | + | delete(s.latest, c.ID) | |
| 663 | 702 | } else { | |
| 664 | 703 | s.damage[c.ID] = h | |
| 704 | + | s.latest[c.ID] = c | |
| 665 | 705 | } | |
| 666 | - | c.DamageHistory = h[:len(h):len(h)] | |
| 667 | - | s.contents[c.ID] = c | |
| 668 | - | s.contentMu.Unlock() | |
| 669 | - | select { | |
| 670 | - | case s.contentReady <- struct{}{}: | |
| 671 | - | default: | |
| 672 | - | } | |
| 673 | - | } | |
| 674 | - | ||
| 675 | - | func (s *Server) forwardContents(ctx context.Context) { | |
| 676 | - | for { | |
| 677 | - | s.contentMu.Lock() | |
| 678 | - | var id ports.WindowID | |
| 679 | - | var c ports.SurfaceContent | |
| 680 | - | found := false | |
| 681 | - | var seq uint64 | |
| 682 | - | for id, c = range s.contents { | |
| 683 | - | found = true | |
| 684 | - | seq = s.contentSeq[id] | |
| 685 | - | break | |
| 686 | - | } | |
| 687 | - | s.contentMu.Unlock() | |
| 688 | - | if !found { | |
| 689 | - | select { | |
| 690 | - | case <-ctx.Done(): | |
| 691 | - | return | |
| 692 | - | case <-s.contentReady: | |
| 693 | - | continue | |
| 694 | - | } | |
| 695 | - | } | |
| 696 | - | // Do not hold a stale frame while the receiver is busy: wake on newer | |
| 697 | - | // data. contentReady is signalled after every publication; a stale | |
| 698 | - | // signal only costs one more loop. | |
| 699 | - | select { | |
| 700 | - | case <-ctx.Done(): | |
| 701 | - | return | |
| 702 | - | case <-s.contentReady: | |
| 703 | - | continue | |
| 704 | - | case s.channels.Contents <- c: | |
| 705 | - | s.contentMu.Lock() | |
| 706 | - | if s.contentSeq[id] == seq { | |
| 707 | - | delete(s.contents, id) | |
| 708 | - | } | |
| 709 | - | s.contentMu.Unlock() | |
| 710 | - | } | |
| 706 | + | for _, l := range s.links { | |
| 707 | + | l.add(c) | |
| 708 | + | s.flushLink(l) | |
| 711 | 709 | } | |
| 712 | 710 | } | |
| 713 | 711 | ||
M internal/adapters/wayland/server_test.go +2 -2
| @@ -374,11 +374,11 @@ func TestWestonContents(t *testing.T) { | |||
| 374 | 374 | } | |
| 375 | 375 | dir := t.TempDir() | |
| 376 | 376 | events := make(chan ports.ClientEvent, 16) | |
| 377 | - | contents := make(chan ports.SurfaceContent, 16) | |
| 378 | - | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland")) | |
| 377 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events}, logging.For(context.Background(), "wayland")) | |
| 379 | 378 | if err != nil { | |
| 380 | 379 | t.Fatal(err) | |
| 381 | 380 | } | |
| 381 | + | contents := relayContents(t, s) | |
| 382 | 382 | ctx, cancel := context.WithCancel(context.Background()) | |
| 383 | 383 | done := make(chan error, 1) | |
| 384 | 384 | go func() { done <- s.Run(ctx) }() | |
M internal/adapters/wayland/surface.go +33 -1
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/go-wayland-bindings/server/wlrlayershell" | |
| 14 | 14 | "github.com/bnema/go-wayland-bindings/server/xdgshell" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | + | "github.com/rs/zerolog" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | type roleKind uint8 | |
| @@ -263,7 +264,7 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) { | |||
| 263 | 264 | // for later) are not drawn, so they do not make it full. | |
| 264 | 265 | func (s *surface) redraw() { | |
| 265 | 266 | r := s.root() | |
| 266 | - | if id := r.windowID(); id != 0 && s.server.channels.Contents != nil { | |
| 267 | + | if id := r.windowID(); id != 0 { | |
| 267 | 268 | c := r.tree(id) | |
| 268 | 269 | d := damage{full: true} | |
| 269 | 270 | if s == r && len(c.Children) == 0 && !r.drewChildren { | |
| @@ -588,6 +589,37 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) { | |||
| 588 | 589 | // A new size, too many rects or none at all with a new buffer (clients | |
| 589 | 590 | // that do not report damage) is a full change. | |
| 590 | 591 | func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) { | |
| 592 | + | s.mapDamage(u, fresh, resized, bw, bh) | |
| 593 | + | if fresh && s.server.logDamage { | |
| 594 | + | s.traceDamage(bw, bh) | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // traceDamage logs the damage commitDamage just computed | |
| 599 | + | // (--debug=wayland-damage): full_buffer is set when the commit is a full | |
| 600 | + | // change or one rect covers the whole buffer. Rects are already clipped to | |
| 601 | + | // the buffer, so covering means equal to it. | |
| 602 | + | func (s *surface) traceDamage(bw, bh int) { | |
| 603 | + | d := s.committed | |
| 604 | + | full := d.full | |
| 605 | + | for _, r := range d.rects { | |
| 606 | + | if r.X <= 0 && r.Y <= 0 && r.X+r.W >= bw && r.Y+r.H >= bh { | |
| 607 | + | full = true | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | arr := zerolog.Arr() | |
| 611 | + | for _, r := range d.rects[:min(len(d.rects), maxLoggedDamageRects)] { | |
| 612 | + | arr.Dict(zerolog.Dict().Int("x", r.X).Int("y", r.Y).Int("w", r.W).Int("h", r.H)) | |
| 613 | + | } | |
| 614 | + | s.server.log.Debug().Uint64("window", uint64(s.root().windowID())).Int("bw", bw).Int("bh", bh). | |
| 615 | + | Int("rects", len(d.rects)).Bool("full_buffer", full).Array("first_rects", arr).Msg("client damage") | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | // maxLoggedDamageRects bounds the rects one "client damage" entry lists. | |
| 619 | + | const maxLoggedDamageRects = 4 | |
| 620 | + | ||
| 621 | + | // mapDamage sets s.committed from the commit's requested damage. | |
| 622 | + | func (s *surface) mapDamage(u *update, fresh, resized bool, bw, bh int) { | |
| 591 | 623 | surf, buf := u.damage, u.bufDamage | |
| 592 | 624 | if !fresh { | |
| 593 | 625 | s.committed = damage{} | |
M internal/adapters/wayland/surface_perf_test.go +5 -5
| @@ -35,7 +35,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 35 | 35 | // The top-level test is listed in Makefile perf-check; this subtest guards | |
| 36 | 36 | // the per-commit update storage after its first retirement. | |
| 37 | 37 | t.Run("queueUpdateSteadyState", func(t *testing.T) { | |
| 38 | - | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |
| 38 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{})} | |
| 39 | 39 | surf := &surface{server: srv} | |
| 40 | 40 | commit := func() { | |
| 41 | 41 | surf.queueUpdate() | |
| @@ -48,7 +48,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 48 | 48 | }) | |
| 49 | 49 | for _, count := range []int{16, 64} { | |
| 50 | 50 | t.Run(fmt.Sprintf("%d", count), func(t *testing.T) { | |
| 51 | - | channels := Channels{Contents: make(chan ports.SurfaceContent, 1)} | |
| 51 | + | channels := Channels{} | |
| 52 | 52 | srv, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, channels, logging.For(context.Background(), "wayland")) | |
| 53 | 53 | if err != nil { | |
| 54 | 54 | t.Fatal(err) | |
| @@ -64,7 +64,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 64 | 64 | publish := func() { root.redraw() } | |
| 65 | 65 | root.treeDirty = true | |
| 66 | 66 | publish() | |
| 67 | - | first := srv.contents[1] | |
| 67 | + | first := srv.latest[1] | |
| 68 | 68 | publish() | |
| 69 | 69 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 { | |
| 70 | 70 | t.Fatalf("previous publication changed: %+v", first) | |
| @@ -99,10 +99,10 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 99 | 99 | root.sub.children[count-1].version++ | |
| 100 | 100 | root.treeDirty = true | |
| 101 | 101 | publish() | |
| 102 | - | if first.Children[count-1].Version != 0 || srv.contents[1].Children[count-1].Version != 1 { | |
| 102 | + | if first.Children[count-1].Version != 0 || srv.latest[1].Children[count-1].Version != 1 { | |
| 103 | 103 | t.Fatal("child version leaked into an earlier publication") | |
| 104 | 104 | } | |
| 105 | - | if &first.Children[0] == &srv.contents[1].Children[0] { | |
| 105 | + | if &first.Children[0] == &srv.latest[1].Children[0] { | |
| 106 | 106 | t.Fatal("rebuild reused previously published children storage") | |
| 107 | 107 | } | |
| 108 | 108 | // Rebuilding a changed child's immutable snapshot must have a | |
M internal/adapters/wayland/syncobj.go +1 -1
| @@ -219,7 +219,7 @@ type syncWaiter struct { | |||
| 219 | 219 | // syncWait is one acquire point a queued commit waits for. | |
| 220 | 220 | type syncWait struct { | |
| 221 | 221 | efd int | |
| 222 | - | done bool // the eventfd fired or the wait was cancelled (pacer, under Do) | |
| 222 | + | done bool // the eventfd fired or the wait was cancelled (display goroutine) | |
| 223 | 223 | failed bool // observation failed; done must not authorize publication | |
| 224 | 224 | cancelled atomic.Bool // run stops polling it and closes efd | |
| 225 | 225 | } | |
M internal/adapters/wayland/syncobj_test.go +6 -7
| @@ -48,8 +48,8 @@ type syncHarness struct { | |||
| 48 | 48 | s *Server | |
| 49 | 49 | c *wlturbo.Display | |
| 50 | 50 | dev *mocksyncobjDevice | |
| 51 | - | contents chan ports.SurfaceContent | |
| 52 | - | presented chan ports.OutputPresented | |
| 51 | + | contents <-chan ports.SurfaceContent | |
| 52 | + | presented ports.PresentationSink | |
| 53 | 53 | surf uint32 | |
| 54 | 54 | syncSurf uint32 | |
| 55 | 55 | timeline uint32 | |
| @@ -99,13 +99,12 @@ func newSyncHarness(t *testing.T) *syncHarness { | |||
| 99 | 99 | dir := t.TempDir() | |
| 100 | 100 | events := make(chan ports.ClientEvent, 16) | |
| 101 | 101 | commands := make(chan ports.ClientCommand, 16) | |
| 102 | - | h.contents = make(chan ports.SurfaceContent, 64) | |
| 103 | - | h.presented = make(chan ports.OutputPresented, 16) | |
| 104 | 102 | sup := ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}} | |
| 105 | - | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands, Contents: h.contents, Presented: h.presented}, logging.For(context.Background(), "wayland")) | |
| 103 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland")) | |
| 106 | 104 | if err != nil { | |
| 107 | 105 | t.Fatal(err) | |
| 108 | 106 | } | |
| 107 | + | h.contents = relayContents(t, s) | |
| 109 | 108 | ctx, cancel := context.WithCancel(context.Background()) | |
| 110 | 109 | done := make(chan error, 1) | |
| 111 | 110 | go func() { done <- s.Run(ctx) }() | |
| @@ -118,7 +117,7 @@ func newSyncHarness(t *testing.T) *syncHarness { | |||
| 118 | 117 | } | |
| 119 | 118 | h.mu.Unlock() | |
| 120 | 119 | }) | |
| 121 | - | h.s = s | |
| 120 | + | h.s, h.presented = s, s.Presentation() | |
| 122 | 121 | h.c = protocolClient(t, s, dir) | |
| 123 | 122 | w, surf, xdg := surfaceMapper(t, h.c, events)() | |
| 124 | 123 | registerProtocol(t, h.c, xdg) | |
| @@ -275,7 +274,7 @@ func TestSyncobjAcquireAndRelease(t *testing.T) { | |||
| 275 | 274 | t.Fatal("acquire fence closed before release") | |
| 276 | 275 | } | |
| 277 | 276 | // The output read the second content: the first buffer is released. | |
| 278 | - | h.presented <- ports.OutputPresented{Output: "HEADLESS-1", Seen: map[ports.WindowID]uint64{h.win: second.Seq}} | |
| 277 | + | h.presented.Push(ports.OutputPresented{Output: "HEADLESS-1", Seen: map[ports.WindowID]uint64{h.win: second.Seq}}) | |
| 279 | 278 | deadline := time.Now().Add(2 * time.Second) | |
| 280 | 279 | for len(h.signalled()) == 0 && time.Now().Before(deadline) { | |
| 281 | 280 | if err := h.c.Roundtrip(); err != nil { | |
M internal/adapters/wayland/transform_test.go +2 -2
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | type transformClient struct { | |
| 18 | 18 | c *wlturbo.Display | |
| 19 | 19 | comp, surf, vp, bf uint32 | |
| 20 | - | contents chan ports.SurfaceContent | |
| 20 | + | contents <-chan ports.SurfaceContent | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | func newTransformClient(t *testing.T) transformClient { | |
| @@ -234,7 +234,7 @@ func TestOpaqueRect(t *testing.T) { | |||
| 234 | 234 | // Transform and opaque region requested after a queued commit belong to | |
| 235 | 235 | // the next commit. | |
| 236 | 236 | func TestCapturedTransformAndOpaqueKeepLaterRequests(t *testing.T) { | |
| 237 | - | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}} | |
| 237 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, constraints: map[*surface]*constraint{}} | |
| 238 | 238 | surf := &surface{server: srv} | |
| 239 | 239 | surf.next.transform = 1 | |
| 240 | 240 | surf.next.opaque, surf.next.opaqueSet = []ports.Rect{{W: 4, H: 4}}, true | |
M internal/app/app.go +0 -25
| @@ -138,31 +138,6 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 138 | 138 | } | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | - | // relayPresented passes output reports to wayland in order. Each flip is | |
| 142 | - | // also a frame for core; a frame core has not read yet is enough, so | |
| 143 | - | // frames never wait. | |
| 144 | - | func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) { | |
| 145 | - | for { | |
| 146 | - | var p ports.OutputPresented | |
| 147 | - | select { | |
| 148 | - | case <-ctx.Done(): | |
| 149 | - | return | |
| 150 | - | case p = <-in: | |
| 151 | - | } | |
| 152 | - | if p.Flip != nil { | |
| 153 | - | select { | |
| 154 | - | case frames <- ports.OutputFrame{Output: p.Output}: | |
| 155 | - | default: | |
| 156 | - | } | |
| 157 | - | } | |
| 158 | - | select { | |
| 159 | - | case out <- p: | |
| 160 | - | case <-ctx.Done(): | |
| 161 | - | return | |
| 162 | - | } | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | 141 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | |
| 167 | 142 | log := logging.For(ctx, "core") | |
| 168 | 143 | configLog := logging.For(ctx, "config") | |
M internal/app/capture_test.go +1 -1
| @@ -144,7 +144,7 @@ func TestOutputStopWithFullReplies(t *testing.T) { | |||
| 144 | 144 | replies := make(chan ports.CaptureDone, 1) | |
| 145 | 145 | replies <- ports.CaptureDone{} | |
| 146 | 146 | set := newOutputSet(ctx, replies) | |
| 147 | - | set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 147 | + | set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 148 | 148 | <-ctx.Done() | |
| 149 | 149 | return nil | |
| 150 | 150 | }) | |
A internal/app/contentattacher_mock_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // newMockcontentAttacher creates a new instance of mockcontentAttacher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func newMockcontentAttacher(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *mockcontentAttacher { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &mockcontentAttacher{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // mockcontentAttacher is an autogenerated mock type for the contentAttacher type | |
| 36 | + | type mockcontentAttacher struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type mockcontentAttacher_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *mockcontentAttacher) EXPECT() *mockcontentAttacher_Expecter { | |
| 45 | + | return &mockcontentAttacher_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // AttachOutput provides a mock function for the type mockcontentAttacher | |
| 49 | + | func (_mock *mockcontentAttacher) AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |
| 50 | + | ret := _mock.Called(name) | |
| 51 | + | ||
| 52 | + | if len(ret) == 0 { | |
| 53 | + | panic("no return value specified for AttachOutput") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var r0 <-chan *ports.ContentBatch | |
| 57 | + | var r1 ports.ContentReturn | |
| 58 | + | if returnFunc, ok := ret.Get(0).(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn)); ok { | |
| 59 | + | return returnFunc(name) | |
| 60 | + | } | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(string) <-chan *ports.ContentBatch); ok { | |
| 62 | + | r0 = returnFunc(name) | |
| 63 | + | } else { | |
| 64 | + | if ret.Get(0) != nil { | |
| 65 | + | r0 = ret.Get(0).(<-chan *ports.ContentBatch) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(string) ports.ContentReturn); ok { | |
| 69 | + | r1 = returnFunc(name) | |
| 70 | + | } else { | |
| 71 | + | if ret.Get(1) != nil { | |
| 72 | + | r1 = ret.Get(1).(ports.ContentReturn) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // mockcontentAttacher_AttachOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AttachOutput' | |
| 79 | + | type mockcontentAttacher_AttachOutput_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // AttachOutput is a helper method to define mock.On call | |
| 84 | + | // - name string | |
| 85 | + | func (_e *mockcontentAttacher_Expecter) AttachOutput(name any) *mockcontentAttacher_AttachOutput_Call { | |
| 86 | + | return &mockcontentAttacher_AttachOutput_Call{Call: _e.mock.On("AttachOutput", name)} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (_c *mockcontentAttacher_AttachOutput_Call) Run(run func(name string)) *mockcontentAttacher_AttachOutput_Call { | |
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 91 | + | var arg0 string | |
| 92 | + | if args[0] != nil { | |
| 93 | + | arg0 = args[0].(string) | |
| 94 | + | } | |
| 95 | + | run( | |
| 96 | + | arg0, | |
| 97 | + | ) | |
| 98 | + | }) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *mockcontentAttacher_AttachOutput_Call) Return(contentBatchCh <-chan *ports.ContentBatch, contentReturn ports.ContentReturn) *mockcontentAttacher_AttachOutput_Call { | |
| 103 | + | _c.Call.Return(contentBatchCh, contentReturn) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (_c *mockcontentAttacher_AttachOutput_Call) RunAndReturn(run func(name string) (<-chan *ports.ContentBatch, ports.ContentReturn)) *mockcontentAttacher_AttachOutput_Call { | |
| 108 | + | _c.Call.Return(run) | |
| 109 | + | return _c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // DetachOutput provides a mock function for the type mockcontentAttacher | |
| 113 | + | func (_mock *mockcontentAttacher) DetachOutput(feed ports.ContentReturn) { | |
| 114 | + | _mock.Called(feed) | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // mockcontentAttacher_DetachOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetachOutput' | |
| 119 | + | type mockcontentAttacher_DetachOutput_Call struct { | |
| 120 | + | *mock.Call | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // DetachOutput is a helper method to define mock.On call | |
| 124 | + | // - feed ports.ContentReturn | |
| 125 | + | func (_e *mockcontentAttacher_Expecter) DetachOutput(feed any) *mockcontentAttacher_DetachOutput_Call { | |
| 126 | + | return &mockcontentAttacher_DetachOutput_Call{Call: _e.mock.On("DetachOutput", feed)} | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (_c *mockcontentAttacher_DetachOutput_Call) Run(run func(feed ports.ContentReturn)) *mockcontentAttacher_DetachOutput_Call { | |
| 130 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 131 | + | var arg0 ports.ContentReturn | |
| 132 | + | if args[0] != nil { | |
| 133 | + | arg0 = args[0].(ports.ContentReturn) | |
| 134 | + | } | |
| 135 | + | run( | |
| 136 | + | arg0, | |
| 137 | + | ) | |
| 138 | + | }) | |
| 139 | + | return _c | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *mockcontentAttacher_DetachOutput_Call) Return() *mockcontentAttacher_DetachOutput_Call { | |
| 143 | + | _c.Call.Return() | |
| 144 | + | return _c | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (_c *mockcontentAttacher_DetachOutput_Call) RunAndReturn(run func(feed ports.ContentReturn)) *mockcontentAttacher_DetachOutput_Call { | |
| 148 | + | _c.Run(run) | |
| 149 | + | return _c | |
| 150 | + | } | |
A internal/app/contentattacher_test.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Wayland feeds a started output its contents: Attach comes after the | |
| 14 | + | // output registered and got its security state, the output runs on what | |
| 15 | + | // Attach returned, Detach of that same handle follows its end, and a | |
| 16 | + | // restarted connector attaches again. | |
| 17 | + | func TestOutputSetAttachesAndDetachesContents(t *testing.T) { | |
| 18 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | + | defer cancel() | |
| 20 | + | set := newOutputSet(ctx, nil) | |
| 21 | + | events := make(chan ports.SecurityBackendEvent, 4) | |
| 22 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 23 | + | attacher := newMockcontentAttacher(t) | |
| 24 | + | set.attach = attacher | |
| 25 | + | rets := []*portsmocks.MockContentReturn{portsmocks.NewMockContentReturn(t), portsmocks.NewMockContentReturn(t)} | |
| 26 | + | feed := make(chan *ports.ContentBatch, 1) | |
| 27 | + | var order []string | |
| 28 | + | type seen struct { | |
| 29 | + | contents <-chan *ports.ContentBatch | |
| 30 | + | returns ports.ContentReturn | |
| 31 | + | security ports.SecurityState | |
| 32 | + | } | |
| 33 | + | started := make(chan seen, 2) | |
| 34 | + | run := func(ctx context.Context, _ <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 35 | + | started <- seen{contents, returns, <-security} | |
| 36 | + | <-ctx.Done() | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | attaches := 0 | |
| 40 | + | attacher.EXPECT().AttachOutput("DP-1").RunAndReturn(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn) { | |
| 41 | + | order = append(order, "attach") | |
| 42 | + | ret := rets[attaches] | |
| 43 | + | attaches++ | |
| 44 | + | // The output is registered with the security backend first. | |
| 45 | + | if len(events) != 1 { | |
| 46 | + | t.Errorf("attached with %d registrations queued, want 1", len(events)) | |
| 47 | + | } | |
| 48 | + | return feed, ret | |
| 49 | + | }).Times(2) | |
| 50 | + | var detached []ports.ContentReturn | |
| 51 | + | attacher.EXPECT().DetachOutput(mock.Anything).Run(func(feed ports.ContentReturn) { | |
| 52 | + | order = append(order, "detach") | |
| 53 | + | detached = append(detached, feed) | |
| 54 | + | }).Times(2) | |
| 55 | + | for round := range 2 { | |
| 56 | + | if !set.start(ctx, "DP-1", run) { | |
| 57 | + | t.Fatal("start failed") | |
| 58 | + | } | |
| 59 | + | got := <-started | |
| 60 | + | if got.contents != (<-chan *ports.ContentBatch)(feed) || got.returns != ports.ContentReturn(rets[round]) { | |
| 61 | + | t.Fatalf("round %d: output got another feed", round) | |
| 62 | + | } | |
| 63 | + | <-events | |
| 64 | + | set.outs["DP-1"].stop() | |
| 65 | + | if err := set.finish("DP-1"); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | for round := range detached { | |
| 70 | + | if detached[round] != ports.ContentReturn(rets[round]) { | |
| 71 | + | t.Fatalf("round %d: detached another feed than the one attached", round) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | want := []string{"attach", "detach", "attach", "detach"} | |
| 75 | + | if len(order) != len(want) { | |
| 76 | + | t.Fatalf("order %v, want %v", order, want) | |
| 77 | + | } | |
| 78 | + | for i := range want { | |
| 79 | + | if order[i] != want[i] { | |
| 80 | + | t.Fatalf("order %v, want %v", order, want) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // wait detaches the outputs it stops. | |
| 86 | + | func TestOutputSetWaitDetachesContents(t *testing.T) { | |
| 87 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 88 | + | defer cancel() | |
| 89 | + | set := newOutputSet(ctx, nil) | |
| 90 | + | attacher := newMockcontentAttacher(t) | |
| 91 | + | set.attach = attacher | |
| 92 | + | ret := portsmocks.NewMockContentReturn(t) | |
| 93 | + | attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once() | |
| 94 | + | attacher.EXPECT().DetachOutput(ports.ContentReturn(ret)).Return().Once() | |
| 95 | + | set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 96 | + | <-ctx.Done() | |
| 97 | + | return nil | |
| 98 | + | }) | |
| 99 | + | if err := set.wait(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | } | |
M internal/app/drm.go +4 -8
| @@ -333,6 +333,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 333 | 333 | defer b.seat.Unsubscribe(active) | |
| 334 | 334 | seatActive := true // openDRM waits for the first seat enable | |
| 335 | 335 | set := newOutputSet(ctx, ch.captured) | |
| 336 | + | set.attach = ch.content | |
| 336 | 337 | set.wireSecurity(ch) | |
| 337 | 338 | protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected } | |
| 338 | 339 | leases := newLeasePublisher(ctx, ch.leaseEvents, func() bool { return seatActive }, protected, log) | |
| @@ -458,7 +459,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 458 | 459 | for _, o := range added { | |
| 459 | 460 | // Every physical lifetime gets the gate before any early exit: | |
| 460 | 461 | // Close must not restore saved desktop, even on a name collision. | |
| 461 | - | o.Security = ch.security | |
| 462 | + | o.Security, o.Frames = ch.security, ch.frames | |
| 462 | 463 | name := o.Info().Name | |
| 463 | 464 | if other := cards[name]; other != nil && other != c { | |
| 464 | 465 | // Core and clients know outputs by connector name. | |
| @@ -513,7 +514,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 513 | 514 | } | |
| 514 | 515 | active := b.seat.Subscribe() | |
| 515 | 516 | realtime := currentConfig.Performance.Realtime | |
| 516 | - | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 517 | + | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 517 | 518 | defer b.seat.Unsubscribe(active) | |
| 518 | 519 | o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance | |
| 519 | 520 | return safe("output "+name, func() error { | |
| @@ -533,7 +534,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 533 | 534 | schedLog.Warn().Err(err).Msg("output scheduling") | |
| 534 | 535 | } | |
| 535 | 536 | } | |
| 536 | - | return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured) | |
| 537 | + | return o.Run(octx, newRenderer, loadCursor, active, sc, cc, rc, cu, ch.presented, cap, ch.captured) | |
| 537 | 538 | }) | |
| 538 | 539 | }, &o.SecurityEvents) | |
| 539 | 540 | if !started { | |
| @@ -595,7 +596,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 595 | 596 | configs, configNext := apply.configOut(ch.configured), apply.config | |
| 596 | 597 | replies, replyNext := apply.replyOut(ch.replies) | |
| 597 | 598 | heads, headsNext := apply.headsOut(ch.heads) | |
| 598 | - | contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents) | |
| 599 | 599 | select { | |
| 600 | 600 | case <-ctx.Done(): | |
| 601 | 601 | return set.wait() | |
| @@ -700,10 +700,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 700 | 700 | complete(progress.timeout(op)) | |
| 701 | 701 | case s := <-ch.scenes: | |
| 702 | 702 | set.scenes(s) | |
| 703 | - | case c := <-contents: | |
| 704 | - | set.content(c) | |
| 705 | - | case contentOut <- contentNext: | |
| 706 | - | contentOwner.contentSent() | |
| 707 | 703 | case q := <-ch.captures: | |
| 708 | 704 | set.routeCapture(q) | |
| 709 | 705 | case c := <-ch.cursorChanges: | |
M internal/app/headless.go +4 -8
| @@ -27,6 +27,7 @@ type headlessOptions struct { | |||
| 27 | 27 | // newRenderer is configured for virtual outputs by newVulkanRenderer. | |
| 28 | 28 | func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 29 | 29 | set := newOutputSet(ctx, ch.captured) | |
| 30 | + | set.attach = ch.content | |
| 30 | 31 | set.wireSecurity(ch) | |
| 31 | 32 | inventory := ports.OutputHeads{} | |
| 32 | 33 | for i, size := range o.sizes { | |
| @@ -43,10 +44,10 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 43 | 44 | } | |
| 44 | 45 | cur := &headless.Cursor{} | |
| 45 | 46 | curs.set(name, cur) | |
| 46 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 47 | - | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 47 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured} | |
| 48 | + | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 48 | 49 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | |
| 49 | - | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| 50 | + | return headless.Run(octx, opts, sc, cc, rc, cu, cap) | |
| 50 | 51 | }, &opts.SecurityEvents) | |
| 51 | 52 | if !started { | |
| 52 | 53 | return set.wait() | |
| @@ -68,7 +69,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 68 | 69 | configs, configNext := apply.configOut(ch.configured), apply.config | |
| 69 | 70 | replies, replyNext := apply.replyOut(ch.replies) | |
| 70 | 71 | heads, headsNext := apply.headsOut(ch.heads) | |
| 71 | - | contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents) | |
| 72 | 72 | select { | |
| 73 | 73 | case <-ctx.Done(): | |
| 74 | 74 | return set.wait() | |
| @@ -92,10 +92,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 92 | 92 | apply.headsSent() | |
| 93 | 93 | case s := <-ch.scenes: | |
| 94 | 94 | set.scenes(s) | |
| 95 | - | case c := <-contents: | |
| 96 | - | set.content(c) | |
| 97 | - | case contentOut <- contentNext: | |
| 98 | - | contentOwner.contentSent() | |
| 99 | 95 | case q := <-ch.captures: | |
| 100 | 96 | set.routeCapture(q) | |
| 101 | 97 | case c := <-ch.cursorChanges: | |
M internal/app/headless_test.go +1 -1
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | // and a headless output falls back to SDR. Skips without Vulkan or without a | |
| 16 | 16 | // device that exports dmabufs (lavapipe in CI). | |
| 17 | 17 | func TestVirtualRendererExportsHDRTargets(t *testing.T) { | |
| 18 | - | r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16) | |
| 18 | + | r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false, false)(64, 16) | |
| 19 | 19 | if err != nil { | |
| 20 | 20 | t.Skipf("Vulkan unavailable: %v", err) | |
| 21 | 21 | } | |
M internal/app/outputrouting.go +5 -3
| @@ -4,7 +4,8 @@ import "github.com/bnema/neferwl/internal/ports" | |||
| 4 | 4 | ||
| 5 | 5 | // outputChannels is the shared app-to-backend wiring. The backend loop remains | |
| 6 | 6 | // the sole owner of outputSet and outputApply. Security producers use bounded | |
| 7 | - | // lossless FIFO forwarders; content backpressure stays inside the backend select. | |
| 7 | + | // lossless FIFO forwarders; contents are not routed here: each output is | |
| 8 | + | // attached to wayland's content feed (contentAttacher). | |
| 8 | 9 | // heads, configured and replies are fed from the outputApply outbox. | |
| 9 | 10 | type outputChannels struct { | |
| 10 | 11 | security ports.SessionSecurity | |
| @@ -14,9 +15,10 @@ type outputChannels struct { | |||
| 14 | 15 | leaseEvents chan<- ports.LeaseMessage | |
| 15 | 16 | events chan<- ports.OutputEvent | |
| 16 | 17 | scenes <-chan []ports.Scene | |
| 17 | - | contents <-chan ports.SurfaceContent | |
| 18 | + | content contentAttacher | |
| 18 | 19 | cursorChanges <-chan ports.CursorChange | |
| 19 | - | presented chan<- ports.OutputPresented | |
| 20 | + | presented ports.PresentationSink | |
| 21 | + | frames chan<- ports.OutputFrame | |
| 20 | 22 | captures <-chan ports.CaptureRequest | |
| 21 | 23 | captured chan<- ports.CaptureDone | |
| 22 | 24 | formats chan<- ports.OutputFormats | |
M internal/app/outputrouting_test.go +2 -15
| @@ -14,7 +14,7 @@ func TestOutputRouting(t *testing.T) { | |||
| 14 | 14 | replies := make(chan ports.CaptureDone, 2) | |
| 15 | 15 | set := newOutputSet(ctx, replies) | |
| 16 | 16 | for _, name := range []string{"A", "B"} { | |
| 17 | - | set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 17 | + | set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 18 | 18 | <-ctx.Done() | |
| 19 | 19 | return nil | |
| 20 | 20 | }) | |
| @@ -28,13 +28,6 @@ func TestOutputRouting(t *testing.T) { | |||
| 28 | 28 | if got := <-set.outs["B"].scenes; got.Seq != 2 { | |
| 29 | 29 | t.Fatalf("B scene: %+v", got) | |
| 30 | 30 | } | |
| 31 | - | content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}} | |
| 32 | - | set.content(content) | |
| 33 | - | for _, name := range []string{"A", "B"} { | |
| 34 | - | if got := <-set.outs[name].contents; got.ID != content.ID { | |
| 35 | - | t.Fatalf("%s content: %+v", name, got) | |
| 36 | - | } | |
| 37 | - | } | |
| 38 | 31 | set.setCursor(ports.CursorChange{Shape: "text"}) | |
| 39 | 32 | set.setCursor(ports.CursorChange{Shape: "pointer"}) | |
| 40 | 33 | for _, name := range []string{"A", "B"} { | |
| @@ -86,13 +79,9 @@ func TestOutputSetRemembersOff(t *testing.T) { | |||
| 86 | 79 | ||
| 87 | 80 | func TestOutputRoutingAllocations(t *testing.T) { | |
| 88 | 81 | set := newOutputSet(context.Background(), nil) | |
| 89 | - | // Populate the existing content slot before measuring, so the guard | |
| 90 | - | // measures routing rather than first-use map growth. | |
| 91 | - | content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}} | |
| 92 | - | set.content(content) | |
| 93 | 82 | ctx, cancel := context.WithCancel(context.Background()) | |
| 94 | 83 | defer cancel() | |
| 95 | - | set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 84 | + | set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 96 | 85 | <-ctx.Done() | |
| 97 | 86 | return nil | |
| 98 | 87 | }) | |
| @@ -101,8 +90,6 @@ func TestOutputRoutingAllocations(t *testing.T) { | |||
| 101 | 90 | capture := ports.CaptureRequest{Output: "A"} | |
| 102 | 91 | if got := testing.AllocsPerRun(100, func() { | |
| 103 | 92 | set.scenes(scenes) | |
| 104 | - | <-set.outs["A"].contents | |
| 105 | - | set.content(content) | |
| 106 | 93 | set.setCursor(cursor) | |
| 107 | 94 | set.routeCapture(capture) | |
| 108 | 95 | <-set.outs["A"].captures | |
M internal/app/outputs.go +44 -64
| @@ -13,16 +13,31 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | // outputRun drives one output until ctx ends: it renders the scenes and | |
| 15 | 15 | // surface contents it receives, with the cursor the client asks for. | |
| 16 | - | type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error | |
| 16 | + | type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error | |
| 17 | + | ||
| 18 | + | // contentAttacher connects an output to the surface contents: wayland feeds | |
| 19 | + | // every attached output batches of window contents, starting with the latest | |
| 20 | + | // content of each window, and takes the batches back through the returned | |
| 21 | + | // ports.ContentReturn. AttachOutput may block on the display goroutine: call it | |
| 22 | + | // from outside that goroutine. It returns a nil channel when wayland is gone. | |
| 23 | + | // The returned ports.ContentReturn is also the handle of the feed: attaching | |
| 24 | + | // a name that already has a feed replaces it, and DetachOutput(handle) stops | |
| 25 | + | // that feed only, so a stale handle never stops a newer feed of the same | |
| 26 | + | // name. Detach an output once it stopped. | |
| 27 | + | type contentAttacher interface { | |
| 28 | + | AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) | |
| 29 | + | DetachOutput(feed ports.ContentReturn) | |
| 30 | + | } | |
| 17 | 31 | ||
| 18 | 32 | // outputSet owns the running outputs and feeds them. Core sends one scene | |
| 19 | - | // per output; each goes to its output, latest first. Surface contents go to | |
| 20 | - | // every output, since windows move between outputs; the latest content of | |
| 21 | - | // each window is kept so a new output starts with every window drawn. | |
| 33 | + | // per output; each goes to its output, latest first. Surface contents reach | |
| 34 | + | // every output (windows move between outputs) straight from wayland through | |
| 35 | + | // attach, which also replays the current windows to a new output. | |
| 22 | 36 | // Only the goroutine running loop touches the set. | |
| 23 | 37 | type outputSet struct { | |
| 24 | - | outs map[string]*runningOutput | |
| 25 | - | latest map[ports.WindowID]ports.SurfaceContent | |
| 38 | + | outs map[string]*runningOutput | |
| 39 | + | // attach is nil when no wayland server feeds contents (tests). | |
| 40 | + | attach contentAttacher | |
| 26 | 41 | // off is the latest Scene.Off per output name, kept after the output | |
| 27 | 42 | // goes: it reconnects off until core's first scene (Output.StartOff). | |
| 28 | 43 | off map[string]bool | |
| @@ -50,22 +65,23 @@ type runningOutput struct { | |||
| 50 | 65 | instance ports.OutputInstance | |
| 51 | 66 | security chan ports.SecurityState | |
| 52 | 67 | securityEvents chan ports.SecurityBackendEvent | |
| 53 | - | pending []ports.SurfaceContent | |
| 54 | 68 | scenes chan ports.Scene | |
| 55 | - | contents chan ports.SurfaceContent | |
| 56 | 69 | cursor chan ports.CursorChange | |
| 57 | 70 | captures chan ports.CaptureRequest | |
| 58 | 71 | ctx context.Context | |
| 59 | 72 | stop context.CancelFunc | |
| 60 | 73 | err error | |
| 61 | 74 | done chan struct{} | |
| 75 | + | // feed is the content feed AttachOutput returned (nil: none), the handle | |
| 76 | + | // to detach once the output stopped. | |
| 77 | + | feed ports.ContentReturn | |
| 62 | 78 | } | |
| 63 | 79 | ||
| 64 | 80 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | |
| 65 | - | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})} | |
| 81 | + | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})} | |
| 66 | 82 | } | |
| 67 | 83 | ||
| 68 | - | // start runs an output in its own goroutine and replays the window contents. | |
| 84 | + | // start runs an output in its own goroutine, fed the window contents. | |
| 69 | 85 | func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire ...*chan<- ports.SecurityBackendEvent) bool { | |
| 70 | 86 | // Exhaustion is permanent: never wrap to zero or reuse a lifetime. | |
| 71 | 87 | if s.nextInstance == ^ports.OutputInstance(0) { | |
| @@ -82,22 +98,22 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire | |||
| 82 | 98 | s.setSecurity(s.security.Snapshot()) | |
| 83 | 99 | } | |
| 84 | 100 | octx, stop := context.WithCancel(ctx) | |
| 85 | - | r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})} | |
| 86 | - | r.security <- s.state // available before any old scene/content replay | |
| 101 | + | r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})} | |
| 102 | + | r.security <- s.state // available before any content replay | |
| 87 | 103 | r.securityEvents = s.forwardSecurity() | |
| 88 | 104 | for _, destination := range wire { | |
| 89 | 105 | *destination = r.securityEvents | |
| 90 | 106 | } | |
| 91 | - | for _, c := range s.latest { | |
| 92 | - | select { | |
| 93 | - | case r.contents <- c: | |
| 94 | - | default: | |
| 95 | - | r.pending = append(r.pending, c) | |
| 96 | - | } | |
| 107 | + | // After the security state above: the replay of the windows starts here. | |
| 108 | + | var contents <-chan *ports.ContentBatch | |
| 109 | + | var returns ports.ContentReturn | |
| 110 | + | if s.attach != nil { | |
| 111 | + | contents, returns = s.attach.AttachOutput(name) | |
| 97 | 112 | } | |
| 113 | + | r.feed = returns | |
| 98 | 114 | s.outs[name] = r | |
| 99 | 115 | go func() { | |
| 100 | - | r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures, r.security, r.instance) | |
| 116 | + | r.err = run(octx, r.scenes, contents, returns, r.cursor, r.captures, r.security, r.instance) | |
| 101 | 117 | if r.securityEvents != nil { | |
| 102 | 118 | close(r.securityEvents) | |
| 103 | 119 | } | |
| @@ -229,51 +245,6 @@ func (s *outputSet) scenes(set []ports.Scene) { | |||
| 229 | 245 | } | |
| 230 | 246 | } | |
| 231 | 247 | ||
| 232 | - | func (s *outputSet) content(c ports.SurfaceContent) { | |
| 233 | - | if c.Empty() { | |
| 234 | - | delete(s.latest, c.ID) | |
| 235 | - | } else { | |
| 236 | - | s.latest[c.ID] = c | |
| 237 | - | } | |
| 238 | - | for _, r := range s.outs { | |
| 239 | - | if r.ctx.Err() != nil { | |
| 240 | - | continue | |
| 241 | - | } | |
| 242 | - | if len(r.pending) == 0 { | |
| 243 | - | select { | |
| 244 | - | case r.contents <- c: | |
| 245 | - | continue | |
| 246 | - | default: | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | // The backend disabled intake until the preceding broadcast drained. | |
| 250 | - | if cap(r.pending) == 0 { | |
| 251 | - | r.pending = make([]ports.SurfaceContent, 0, 1) | |
| 252 | - | } | |
| 253 | - | r.pending = append(r.pending, c) | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // At most one admitted broadcast waits behind each owner's finite replay. | |
| 258 | - | // Disable new content intake until all copies have been delivered. | |
| 259 | - | func (s *outputSet) contentOut(incoming <-chan ports.SurfaceContent) (<-chan ports.SurfaceContent, chan<- ports.SurfaceContent, ports.SurfaceContent, *runningOutput) { | |
| 260 | - | for _, r := range s.outs { | |
| 261 | - | if len(r.pending) != 0 && r.ctx.Err() == nil { | |
| 262 | - | return nil, r.contents, r.pending[0], r | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | return incoming, nil, ports.SurfaceContent{}, nil | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | func (r *runningOutput) contentSent() { | |
| 269 | - | r.pending[0] = ports.SurfaceContent{} | |
| 270 | - | if len(r.pending) == 1 { | |
| 271 | - | r.pending = r.pending[:0] // retain the one-slot broadcast buffer | |
| 272 | - | } else { | |
| 273 | - | r.pending = r.pending[1:] | |
| 274 | - | } | |
| 275 | - | } | |
| 276 | - | ||
| 277 | 248 | // finish forgets a stopped output and returns its error. | |
| 278 | 249 | func (s *outputSet) finish(name string) error { | |
| 279 | 250 | r := s.outs[name] | |
| @@ -283,10 +254,18 @@ func (s *outputSet) finish(name string) error { | |||
| 283 | 254 | <-r.done | |
| 284 | 255 | r.stop() | |
| 285 | 256 | s.failQueued(r) | |
| 257 | + | s.detach(r) | |
| 286 | 258 | delete(s.outs, name) | |
| 287 | 259 | return r.err | |
| 288 | 260 | } | |
| 289 | 261 | ||
| 262 | + | // detach stops the content feed of an output that has stopped. | |
| 263 | + | func (s *outputSet) detach(r *runningOutput) { | |
| 264 | + | if s.attach != nil && r.feed != nil { | |
| 265 | + | s.attach.DetachOutput(r.feed) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 290 | 269 | // wait stops every output and returns the first error. | |
| 291 | 270 | func (s *outputSet) wait() error { | |
| 292 | 271 | var first error = s.startErr | |
| @@ -301,6 +280,7 @@ func (s *outputSet) wait() error { | |||
| 301 | 280 | r.stop() | |
| 302 | 281 | <-r.done | |
| 303 | 282 | s.failQueued(r) | |
| 283 | + | s.detach(r) | |
| 304 | 284 | if first == nil && r.err != nil { | |
| 305 | 285 | first = r.err | |
| 306 | 286 | } | |
M internal/app/outputs_test.go +1 -1
| @@ -17,7 +17,7 @@ func TestOutputSetCursor(t *testing.T) { | |||
| 17 | 17 | run := func(name string) outputRun { | |
| 18 | 18 | got[name] = make(chan ports.CursorChange, 8) | |
| 19 | 19 | out := got[name] | |
| 20 | - | return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 20 | + | return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 21 | 21 | for { | |
| 22 | 22 | select { | |
| 23 | 23 | case <-ctx.Done(): | |
M internal/app/security_duplicate_test.go +1 -1
| @@ -27,7 +27,7 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T | |||
| 27 | 27 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 28 | 28 | set := newOutputSet(context.Background(), nil) | |
| 29 | 29 | set.wireSecurity(outputChannels{security: gate, securityEvents: events}) | |
| 30 | - | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 30 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 31 | 31 | <-ctx.Done() | |
| 32 | 32 | return nil | |
| 33 | 33 | } | |
M internal/app/security_hotplug_test.go +4 -4
| @@ -66,7 +66,7 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) { | |||
| 66 | 66 | commands := make(chan []ports.SecurityBackendEvent, 1) | |
| 67 | 67 | closed := make(chan bool, 1) | |
| 68 | 68 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 69 | - | if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 69 | + | if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 70 | 70 | defer func() { closed <- closeSuccess }() | |
| 71 | 71 | state := <-security | |
| 72 | 72 | proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}} | |
| @@ -205,7 +205,7 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) { | |||
| 205 | 205 | startDone := make(chan bool, 1) | |
| 206 | 206 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 207 | 207 | go func() { | |
| 208 | - | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 208 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 209 | 209 | for i := range 40 { | |
| 210 | 210 | var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance} | |
| 211 | 211 | if i%2 == 0 { | |
| @@ -295,7 +295,7 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) { | |||
| 295 | 295 | startDone := make(chan bool, 1) | |
| 296 | 296 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 297 | 297 | go func() { | |
| 298 | - | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 298 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 299 | 299 | for i := range 16 { | |
| 300 | 300 | event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance} | |
| 301 | 301 | select { | |
| @@ -350,7 +350,7 @@ func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) { | |||
| 350 | 350 | startDone := make(chan bool, 1) | |
| 351 | 351 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 352 | 352 | go func() { | |
| 353 | - | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 353 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 354 | 354 | defer close(ownerDone) | |
| 355 | 355 | for i := range 40 { | |
| 356 | 356 | select { | |
M internal/app/security_test.go +8 -69
| @@ -21,7 +21,7 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) { | |||
| 21 | 21 | started := make(chan ports.OutputInstance, 1) | |
| 22 | 22 | result := make(chan bool, 1) | |
| 23 | 23 | go func() { | |
| 24 | - | result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 24 | + | result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 25 | 25 | state := <-security | |
| 26 | 26 | if state != (ports.SecurityState{Generation: 7, Protected: true}) { | |
| 27 | 27 | return errors.New("missing startup protection") | |
| @@ -67,7 +67,7 @@ func TestSecurityReplacementAndStaleState(t *testing.T) { | |||
| 67 | 67 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 68 | 68 | set := newOutputSet(ctx, nil) | |
| 69 | 69 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 70 | - | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 70 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 71 | 71 | <-ctx.Done() | |
| 72 | 72 | return nil | |
| 73 | 73 | } | |
| @@ -167,7 +167,7 @@ func TestSecurityInstanceExhaustion(t *testing.T) { | |||
| 167 | 167 | events := make(chan ports.SecurityBackendEvent, 2) | |
| 168 | 168 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 169 | 169 | set.nextInstance = ^ports.OutputInstance(0) - 1 | |
| 170 | - | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 170 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 171 | 171 | <-ctx.Done() | |
| 172 | 172 | return nil | |
| 173 | 173 | } | |
| @@ -195,26 +195,22 @@ func TestSecurityInstanceExhaustion(t *testing.T) { | |||
| 195 | 195 | } | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | - | // The shared display event channel and an output content queue can both be | |
| 199 | - | // saturated. Routing must retain the next content without pinning the backend | |
| 200 | - | // outside its select; a lease barrier can then finish before either is drained. | |
| 201 | - | func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) { | |
| 198 | + | // A saturated shared display event channel must not pin the backend outside | |
| 199 | + | // its select: a lease barrier can finish before the channel is drained. | |
| 200 | + | func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) { | |
| 202 | 201 | ctx, cancel := context.WithCancel(context.Background()) | |
| 203 | 202 | defer cancel() | |
| 204 | 203 | set := newOutputSet(ctx, nil) | |
| 205 | 204 | events := make(chan ports.SecurityBackendEvent, 1) | |
| 206 | 205 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 207 | 206 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 208 | - | set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 207 | + | set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 209 | 208 | <-ctx.Done() | |
| 210 | 209 | return nil | |
| 211 | 210 | }, &ownerEvents) | |
| 212 | 211 | <-events // ordered registration | |
| 213 | 212 | events <- ports.SecurityBackendBarrier{Generation: 99} // blocked display | |
| 214 | 213 | r := set.outs["DP-1"] | |
| 215 | - | for i := range cap(r.contents) { | |
| 216 | - | r.contents <- ports.SurfaceContent{ID: ports.WindowID(i + 1)} | |
| 217 | - | } | |
| 218 | 214 | first := ports.SecurityOutputInvalidated{Generation: 1, Instance: r.instance} | |
| 219 | 215 | second := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: 1, Instance: r.instance, Kind: ports.ProtectionProtectedFrame}} | |
| 220 | 216 | ownerEvents <- first | |
| @@ -225,7 +221,6 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) { | |||
| 225 | 221 | card.EXPECT().Revoke(uint32(9)).Return(nil).Once() | |
| 226 | 222 | progressed := make(chan error, 1) | |
| 227 | 223 | go func() { | |
| 228 | - | set.content(ports.SurfaceContent{ID: 70}) | |
| 229 | 224 | set.setSecurity(ports.SecurityState{Generation: 1, Protected: true}) | |
| 230 | 225 | err := revokeSecurityLeases(card, func(uint32) {}) | |
| 231 | 226 | set.securityEvent(ports.SecurityBackendBarrier{Generation: 1, Err: err}) | |
| @@ -237,23 +232,7 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) { | |||
| 237 | 232 | t.Fatal(err) | |
| 238 | 233 | } | |
| 239 | 234 | case <-time.After(time.Second): | |
| 240 | - | t.Fatal("content/event backpressure blocked lease transition") | |
| 241 | - | } | |
| 242 | - | incoming := make(chan ports.SurfaceContent) | |
| 243 | - | contentIn, output, next, owner := set.contentOut(incoming) | |
| 244 | - | if contentIn != nil || output != r.contents || owner != r || next.ID != 70 || len(r.pending) != 1 { | |
| 245 | - | t.Fatal("pending content not bounded/preserved") | |
| 246 | - | } | |
| 247 | - | for range cap(r.contents) { | |
| 248 | - | <-r.contents | |
| 249 | - | } | |
| 250 | - | output <- next | |
| 251 | - | owner.contentSent() | |
| 252 | - | if got := <-r.contents; got.ID != 70 { | |
| 253 | - | t.Fatal("lost pending content") | |
| 254 | - | } | |
| 255 | - | if contentIn, _, _, _ := set.contentOut(incoming); contentIn != incoming { | |
| 256 | - | t.Fatal("content intake stayed disabled") | |
| 235 | + | t.Fatal("event backpressure blocked lease transition") | |
| 257 | 236 | } | |
| 258 | 237 | <-events // unblock display | |
| 259 | 238 | var ownerSequence []ports.SecurityBackendEvent | |
| @@ -275,43 +254,3 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) { | |||
| 275 | 254 | t.Fatal(err) | |
| 276 | 255 | } | |
| 277 | 256 | } | |
| 278 | - | ||
| 279 | - | func TestSecurityLargeReplayDoesNotBlockStart(t *testing.T) { | |
| 280 | - | set := newOutputSet(context.Background(), nil) | |
| 281 | - | for i := range 100 { | |
| 282 | - | set.content(ports.SurfaceContent{ID: ports.WindowID(i + 1), SHM: &ports.SHMBuffer{}}) | |
| 283 | - | } | |
| 284 | - | if !set.start(context.Background(), "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |
| 285 | - | <-ctx.Done() | |
| 286 | - | return nil | |
| 287 | - | }) { | |
| 288 | - | t.Fatal("start failed") | |
| 289 | - | } | |
| 290 | - | r := set.outs["A"] | |
| 291 | - | if len(r.contents) != cap(r.contents) || len(r.pending) != 100-cap(r.contents) { | |
| 292 | - | t.Fatal("replay not preserved") | |
| 293 | - | } | |
| 294 | - | seen := map[ports.WindowID]bool{} | |
| 295 | - | for len(seen) != 100 { | |
| 296 | - | select { | |
| 297 | - | case c := <-r.contents: | |
| 298 | - | if seen[c.ID] { | |
| 299 | - | t.Fatal("duplicate replay") | |
| 300 | - | } | |
| 301 | - | seen[c.ID] = true | |
| 302 | - | default: | |
| 303 | - | _, out, c, owner := set.contentOut(nil) | |
| 304 | - | if out == nil { | |
| 305 | - | t.Fatal("replay lost") | |
| 306 | - | } | |
| 307 | - | out <- c | |
| 308 | - | owner.contentSent() | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | if len(r.pending) != 0 { | |
| 312 | - | t.Fatal("replay remains") | |
| 313 | - | } | |
| 314 | - | if err := set.wait(); err != nil { | |
| 315 | - | t.Fatal(err) | |
| 316 | - | } | |
| 317 | - | } | |
M internal/app/session.go +9 -7
| @@ -123,6 +123,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 123 | 123 | Keymap: s.km.String(), | |
| 124 | 124 | RepeatRate: opts.Config.Keyboard.RepeatRate, | |
| 125 | 125 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | |
| 126 | + | LogDamage: logging.Enabled(ctx, "wayland-damage"), | |
| 126 | 127 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | |
| 127 | 128 | return s, err | |
| 128 | 129 | } | |
| @@ -145,10 +146,11 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 145 | 146 | s.path = config.DefaultPath() | |
| 146 | 147 | } | |
| 147 | 148 | renderLog := logging.For(ctx, "render") | |
| 149 | + | traceDamage := logging.Enabled(ctx, "render-damage") | |
| 148 | 150 | if s.hw != nil { | |
| 149 | - | s.newRenderer = newVulkanRenderer(renderLog, false, false) | |
| 151 | + | s.newRenderer = newVulkanRenderer(renderLog, false, false, traceDamage) | |
| 150 | 152 | } else { | |
| 151 | - | s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw) | |
| 153 | + | s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw, traceDamage) | |
| 152 | 154 | } | |
| 153 | 155 | return s, nil | |
| 154 | 156 | } | |
| @@ -182,14 +184,16 @@ func (s *session) close() { | |||
| 182 | 184 | // outputs have no display to list modifiers, so a headless renderer is told | |
| 183 | 185 | // it drives one: without it a real GPU refuses the HDR targets and the | |
| 184 | 186 | // output falls back to SDR. readback makes the HDR targets readable | |
| 185 | - | // (HDRPixels). These settings are Vulkan-only and not on ports.Renderer. | |
| 186 | - | func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) { | |
| 187 | + | // (HDRPixels). traceDamage logs every frame's redrawn area. These settings | |
| 188 | + | // are Vulkan-only and not on ports.Renderer. | |
| 189 | + | func newVulkanRenderer(log zerowrap.Logger, virtual, readback, traceDamage bool) func(w, h int) (ports.Renderer, error) { | |
| 187 | 190 | return func(w, h int) (ports.Renderer, error) { | |
| 188 | 191 | r, err := vulkan.New(w, h) | |
| 189 | 192 | if err != nil { | |
| 190 | 193 | return nil, err | |
| 191 | 194 | } | |
| 192 | 195 | r.SetLogger(log) | |
| 196 | + | r.SetTraceDamage(traceDamage) | |
| 193 | 197 | log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue") | |
| 194 | 198 | r.SetVirtualOutput(virtual) | |
| 195 | 199 | if readback { | |
| @@ -347,12 +351,10 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 347 | 351 | defer workers.Done() | |
| 348 | 352 | consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes) | |
| 349 | 353 | }() | |
| 350 | - | workers.Add(1) | |
| 351 | - | go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }() | |
| 352 | 354 | ||
| 353 | 355 | go func() { | |
| 354 | 356 | defer workers.Done() | |
| 355 | - | outputIO := p.outputs(security) | |
| 357 | + | outputIO := p.outputs(security, server.Presentation(), server) | |
| 356 | 358 | apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | |
| 357 | 359 | if hw != nil { | |
| 358 | 360 | done <- safe("output", func() error { | |
M internal/app/wiring.go +8 -15
| @@ -41,12 +41,9 @@ type pipes struct { | |||
| 41 | 41 | // config file. | |
| 42 | 42 | scales chan ports.ScaleChanged | |
| 43 | 43 | ||
| 44 | - | contents chan ports.SurfaceContent | |
| 45 | - | presented chan ports.OutputPresented | |
| 46 | - | // Outputs report on flips; relayPresented passes them to wayland | |
| 47 | - | // (presented) and tells core about each flip (frames) to move running | |
| 48 | - | // slides. | |
| 49 | - | flips chan ports.OutputPresented | |
| 44 | + | // Outputs report to wayland through its presentation sink and, for each | |
| 45 | + | // flip of an animating scene (Scene.Animating), tell core (frames) to | |
| 46 | + | // move running slides. | |
| 50 | 47 | frames chan ports.OutputFrame | |
| 51 | 48 | ||
| 52 | 49 | captures chan ports.CaptureRequest | |
| @@ -94,10 +91,7 @@ func newPipes(realSession bool) *pipes { | |||
| 94 | 91 | configured: make(chan ports.ConfigChanged, 8), | |
| 95 | 92 | configErrors: make(chan error, 8), | |
| 96 | 93 | ||
| 97 | - | contents: make(chan ports.SurfaceContent, 64), | |
| 98 | - | presented: make(chan ports.OutputPresented, 64), | |
| 99 | - | flips: make(chan ports.OutputPresented, 64), | |
| 100 | - | frames: make(chan ports.OutputFrame, 8), | |
| 94 | + | frames: make(chan ports.OutputFrame, 8), | |
| 101 | 95 | ||
| 102 | 96 | captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight), | |
| 103 | 97 | captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight), | |
| @@ -147,9 +141,7 @@ func (p *pipes) wayland() wayland.Channels { | |||
| 147 | 141 | Events: p.client, | |
| 148 | 142 | Commands: p.commands, | |
| 149 | 143 | Workspaces: p.workspaces, | |
| 150 | - | Contents: p.contents, | |
| 151 | 144 | Cursors: p.cursorChanges, | |
| 152 | - | Presented: p.presented, | |
| 153 | 145 | Captures: p.captures, | |
| 154 | 146 | Captured: p.captured, | |
| 155 | 147 | OutputFormats: p.outputFormats, | |
| @@ -164,16 +156,17 @@ func (p *pipes) wayland() wayland.Channels { | |||
| 164 | 156 | } | |
| 165 | 157 | ||
| 166 | 158 | // outputs is the outputs' side (DRM or headless). | |
| 167 | - | func (p *pipes) outputs(security ports.SessionSecurity) outputChannels { | |
| 159 | + | func (p *pipes) outputs(security ports.SessionSecurity, presented ports.PresentationSink, content contentAttacher) outputChannels { | |
| 168 | 160 | return outputChannels{ | |
| 169 | 161 | security: security, | |
| 170 | 162 | securityChanges: p.securityChanges, | |
| 171 | 163 | securityEvents: p.securityEvents, | |
| 172 | 164 | events: p.output, | |
| 173 | 165 | scenes: p.renderScenes, | |
| 174 | - | contents: p.contents, | |
| 175 | 166 | cursorChanges: p.cursorChanges, | |
| 176 | - | presented: p.flips, | |
| 167 | + | presented: presented, | |
| 168 | + | content: content, | |
| 169 | + | frames: p.frames, | |
| 177 | 170 | captures: p.captures, | |
| 178 | 171 | captured: p.captured, | |
| 179 | 172 | formats: p.outputFormats, | |
M internal/cli/run.go +1 -1
| @@ -39,7 +39,7 @@ func (f *runFlags) register(fs *flag.FlagSet) { | |||
| 39 | 39 | fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | |
| 40 | 40 | fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output") | |
| 41 | 41 | fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)") | |
| 42 | - | fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)") | |
| 42 | + | fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)") | |
| 43 | 43 | fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)") | |
| 44 | 44 | fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | |
| 45 | 45 | fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | |
M internal/core/core.go +17 -4
| @@ -43,8 +43,9 @@ type Channels struct { | |||
| 43 | 43 | // Scales, when set, receives each scale a scale bind sets, to persist it. | |
| 44 | 44 | // Core does not wait: a full channel drops the change. | |
| 45 | 45 | Scales chan<- ports.ScaleChanged | |
| 46 | - | // Frames, when set, reports outputs' page flips: a running slide moves | |
| 47 | - | // one step per flip. Without flips it moves on a timer. | |
| 46 | + | // Frames, when set, carries the outputs' reports of each flip of an | |
| 47 | + | // animating scene (Scene.Animating): a running slide moves one step per | |
| 48 | + | // report. Without them it moves on a timer. | |
| 48 | 49 | Frames <-chan ports.OutputFrame | |
| 49 | 50 | } | |
| 50 | 51 | ||
| @@ -650,13 +651,25 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 650 | 651 | return err | |
| 651 | 652 | } | |
| 652 | 653 | drawable = drawable || shown | |
| 653 | - | sc.last = scene | |
| 654 | 654 | scenes = append(scenes, scene) | |
| 655 | 655 | } | |
| 656 | 656 | c.configures.prune() | |
| 657 | 657 | // A pulse nothing shows (fullscreen, overview) asks for no frames. | |
| 658 | - | if c.pulse.drawable = drawable; !drawable { | |
| 658 | + | if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 { | |
| 659 | 659 | c.pulse.id = 0 | |
| 660 | + | // The focused scene was built as animating: publish it settled, | |
| 661 | + | // or its flips would keep waking core. A scene that kept its Seq | |
| 662 | + | // needs a fresh one, or outputs keep the animating one they show. | |
| 663 | + | if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() { | |
| 664 | + | scenes[i].Animating = false | |
| 665 | + | if scenes[i].Seq == c.cur().last.Seq { | |
| 666 | + | c.seq++ | |
| 667 | + | scenes[i].Seq = c.seq | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | for i, sc := range c.screens { | |
| 672 | + | sc.last = scenes[i] | |
| 660 | 673 | } | |
| 661 | 674 | // A workspace switch can hide the window under the pointer; it must not get | |
| 662 | 675 | // clicks. The next motion re-runs hit-testing. | |
M internal/core/scene.go +10 -5
| @@ -123,16 +123,21 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 123 | 123 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 124 | 124 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 125 | 125 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 126 | + | // The springs of this frame are advanced: a scene shown while one still | |
| 127 | + | // runs wakes core when it flips; the one a spring settles on does not. | |
| 128 | + | scene.Animating = c.sliding(sc.name()) | |
| 126 | 129 | // A scene carrying a capture image always gets a fresh Seq, and so | |
| 127 | 130 | // does one of an animating screen (the one being stepped, or any | |
| 128 | 131 | // when none is): the outputs drop a scene they already show, and a | |
| 129 | 132 | // spring that starts at the previous scene, or rounds to it in its | |
| 130 | - | // tail, would get no flip and wait for the fallback timer. Any other | |
| 131 | - | // keeps its output's Seq while it draws the same, so an idle output | |
| 132 | - | // is not recomposed. | |
| 133 | + | // tail, would get no flip and wait for the fallback timer. So does the | |
| 134 | + | // scene that flips Animating off: SameAs ignores the flag, and keeping | |
| 135 | + | // the Seq of the animating scene would leave outputs showing it, and | |
| 136 | + | // waking core on every later flip. Any other keeps its output's Seq | |
| 137 | + | // while it draws the same, so an idle output is not recomposed. | |
| 133 | 138 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | |
| 134 | - | animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc) | |
| 135 | - | if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 139 | + | animating := scene.Animating && (only == nil || only == sc) | |
| 140 | + | if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 136 | 141 | scene.Seq = sc.last.Seq | |
| 137 | 142 | } else { | |
| 138 | 143 | c.seq++ | |
A internal/core/scene_animating_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // A scene published while a spring runs carries Animating, so its flip wakes | |
| 12 | + | // core for the next frame; the scene the spring settles on does not, and | |
| 13 | + | // neither does any scene of an idle output. | |
| 14 | + | func TestSceneAnimatingFollowsSprings(t *testing.T) { | |
| 15 | + | c, ic := indicatorCore(t) | |
| 16 | + | c.cfg.Animations.On = true | |
| 17 | + | a := c.cur() | |
| 18 | + | a.mon.AddWindow(1) | |
| 19 | + | a.mon.AddWindow(2) | |
| 20 | + | if s := indicatorScene(t, c); s.Animating { | |
| 21 | + | t.Fatal("an idle output publishes an animating scene") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // A slide starts. | |
| 25 | + | w := a.mon.Current() | |
| 26 | + | w.view.off = 100 | |
| 27 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 28 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 29 | + | first := stepScenes(t, c, a)["A"] | |
| 30 | + | if !first.Animating { | |
| 31 | + | t.Fatal("a scene with a running spring is not animating") | |
| 32 | + | } | |
| 33 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 34 | + | if second := stepScenes(t, c, a)["A"]; !second.Animating || second.Seq == first.Seq { | |
| 35 | + | t.Fatalf("running slide: animating=%v Seq %d (was %d)", second.Animating, second.Seq, first.Seq) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // The step that lands the spring publishes the last scene: nothing more | |
| 39 | + | // is due, so it must not wake core again. | |
| 40 | + | ic.now = ic.now.Add(10 * time.Second) | |
| 41 | + | last := stepScenes(t, c, a)["A"] | |
| 42 | + | if last.Animating { | |
| 43 | + | t.Fatal("the scene a spring settles on is animating") | |
| 44 | + | } | |
| 45 | + | if a.springing() { | |
| 46 | + | t.Fatal("bad setup: the spring still runs") | |
| 47 | + | } | |
| 48 | + | // Idle again: still false, and the Seq is held (no extra recomposition). | |
| 49 | + | if err := c.publish(context.Background()); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | idle := (<-c.ch.Scenes)[0] | |
| 53 | + | if idle.Animating || idle.Seq != last.Seq || !idle.SameAs(last) { | |
| 54 | + | t.Fatalf("idle publish: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, last.Seq) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // A pulse cancelled because nothing can show it (fullscreen) publishes the | |
| 59 | + | // focused scene settled, with a fresh Seq: otherwise outputs keep the | |
| 60 | + | // animating scene and wake core on every flip. | |
| 61 | + | func TestCancelledPulseClearsAnimating(t *testing.T) { | |
| 62 | + | c, ic := pulseCore(t) | |
| 63 | + | c.cur().mon.Current().FocusID(1) | |
| 64 | + | indicatorScene(t, c) | |
| 65 | + | ic.now = ic.now.Add(pulseSettle) | |
| 66 | + | if !c.pulseTick() { | |
| 67 | + | t.Fatal("bad setup: no pulse") | |
| 68 | + | } | |
| 69 | + | running := indicatorScene(t, c) | |
| 70 | + | if !running.Animating { | |
| 71 | + | t.Fatal("bad setup: the pulsing scene is not animating") | |
| 72 | + | } | |
| 73 | + | c.cur().mon.ToggleFullscreen() | |
| 74 | + | cancelled := indicatorScene(t, c) | |
| 75 | + | if cancelled.Animating || c.animating() { | |
| 76 | + | t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating()) | |
| 77 | + | } | |
| 78 | + | if cancelled.Seq == running.Seq { | |
| 79 | + | t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq) | |
| 80 | + | } | |
| 81 | + | idle := indicatorScene(t, c) | |
| 82 | + | if idle.Animating || idle.Seq != cancelled.Seq { | |
| 83 | + | t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Animating is per output: a spring on one does not mark the other. | |
| 88 | + | func TestSceneAnimatingIsPerOutput(t *testing.T) { | |
| 89 | + | c, ic := indicatorCore(t) | |
| 90 | + | c.cfg.Animations.On = true | |
| 91 | + | a := c.cur() | |
| 92 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 93 | + | b := c.screens[1] | |
| 94 | + | a.mon.AddWindow(1) | |
| 95 | + | b.mon.AddWindow(2) | |
| 96 | + | indicatorScene(t, c) | |
| 97 | + | w := b.mon.Current() | |
| 98 | + | w.view.off = 100 | |
| 99 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 100 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 101 | + | got := stepScenes(t, c, b) | |
| 102 | + | if !got["B"].Animating || got["A"].Animating { | |
| 103 | + | t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // A spring that settles on the very scene its last running step drew (the | |
| 108 | + | // tail rounds to the final pixel) must still publish a scene with Animating | |
| 109 | + | // off, with a fresh Seq: SameAs ignores the flag, and the outputs would | |
| 110 | + | // otherwise keep the animating scene they show and wake core on every later | |
| 111 | + | // flip. | |
| 112 | + | func TestSettledSceneSameAsPreviousClearsAnimating(t *testing.T) { | |
| 113 | + | // Commands (rehit) are not read: room for every step. | |
| 114 | + | c, ic := indicatorCoreWith(t, make(chan ports.ClientCommand, 4096)) | |
| 115 | + | c.cfg.Animations.On = true | |
| 116 | + | a := c.cur() | |
| 117 | + | a.mon.AddWindow(1) | |
| 118 | + | a.mon.AddWindow(2) | |
| 119 | + | indicatorScene(t, c) | |
| 120 | + | ||
| 121 | + | w := a.mon.Current() | |
| 122 | + | w.view.off = 100 | |
| 123 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 124 | + | // Step in 1 ms until the spring settles: the snap settles it once it is | |
| 125 | + | // within half a pixel of its target, where the last running scene can | |
| 126 | + | // draw the same pixels as the settled one. | |
| 127 | + | start := ic.now | |
| 128 | + | var running, settled ports.Scene | |
| 129 | + | for ms := 1; ; ms++ { | |
| 130 | + | if ms > 2000 { | |
| 131 | + | t.Fatal("the spring never settles") | |
| 132 | + | } | |
| 133 | + | ic.now = start.Add(time.Duration(ms) * time.Millisecond) | |
| 134 | + | settled = stepScenes(t, c, a)["A"] | |
| 135 | + | if !a.springing() { | |
| 136 | + | break | |
| 137 | + | } | |
| 138 | + | running = settled | |
| 139 | + | } | |
| 140 | + | if !running.Animating { | |
| 141 | + | t.Fatalf("bad setup: last running scene animating=%v", running.Animating) | |
| 142 | + | } | |
| 143 | + | if !settled.SameAs(running) { | |
| 144 | + | t.Fatal("bad setup: the settled scene differs from the last running one") | |
| 145 | + | } | |
| 146 | + | if settled.Animating { | |
| 147 | + | t.Fatal("the settled scene is animating") | |
| 148 | + | } | |
| 149 | + | if settled.Seq == running.Seq { | |
| 150 | + | t.Fatalf("settled scene kept the animating scene's Seq %d", running.Seq) | |
| 151 | + | } | |
| 152 | + | } | |
A internal/mocks/ports/output_events_mock.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package portsmocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewMockPresentationSink creates a new instance of MockPresentationSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewMockPresentationSink(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *MockPresentationSink { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &MockPresentationSink{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MockPresentationSink is an autogenerated mock type for the PresentationSink type | |
| 36 | + | type MockPresentationSink struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type MockPresentationSink_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *MockPresentationSink) EXPECT() *MockPresentationSink_Expecter { | |
| 45 | + | return &MockPresentationSink_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Push provides a mock function for the type MockPresentationSink | |
| 49 | + | func (_mock *MockPresentationSink) Push(outputPresented ports.OutputPresented) { | |
| 50 | + | _mock.Called(outputPresented) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // MockPresentationSink_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push' | |
| 55 | + | type MockPresentationSink_Push_Call struct { | |
| 56 | + | *mock.Call | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Push is a helper method to define mock.On call | |
| 60 | + | // - outputPresented ports.OutputPresented | |
| 61 | + | func (_e *MockPresentationSink_Expecter) Push(outputPresented any) *MockPresentationSink_Push_Call { | |
| 62 | + | return &MockPresentationSink_Push_Call{Call: _e.mock.On("Push", outputPresented)} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (_c *MockPresentationSink_Push_Call) Run(run func(outputPresented ports.OutputPresented)) *MockPresentationSink_Push_Call { | |
| 66 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 67 | + | var arg0 ports.OutputPresented | |
| 68 | + | if args[0] != nil { | |
| 69 | + | arg0 = args[0].(ports.OutputPresented) | |
| 70 | + | } | |
| 71 | + | run( | |
| 72 | + | arg0, | |
| 73 | + | ) | |
| 74 | + | }) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockPresentationSink_Push_Call) Return() *MockPresentationSink_Push_Call { | |
| 79 | + | _c.Call.Return() | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *MockPresentationSink_Push_Call) RunAndReturn(run func(outputPresented ports.OutputPresented)) *MockPresentationSink_Push_Call { | |
| 84 | + | _c.Run(run) | |
| 85 | + | return _c | |
| 86 | + | } | |
A internal/mocks/ports/surface_content_mock.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package portsmocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewMockContentReturn creates a new instance of MockContentReturn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewMockContentReturn(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *MockContentReturn { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &MockContentReturn{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MockContentReturn is an autogenerated mock type for the ContentReturn type | |
| 36 | + | type MockContentReturn struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type MockContentReturn_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *MockContentReturn) EXPECT() *MockContentReturn_Expecter { | |
| 45 | + | return &MockContentReturn_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Return provides a mock function for the type MockContentReturn | |
| 49 | + | func (_mock *MockContentReturn) Return(contentBatch *ports.ContentBatch) { | |
| 50 | + | _mock.Called(contentBatch) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // MockContentReturn_Return_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Return' | |
| 55 | + | type MockContentReturn_Return_Call struct { | |
| 56 | + | *mock.Call | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Return is a helper method to define mock.On call | |
| 60 | + | // - contentBatch *ports.ContentBatch | |
| 61 | + | func (_e *MockContentReturn_Expecter) Return(contentBatch any) *MockContentReturn_Return_Call { | |
| 62 | + | return &MockContentReturn_Return_Call{Call: _e.mock.On("Return", contentBatch)} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (_c *MockContentReturn_Return_Call) Run(run func(contentBatch *ports.ContentBatch)) *MockContentReturn_Return_Call { | |
| 66 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 67 | + | var arg0 *ports.ContentBatch | |
| 68 | + | if args[0] != nil { | |
| 69 | + | arg0 = args[0].(*ports.ContentBatch) | |
| 70 | + | } | |
| 71 | + | run( | |
| 72 | + | arg0, | |
| 73 | + | ) | |
| 74 | + | }) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockContentReturn_Return_Call) Return() *MockContentReturn_Return_Call { | |
| 79 | + | _c.Call.Return() | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *MockContentReturn_Return_Call) RunAndReturn(run func(contentBatch *ports.ContentBatch)) *MockContentReturn_Return_Call { | |
| 84 | + | _c.Run(run) | |
| 85 | + | return _c | |
| 86 | + | } | |
M internal/ports/output_events.go +10 -2
| @@ -94,6 +94,13 @@ type OutputPresented struct { | |||
| 94 | 94 | ChildReads map[WindowID]uint64 | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | // PresentationSink receives output → wayland reports. Push never blocks; | |
| 98 | + | // it wakes the consumer. Owner goroutines of outputs call it. After Push the | |
| 99 | + | // report belongs to the sink: its maps are read-only snapshots, and Flip must | |
| 100 | + | // be fresh for every push, since the sink may update Merged and replace Shows | |
| 101 | + | // when it merges flips. | |
| 102 | + | type PresentationSink interface{ Push(OutputPresented) } | |
| 103 | + | ||
| 97 | 104 | // OutputFormats carries output → wayland the dmabuf formats an output can | |
| 98 | 105 | // scan out directly (its primary plane's, that the renderer also samples, | |
| 99 | 106 | // so a refused buffer can still be composed). Device is the KMS device | |
| @@ -133,8 +140,9 @@ type FlipInfo struct { | |||
| 133 | 140 | Shows map[WindowID]uint64 | |
| 134 | 141 | } | |
| 135 | 142 | ||
| 136 | - | // OutputFrame carries output → core a completed page flip: the next frame | |
| 137 | - | // of a running animation is due. | |
| 143 | + | // OutputFrame carries output → core a completed flip of an animating scene | |
| 144 | + | // (Scene.Animating): the next frame is due. Sends never block; a full | |
| 145 | + | // channel drops it. | |
| 138 | 146 | type OutputFrame struct{ Output string } | |
| 139 | 147 | ||
| 140 | 148 | // OutputRemoved carries output → core an unplugged display. | |
M internal/ports/scene.go +11 -4
| @@ -69,13 +69,20 @@ type Scene struct { | |||
| 69 | 69 | // drawn over everything; nil when none. Captures never hold them: the | |
| 70 | 70 | // capture pipeline serves requests from a scene without them. | |
| 71 | 71 | CaptureIndicators []CaptureIndicator | |
| 72 | + | // Animating is set by core when a spring or the focus pulse runs on | |
| 73 | + | // this output after this frame was computed: the output reports the | |
| 74 | + | // flip of the scene to core (OutputFrame), which moves the animation to | |
| 75 | + | // the next frame. A scene of a settled output carries false, so a flip | |
| 76 | + | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | |
| 77 | + | // the renderer ignore it. | |
| 78 | + | Animating bool | |
| 72 | 79 | } | |
| 73 | 80 | ||
| 74 | 81 | // SameAs reports whether o draws exactly what s draws: every field other | |
| 75 | - | // than Seq is equal. A scene carrying a CaptureScene is never the same | |
| 76 | - | // (conservative: the capture image is not compared). Add every new Scene | |
| 77 | - | // field here; TestSceneSameAsCoversEveryField fails until the field count | |
| 78 | - | // is updated. | |
| 82 | + | // than Seq and Animating (scheduling only, not drawn) is equal. A scene | |
| 83 | + | // carrying a CaptureScene is never the same (conservative: the capture image | |
| 84 | + | // is not compared). Add every new Scene field here; | |
| 85 | + | // TestSceneSameAsCoversEveryField fails until the field count is updated. | |
| 79 | 86 | func (s Scene) SameAs(o Scene) bool { | |
| 80 | 87 | if s.Security != o.Security || s.Output != o.Output || | |
| 81 | 88 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | |
M internal/ports/scene_same_test.go +3 -1
| @@ -83,7 +83,9 @@ func TestSceneSameAs(t *testing.T) { | |||
| 83 | 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 84 | 84 | // update Scene.SameAs, then this count. | |
| 85 | 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 86 | - | const fields = 21 | |
| 86 | + | // 22 with Animating, which SameAs intentionally does not compare: it | |
| 87 | + | // only schedules the next frame, it draws nothing. | |
| 88 | + | const fields = 22 | |
| 87 | 89 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 88 | 90 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 89 | 91 | } | |
M internal/ports/surface_content.go +24 -0
| @@ -108,6 +108,30 @@ type SurfaceContent struct { | |||
| 108 | 108 | DamageHistory []SeqDamage | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | + | // ContentBatch is the window contents wayland hands to one output: at most | |
| 112 | + | // one per window, the latest published. A content superseded before the | |
| 113 | + | // output took the batch is overwritten in place; contents carry the window's | |
| 114 | + | // Seq, so the release accounting needs only the newest one. | |
| 115 | + | // | |
| 116 | + | // Ownership moves with the batch: the sender must not touch it after the | |
| 117 | + | // send, and the receiver resets it and gives it back through ContentReturn. | |
| 118 | + | // Items is a slice, not a map: SurfaceContent is large, and a map would | |
| 119 | + | // allocate its elements again after every clear. | |
| 120 | + | type ContentBatch struct { | |
| 121 | + | Items []SurfaceContent | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Reset empties the batch, dropping its references to buffers, and keeps its | |
| 125 | + | // capacity for reuse. | |
| 126 | + | func (b *ContentBatch) Reset() { | |
| 127 | + | clear(b.Items) | |
| 128 | + | b.Items = b.Items[:0] | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // ContentReturn gives a consumed batch back to wayland. Return never blocks | |
| 132 | + | // and is safe after the output was detached. | |
| 133 | + | type ContentReturn interface{ Return(*ContentBatch) } | |
| 134 | + | ||
| 111 | 135 | // BufferTransform is a wl_output.transform value: bit 0 rotates 90° | |
| 112 | 136 | // counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical | |
| 113 | 137 | // axis first. | |
A tools/bench/README.md +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | # Headless frame-cost bench | |
| 2 | + | ||
| 3 | + | `bench.sh` measures NeferWL's CPU time and voluntary context switches per presented frame on a 5120x2160 headless output while chromium animates a small part of a page. | |
| 4 | + | ||
| 5 | + | Requires `go`, `chromium` and `python3`. Frames are counted from the renderer's `frame` debug entries, which the script turns on. | |
| 6 | + | ||
| 7 | + | tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps | |
| 8 | + | tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps | |
| 9 | + | ||
| 10 | + | Environment: `BENCH_X11=1` runs chromium through XWayland, `BENCH_DEBUG=<categories>` adds `--debug` categories, `BENCH_KEEP=1` leaves the processes running, `BENCH_PPROF=<addr>` sets the pprof address (default `localhost:6061`). | |
A tools/bench/bench.sh +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Measures NeferWL's CPU cost per presented frame on the headless backend | |
| 3 | + | # while chromium animates a small region of a page. | |
| 4 | + | # | |
| 5 | + | # usage: tools/bench/bench.sh <page.html> <seconds> | |
| 6 | + | # env: BENCH_X11=1 run chromium through XWayland instead of Wayland | |
| 7 | + | # BENCH_DEBUG=cat extra --debug categories (comma separated) | |
| 8 | + | # BENCH_KEEP=1 leave neferwl and chromium running (prints PIDs) | |
| 9 | + | # BENCH_PPROF=addr pprof listen address (default localhost:6061) | |
| 10 | + | set -euo pipefail | |
| 11 | + | ||
| 12 | + | page=${1:?usage: bench.sh <page.html> <seconds>} | |
| 13 | + | secs=${2:?usage: bench.sh <page.html> <seconds>} | |
| 14 | + | here=$(cd "$(dirname "$0")" && pwd) | |
| 15 | + | root=$(cd "$here/../.." && pwd) | |
| 16 | + | tmp=$(mktemp -d) | |
| 17 | + | pids=() | |
| 18 | + | ||
| 19 | + | cleanup() { | |
| 20 | + | if [ -z "${BENCH_KEEP:-}" ]; then | |
| 21 | + | for p in "${pids[@]}"; do kill -- "-$p" 2>/dev/null || true; done | |
| 22 | + | sleep 1 | |
| 23 | + | rm -rf "$tmp" | |
| 24 | + | fi | |
| 25 | + | } | |
| 26 | + | trap cleanup EXIT | |
| 27 | + | ||
| 28 | + | (cd "$root" && CGO_ENABLED=0 go build -o "$tmp/nwl" ./cmd/neferwl) | |
| 29 | + | mkdir -p "$tmp/rt" "$tmp/cfg" "$tmp/state" | |
| 30 | + | chmod 700 "$tmp/rt" | |
| 31 | + | ||
| 32 | + | debug=render${BENCH_DEBUG:+,$BENCH_DEBUG} | |
| 33 | + | xflag=--no-xwayland | |
| 34 | + | [ -n "${BENCH_X11:-}" ] && xflag= | |
| 35 | + | env XDG_RUNTIME_DIR="$tmp/rt" XDG_CONFIG_HOME="$tmp/cfg" XDG_STATE_HOME="$tmp/state" WAYLAND_DISPLAY= \ | |
| 36 | + | setsid "$tmp/nwl" --backend=headless --size=5120x2160 --no-terminal $xflag \ | |
| 37 | + | --debug="$debug" --pprof="${BENCH_PPROF:-localhost:6061}" >"$tmp/nwl.out" 2>&1 </dev/null & | |
| 38 | + | nwl=$! | |
| 39 | + | pids+=("$nwl") | |
| 40 | + | log=$tmp/state/neferwl/runs/headless/latest.log | |
| 41 | + | for _ in $(seq 50); do [ -S "$tmp/rt/wayland-1" ] && break; sleep 0.1; done | |
| 42 | + | if [ ! -S "$tmp/rt/wayland-1" ]; then | |
| 43 | + | echo "neferwl did not start: no wayland-1 socket; its output:" >&2 | |
| 44 | + | cat "$tmp/nwl.out" >&2 | |
| 45 | + | exit 1 | |
| 46 | + | fi | |
| 47 | + | ||
| 48 | + | if [ -n "${BENCH_X11:-}" ]; then | |
| 49 | + | disp= | |
| 50 | + | for _ in $(seq 50); do | |
| 51 | + | disp=$(grep -o '"DISPLAY":"[^"]*"' "$log" 2>/dev/null | head -1 | cut -d'"' -f4) || true | |
| 52 | + | [ -n "$disp" ] && break | |
| 53 | + | sleep 0.1 | |
| 54 | + | done | |
| 55 | + | [ -n "$disp" ] || { echo "no X11 display in log" >&2; exit 1; } | |
| 56 | + | env XDG_RUNTIME_DIR="$tmp/rt" WAYLAND_DISPLAY= DISPLAY="$disp" setsid chromium --ozone-platform=x11 \ | |
| 57 | + | --user-data-dir="$tmp/chr" --no-first-run --app="file://$here/$page" >"$tmp/chr.out" 2>&1 </dev/null & | |
| 58 | + | else | |
| 59 | + | env XDG_RUNTIME_DIR="$tmp/rt" WAYLAND_DISPLAY=wayland-1 setsid chromium --ozone-platform=wayland \ | |
| 60 | + | --user-data-dir="$tmp/chr" --no-first-run --app="file://$here/$page" >"$tmp/chr.out" 2>&1 </dev/null & | |
| 61 | + | fi | |
| 62 | + | pids+=("$!") | |
| 63 | + | sleep 8 | |
| 64 | + | ||
| 65 | + | cs() { cat /proc/"$nwl"/task/*/status | awk '/^voluntary_ctxt/{v+=$2} END{print v}'; } | |
| 66 | + | # CPU time in ns summed over threads (schedstat is finer than stat's ticks). | |
| 67 | + | cpu_ns() { cat /proc/"$nwl"/task/*/schedstat | awk '{v+=$1} END{printf "%d\n", v}'; } | |
| 68 | + | frames() { grep -c '"message":"frame"' "$log" || true; } | |
| 69 | + | ||
| 70 | + | t0=$(cpu_ns); f0=$(frames); c0=$(cs) | |
| 71 | + | sleep "$secs" | |
| 72 | + | t1=$(cpu_ns); f1=$(frames); c1=$(cs) | |
| 73 | + | ||
| 74 | + | if [ "$((f1 - f0))" -le 0 ]; then | |
| 75 | + | echo "no frame presented in ${secs}s: the page did not animate or neferwl stalled" >&2 | |
| 76 | + | tail -n 20 "$tmp/nwl.out" >&2 | |
| 77 | + | exit 1 | |
| 78 | + | fi | |
| 79 | + | ||
| 80 | + | python3 - "$((t1 - t0))" "$((f1 - f0))" "$((c1 - c0))" "$secs" "$page" <<'EOF' | |
| 81 | + | import sys | |
| 82 | + | t, f, c, s, page = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]), float(sys.argv[4]), sys.argv[5] | |
| 83 | + | cpu = t / 1e9 / s * 100 | |
| 84 | + | fr = max(f, 1) | |
| 85 | + | print(f"{page} cpu%={cpu:.2f} fps={f / s:.1f} ms/frame={t / 1e6 / fr:.3f} cs/frame={c / fr:.0f}") | |
| 86 | + | EOF | |
| 87 | + | ||
| 88 | + | if [ -n "${BENCH_KEEP:-}" ]; then | |
| 89 | + | echo "kept: dir=$tmp pids=${pids[*]} log=$log" | |
| 90 | + | fi | |
A tools/bench/emoji.gif +0 -0 binary file: no text diff
binary file: no text diff
A tools/bench/gif.html +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <html><body style="margin:0;background:#fff;font:14px sans-serif"><p>general — hello <img src="emoji.gif" width=22 height=22> text</p></body></html> | |
A tools/bench/spin60.html +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <html><body style="margin:0;background:#fff"><p>x</p><div style="width:20px;height:20px;border:3px solid #ccc;border-top-color:#36c;border-radius:50%;animation:s 1s linear infinite"></div><style>@keyframes s{to{transform:rotate(360deg)}}</style></body></html> | |