detached fefcf43

refactor(wayland): pace frames on the display loop via a presentation sink

commit
fefcf43c4307760f824ff3b4f9214464e84ce1b4
author
brice · · 2026-10-13 15:50 UTC
parent
94ab056
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +664 -285
M .mockery.yml +1 -0
@@ -17,6 +17,7 @@ packages:
1717 Clock:
1818 Notifier:
1919 OutputScaleStore:
20+ PresentationSink:
2021 Renderer:
2122 Seat:
2223 SessionSecurity:
@@ -17,6 +17,7 @@ packages:
17 Clock:17 Clock:
18 Notifier:18 Notifier:
19 OutputScaleStore:19 OutputScaleStore:
20+ PresentationSink:
20 Renderer:21 Renderer:
21 Seat:22 Seat:
22 SessionSecurity:23 SessionSecurity:
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 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/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|TestPresentationPushAllocations)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check: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=172+ 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|TestPresentationPushAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M go.mod +3 -0
@@ -24,3 +24,6 @@ require (
2424 go.yaml.in/yaml/v3 v3.0.5 // indirect
2525 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
2626 )
27+
28+// TODO(release): drop replace, bump to tagged version
29+replace github.com/bnema/purego-libwayland => ../../../purego-libwayland/.worktrees/display-post
@@ -24,3 +24,6 @@ require (
24 go.yaml.in/yaml/v3 v3.0.5 // indirect24 go.yaml.in/yaml/v3 v3.0.5 // indirect
25 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect25 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
26 )26 )
27+
28+// TODO(release): drop replace, bump to tagged version
29+replace github.com/bnema/purego-libwayland => ../../../purego-libwayland/.worktrees/display-post
M internal/adapters/drm/amdgpu_rules_test.go +1 -1
@@ -431,7 +431,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
431431 defer cancel()
432432 done := make(chan error, 1)
433433 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, discardSink(t), nil, nil)
435435 }()
436436 for {
437437 select {
@@ -431,7 +431,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
431 defer cancel()431 defer cancel()
432 done := make(chan error, 1)432 done := make(chan error, 1)
433 go func() {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, discardSink(t), nil, nil)
435 }()435 }()
436 for {436 for {
437 select {437 select {
M internal/adapters/drm/capture_test.go +4 -4
@@ -366,11 +366,11 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
366366 contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene
367367 captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it
368368 captured := make(chan ports.CaptureDone, 4)
369- presented := make(chan ports.OutputPresented, 64)
369+ sink, presented := presentedSink(t, 64)
370370 ctx, cancel := context.WithCancel(context.Background())
371371 done := make(chan error, 1)
372372 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, nil, sink, captures, captured)
374374 }()
375375 count := func() int {
376376 commitMu.Lock()
@@ -487,7 +487,7 @@ func runCaptureWaitsForItsIndicatorScene(t *testing.T) {
487487 ctx, cancel := context.WithCancel(context.Background())
488488 done := make(chan error, 1)
489489 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, discardSink(t), captures, captured)
491491 }()
492492 count := func() int {
493493 commitMu.Lock()
@@ -563,7 +563,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
563563 ctx, cancel := context.WithCancel(context.Background())
564564 done := make(chan error, 1)
565565 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, discardSink(t), captures, captured)
567567 }()
568568 count := func() int {
569569 commitMu.Lock()
@@ -366,11 +366,11 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
366 contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene366 contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene
367 captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it367 captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it
368 captured := make(chan ports.CaptureDone, 4)368 captured := make(chan ports.CaptureDone, 4)
369- presented := make(chan ports.OutputPresented, 64)369+ sink, presented := presentedSink(t, 64)
370 ctx, cancel := context.WithCancel(context.Background())370 ctx, cancel := context.WithCancel(context.Background())
371 done := make(chan error, 1)371 done := make(chan error, 1)
372 go func() {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, nil, sink, captures, captured)
374 }()374 }()
375 count := func() int {375 count := func() int {
376 commitMu.Lock()376 commitMu.Lock()
@@ -487,7 +487,7 @@ func runCaptureWaitsForItsIndicatorScene(t *testing.T) {
487 ctx, cancel := context.WithCancel(context.Background())487 ctx, cancel := context.WithCancel(context.Background())
488 done := make(chan error, 1)488 done := make(chan error, 1)
489 go func() {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, discardSink(t), captures, captured)
491 }()491 }()
492 count := func() int {492 count := func() int {
493 commitMu.Lock()493 commitMu.Lock()
@@ -563,7 +563,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
563 ctx, cancel := context.WithCancel(context.Background())563 ctx, cancel := context.WithCancel(context.Background())
564 done := make(chan error, 1)564 done := make(chan error, 1)
565 go func() {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, discardSink(t), captures, captured)
567 }()567 }()
568 count := func() int {568 count := func() int {
569 commitMu.Lock()569 commitMu.Lock()
M internal/adapters/drm/commit_test.go +5 -5
@@ -400,7 +400,7 @@ func enableAfterStartAwayModesetsOnce(t *testing.T) {
400400 ctx, cancel := context.WithCancel(context.Background())
401401 done := make(chan error, 1)
402402 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, discardSink(t), nil, nil)
404404 }()
405405 count := func(flags uint32) int {
406406 commitMu.Lock()
@@ -517,11 +517,11 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
517517 }
518518 scenes := make(chan ports.Scene, 1)
519519 contents := make(chan ports.SurfaceContent, 1)
520- presented := make(chan ports.OutputPresented, 8)
520+ sink, presented := presentedSink(t, 8)
521521 ctx, cancel := context.WithCancel(context.Background())
522522 done := make(chan error, 1)
523523 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, nil, sink, nil, nil)
525525 }()
526526 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
527527 var frame commitRec
@@ -601,11 +601,11 @@ func TestRunClosesFrameFences(t *testing.T) {
601601 before := fds()
602602 active := make(chan bool, 1)
603603 scenes := make(chan ports.Scene, 1)
604- presented := make(chan ports.OutputPresented, 4*rounds)
604+ sink, _ := presentedSink(t, 4*rounds)
605605 ctx, cancel := context.WithCancel(context.Background())
606606 done := make(chan error, 1)
607607 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, sink, nil, nil)
609609 }()
610610 count := func() int {
611611 commitMu.Lock()
@@ -400,7 +400,7 @@ func enableAfterStartAwayModesetsOnce(t *testing.T) {
400 ctx, cancel := context.WithCancel(context.Background())400 ctx, cancel := context.WithCancel(context.Background())
401 done := make(chan error, 1)401 done := make(chan error, 1)
402 go func() {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, discardSink(t), nil, nil)
404 }()404 }()
405 count := func(flags uint32) int {405 count := func(flags uint32) int {
406 commitMu.Lock()406 commitMu.Lock()
@@ -517,11 +517,11 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
517 }517 }
518 scenes := make(chan ports.Scene, 1)518 scenes := make(chan ports.Scene, 1)
519 contents := make(chan ports.SurfaceContent, 1)519 contents := make(chan ports.SurfaceContent, 1)
520- presented := make(chan ports.OutputPresented, 8)520+ sink, presented := presentedSink(t, 8)
521 ctx, cancel := context.WithCancel(context.Background())521 ctx, cancel := context.WithCancel(context.Background())
522 done := make(chan error, 1)522 done := make(chan error, 1)
523 go func() {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, nil, sink, nil, nil)
525 }()525 }()
526 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}526 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
527 var frame commitRec527 var frame commitRec
@@ -601,11 +601,11 @@ func TestRunClosesFrameFences(t *testing.T) {
601 before := fds()601 before := fds()
602 active := make(chan bool, 1)602 active := make(chan bool, 1)
603 scenes := make(chan ports.Scene, 1)603 scenes := make(chan ports.Scene, 1)
604- presented := make(chan ports.OutputPresented, 4*rounds)604+ sink, _ := presentedSink(t, 4*rounds)
605 ctx, cancel := context.WithCancel(context.Background())605 ctx, cancel := context.WithCancel(context.Background())
606 done := make(chan error, 1)606 done := make(chan error, 1)
607 go func() {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, sink, nil, nil)
609 }()609 }()
610 count := func() int {610 count := func() int {
611 commitMu.Lock()611 commitMu.Lock()
M internal/adapters/drm/output.go +6 -6
@@ -1005,8 +1005,8 @@ func (o *Output) Close() {
10051005 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
10061006
10071007 // Run renders scenes and commits them until ctx ends. active reports seat
1008-// enable/disable. What the output shows and read is reported on reportsCh.
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.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.SurfaceContent, cursor <-chan ports.CursorChange, reports ports.PresentationSink, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
10101010 defer func() {
10111011 p := recover()
10121012 if p != nil {
@@ -1232,11 +1232,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12321232 readWait = readWait || o.capped
12331233 }
12341234 }
1235- o.reports.Flush(reportsCh)
1236- // An unsent report is retried soon, not only on the next event;
1237- // 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.
12381238 var retry <-chan time.Time
1239- if o.reports.Len() > 0 || readWait {
1239+ if readWait {
12401240 retryTimer.Reset(time.Millisecond)
12411241 retry = retryTimer.C
12421242 } else {
@@ -1005,8 +1005,8 @@ func (o *Output) Close() {
1005 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }1005 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
1006 1006
1007 // Run renders scenes and commits them until ctx ends. active reports seat1007 // Run renders scenes and commits them until ctx ends. active reports seat
1008-// enable/disable. What the output shows and read is reported on reportsCh.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.SurfaceContent, cursor <-chan ports.CursorChange, reportsCh chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {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.SurfaceContent, cursor <-chan ports.CursorChange, reports ports.PresentationSink, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
1010 defer func() {1010 defer func() {
1011 p := recover()1011 p := recover()
1012 if p != nil {1012 if p != nil {
@@ -1232,11 +1232,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1232 readWait = readWait || o.capped1232 readWait = readWait || o.capped
1233 }1233 }
1234 }1234 }
1235- o.reports.Flush(reportsCh)1235+ o.reports.Flush(reports)
1236- // An unsent report is retried soon, not only on the next event;1236+ // A report that waits for an uncommitted frame's fence is retried
1237- // so is one that waits for an uncommitted frame's fence.1237+ // soon, not only on the next event.
1238 var retry <-chan time.Time1238 var retry <-chan time.Time
1239- if o.reports.Len() > 0 || readWait {1239+ if readWait {
1240 retryTimer.Reset(time.Millisecond)1240 retryTimer.Reset(time.Millisecond)
1241 retry = retryTimer.C1241 retry = retryTimer.C
1242 } else {1242 } else {
M internal/adapters/drm/output_test.go +7 -7
@@ -598,11 +598,11 @@ func runReportsSeenAfterFlip(t *testing.T) {
598598 }
599599 scenes := make(chan ports.Scene, 1)
600600 contents := make(chan ports.SurfaceContent, 1)
601- presented := make(chan ports.OutputPresented, 8)
601+ sink, presented := presentedSink(t, 8)
602602 ctx, cancel := context.WithCancel(context.Background())
603603 done := make(chan error, 1)
604604 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, nil, sink, nil, nil)
606606 }()
607607 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
608608 waitFor(t, func() bool { return frameCommits() == 1 })
@@ -679,7 +679,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
679679 ctx, cancel := context.WithCancel(context.Background())
680680 done := make(chan error, 1)
681681 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)
682+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, discardSink(t), nil, nil)
683683 }()
684684 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,
685685 Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
@@ -761,7 +761,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
761761 active <- false
762762 done := make(chan error, 1)
763763 go func() {
764- 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)
764+ done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, discardSink(t), nil, nil)
765765 }()
766766 ticks <- now
767767 <-trimmed
@@ -881,7 +881,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
881881 ctx, cancel := context.WithCancel(context.Background())
882882 done := make(chan error, 1)
883883 go func() {
884- 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)
884+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, discardSink(t), requests, replies)
885885 }()
886886 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}
887887 select {
@@ -1001,7 +1001,7 @@ func startStampRun(t *testing.T) *stampRun {
10011001 ctx, cancel := context.WithCancel(context.Background())
10021002 done := make(chan error, 1)
10031003 go func() {
1004- 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)
1004+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents, nil, discardSink(t), nil, nil)
10051005 }()
10061006 // stop ends Run: its state is ours to read afterwards.
10071007 sr.stop = func() {
@@ -1133,7 +1133,7 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) {
11331133 ctx, cancel := context.WithCancel(context.Background())
11341134 done := make(chan error, 1)
11351135 go func() {
1136- 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)
1136+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, discardSink(t), nil, nil)
11371137 }()
11381138 acq, acqW, _ := os.Pipe()
11391139 defer acq.Close()
@@ -598,11 +598,11 @@ func runReportsSeenAfterFlip(t *testing.T) {
598 }598 }
599 scenes := make(chan ports.Scene, 1)599 scenes := make(chan ports.Scene, 1)
600 contents := make(chan ports.SurfaceContent, 1)600 contents := make(chan ports.SurfaceContent, 1)
601- presented := make(chan ports.OutputPresented, 8)601+ sink, presented := presentedSink(t, 8)
602 ctx, cancel := context.WithCancel(context.Background())602 ctx, cancel := context.WithCancel(context.Background())
603 done := make(chan error, 1)603 done := make(chan error, 1)
604 go func() {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, nil, sink, nil, nil)
606 }()606 }()
607 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}607 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
608 waitFor(t, func() bool { return frameCommits() == 1 })608 waitFor(t, func() bool { return frameCommits() == 1 })
@@ -679,7 +679,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
679 ctx, cancel := context.WithCancel(context.Background())679 ctx, cancel := context.WithCancel(context.Background())
680 done := make(chan error, 1)680 done := make(chan error, 1)
681 go func() {681 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)682+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, discardSink(t), nil, nil)
683 }()683 }()
684 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,684 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,
685 Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},685 Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
@@ -761,7 +761,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
761 active <- false761 active <- false
762 done := make(chan error, 1)762 done := make(chan error, 1)
763 go func() {763 go func() {
764- 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)764+ done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, discardSink(t), nil, nil)
765 }()765 }()
766 ticks <- now766 ticks <- now
767 <-trimmed767 <-trimmed
@@ -881,7 +881,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
881 ctx, cancel := context.WithCancel(context.Background())881 ctx, cancel := context.WithCancel(context.Background())
882 done := make(chan error, 1)882 done := make(chan error, 1)
883 go func() {883 go func() {
884- 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)884+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, discardSink(t), requests, replies)
885 }()885 }()
886 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}886 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}
887 select {887 select {
@@ -1001,7 +1001,7 @@ func startStampRun(t *testing.T) *stampRun {
1001 ctx, cancel := context.WithCancel(context.Background())1001 ctx, cancel := context.WithCancel(context.Background())
1002 done := make(chan error, 1)1002 done := make(chan error, 1)
1003 go func() {1003 go func() {
1004- 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)1004+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents, nil, discardSink(t), nil, nil)
1005 }()1005 }()
1006 // stop ends Run: its state is ours to read afterwards.1006 // stop ends Run: its state is ours to read afterwards.
1007 sr.stop = func() {1007 sr.stop = func() {
@@ -1133,7 +1133,7 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) {
1133 ctx, cancel := context.WithCancel(context.Background())1133 ctx, cancel := context.WithCancel(context.Background())
1134 done := make(chan error, 1)1134 done := make(chan error, 1)
1135 go func() {1135 go func() {
1136- 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)1136+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, discardSink(t), nil, nil)
1137 }()1137 }()
1138 acq, acqW, _ := os.Pipe()1138 acq, acqW, _ := os.Pipe()
1139 defer acq.Close()1139 defer acq.Close()
M internal/adapters/drm/power_test.go +2 -2
@@ -50,7 +50,7 @@ func runOutputPower(t *testing.T) {
5050 ctx, cancel := context.WithCancel(context.Background())
5151 done := make(chan error, 1)
5252 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, make(chan ports.SurfaceContent), nil, discardSink(t), nil, nil)
5454 }()
5555 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
5656 scenes <- on
@@ -174,7 +174,7 @@ func runOutputStartOff(t *testing.T) {
174174 ctx, cancel := context.WithCancel(context.Background())
175175 done := make(chan error, 1)
176176 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, make(chan ports.SurfaceContent), nil, discardSink(t), nil, nil)
178178 }()
179179 if err := <-ready; err != nil {
180180 t.Fatal(err)
@@ -50,7 +50,7 @@ func runOutputPower(t *testing.T) {
50 ctx, cancel := context.WithCancel(context.Background())50 ctx, cancel := context.WithCancel(context.Background())
51 done := make(chan error, 1)51 done := make(chan error, 1)
52 go func() {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, make(chan ports.SurfaceContent), nil, discardSink(t), nil, nil)
54 }()54 }()
55 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}55 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
56 scenes <- on56 scenes <- on
@@ -174,7 +174,7 @@ func runOutputStartOff(t *testing.T) {
174 ctx, cancel := context.WithCancel(context.Background())174 ctx, cancel := context.WithCancel(context.Background())
175 done := make(chan error, 1)175 done := make(chan error, 1)
176 go func() {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, make(chan ports.SurfaceContent), nil, discardSink(t), nil, nil)
178 }()178 }()
179 if err := <-ready; err != nil {179 if err := <-ready; err != nil {
180 t.Fatal(err)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+}
@@ -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
4343 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
4444 done := make(chan error, 1)
4545 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, discardSink(t), nil, nil)
4747 }()
4848 return events, done, cancel
4949 }
@@ -43,7 +43,7 @@ func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*o
43 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)43 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
44 done := make(chan error, 1)44 done := make(chan error, 1)
45 go func() {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, discardSink(t), nil, nil)
47 }()47 }()
48 return events, done, cancel48 return events, done, cancel
49 }49 }
M internal/adapters/drm/reacquire_test.go +1 -1
@@ -96,7 +96,7 @@ func newRetryHarness(t *testing.T) retryHarness {
9696 ctx, cancel := context.WithCancel(context.Background())
9797 done := make(chan error, 1)
9898 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, discardSink(t), nil, nil)
100100 }()
101101 synctest.Wait()
102102 return retryHarness{
@@ -96,7 +96,7 @@ func newRetryHarness(t *testing.T) retryHarness {
96 ctx, cancel := context.WithCancel(context.Background())96 ctx, cancel := context.WithCancel(context.Background())
97 done := make(chan error, 1)97 done := make(chan error, 1)
98 go func() {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, discardSink(t), nil, nil)
100 }()100 }()
101 synctest.Wait()101 synctest.Wait()
102 return retryHarness{102 return retryHarness{
M internal/adapters/drm/seat_disable_test.go +1 -1
@@ -85,7 +85,7 @@ func TestRunPreparesSeatDisable(t *testing.T) {
8585 ctx, cancel := context.WithCancel(context.Background())
8686 done := make(chan error, 1)
8787 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, discardSink(t), nil, nil)
8989 }()
9090 if !o.PrepareSeatDisable(5 * time.Second) {
9191 t.Fatal("no ack")
@@ -85,7 +85,7 @@ func TestRunPreparesSeatDisable(t *testing.T) {
85 ctx, cancel := context.WithCancel(context.Background())85 ctx, cancel := context.WithCancel(context.Background())
86 done := make(chan error, 1)86 done := make(chan error, 1)
87 go func() {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, discardSink(t), nil, nil)
89 }()89 }()
90 if !o.PrepareSeatDisable(5 * time.Second) {90 if !o.PrepareSeatDisable(5 * time.Second) {
91 t.Fatal("no ack")91 t.Fatal("no ack")
M internal/adapters/drm/security_recovery_test.go +1 -1
@@ -61,7 +61,7 @@ func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
6161 scenes := make(chan ports.Scene, 1)
6262 done := make(chan error, 1)
6363 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, discardSink(t), nil, nil)
6565 }()
6666 receive := func() ports.SecurityBackendEvent {
6767 t.Helper()
@@ -61,7 +61,7 @@ func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
61 scenes := make(chan ports.Scene, 1)61 scenes := make(chan ports.Scene, 1)
62 done := make(chan error, 1)62 done := make(chan error, 1)
63 go func() {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, discardSink(t), nil, nil)
65 }()65 }()
66 receive := func() ports.SecurityBackendEvent {66 receive := func() ports.SecurityBackendEvent {
67 t.Helper()67 t.Helper()
M internal/adapters/drm/security_run_test.go +1 -1
@@ -57,7 +57,7 @@ func securityRunWakePendingResumeAndOff(t *testing.T) {
5757 defer cancel()
5858 done := make(chan error, 1)
5959 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, discardSink(t), nil, nil)
6161 }()
6262 scenes <- ports.Scene{Seq: 99, Scale: 1}
6363 select {
@@ -57,7 +57,7 @@ func securityRunWakePendingResumeAndOff(t *testing.T) {
57 defer cancel()57 defer cancel()
58 done := make(chan error, 1)58 done := make(chan error, 1)
59 go func() {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, discardSink(t), nil, nil)
61 }()61 }()
62 scenes <- ports.Scene{Seq: 99, Scale: 1}62 scenes <- ports.Scene{Seq: 99, Scale: 1}
63 select {63 select {
M internal/adapters/drm/stuck_test.go +4 -2
@@ -33,6 +33,7 @@ type stuckRun struct {
3333 contents chan ports.SurfaceContent
3434 active chan bool
3535 presented chan ports.OutputPresented
36+ sink ports.PresentationSink
3637 count func() int
3738 stop func() []commitRec
3839 }
@@ -67,11 +68,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
6768 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
6869 k.EXPECT().destroyBlob(mock.Anything).Return(nil)
6970 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: make(chan ports.SurfaceContent, 1), active: make(chan bool, 1), presented: nil}
72+ h.sink, h.presented = presentedSink(t, 64)
7173 ctx, cancel := context.WithCancel(context.Background())
7274 done := make(chan error, 1)
7375 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, nil, h.sink, nil, nil)
7577 }()
7678 h.count = func() int {
7779 commitMu.Lock()
@@ -33,6 +33,7 @@ type stuckRun struct {
33 contents chan ports.SurfaceContent33 contents chan ports.SurfaceContent
34 active chan bool34 active chan bool
35 presented chan ports.OutputPresented35 presented chan ports.OutputPresented
36+ sink ports.PresentationSink
36 count func() int37 count func() int
37 stop func() []commitRec38 stop func() []commitRec
38 }39 }
@@ -67,11 +68,12 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
67 k.EXPECT().createBlob(mock.Anything).Return(99, nil)68 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
68 k.EXPECT().destroyBlob(mock.Anything).Return(nil)69 k.EXPECT().destroyBlob(mock.Anything).Return(nil)
69 k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()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: make(chan ports.SurfaceContent, 1), active: make(chan bool, 1), presented: nil}
72+ h.sink, h.presented = presentedSink(t, 64)
71 ctx, cancel := context.WithCancel(context.Background())73 ctx, cancel := context.WithCancel(context.Background())
72 done := make(chan error, 1)74 done := make(chan error, 1)
73 go func() {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, nil, h.sink, nil, nil)
75 }()77 }()
76 h.count = func() int {78 h.count = func() int {
77 commitMu.Lock()79 commitMu.Lock()
M internal/adapters/drm/vrr_flip_gap_test.go +1 -1
@@ -115,7 +115,7 @@ func TestRunWaitsVRRFlipGap(t *testing.T) {
115115 ctx, cancel := context.WithCancel(context.Background())
116116 done := make(chan error, 1)
117117 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, discardSink(t), nil, nil)
119119 }()
120120 // A composed fullscreen window: a game frame under VRR.
121121 game := ports.Scene{OutputWidth: 200, OutputHeight: 100,
@@ -115,7 +115,7 @@ func TestRunWaitsVRRFlipGap(t *testing.T) {
115 ctx, cancel := context.WithCancel(context.Background())115 ctx, cancel := context.WithCancel(context.Background())
116 done := make(chan error, 1)116 done := make(chan error, 1)
117 go func() {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, discardSink(t), nil, nil)
119 }()119 }()
120 // A composed fullscreen window: a game frame under VRR.120 // A composed fullscreen window: a game frame under VRR.
121 game := ports.Scene{OutputWidth: 200, OutputHeight: 100,121 game := ports.Scene{OutputWidth: 200, OutputHeight: 100,
M internal/adapters/headless/capture_test.go +2 -2
@@ -163,9 +163,9 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
163163 replies := make(chan ports.CaptureDone, 2)
164164 done := make(chan error, 1)
165165 var made [2]int
166- presented := make(chan ports.OutputPresented, 4)
166+ sink, presented := presentedSink(t, 4)
167167 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,
169169 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil },
170170 NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },
171171 }, scenes, nil, nil, incoming)
@@ -163,9 +163,9 @@ func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
163 replies := make(chan ports.CaptureDone, 2)163 replies := make(chan ports.CaptureDone, 2)
164 done := make(chan error, 1)164 done := make(chan error, 1)
165 var made [2]int165 var made [2]int
166- presented := make(chan ports.OutputPresented, 4)166+ sink, presented := presentedSink(t, 4)
167 go func() {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 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil },169 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil },
170 NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },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, incoming)
M internal/adapters/headless/output.go +5 -27
@@ -47,7 +47,7 @@ type Options struct {
4747 // Name and Presented report what the output has read after each
4848 // frame, so wayland can release client buffers (nil: no reports).
4949 Name string
50- Presented chan<- ports.OutputPresented
50+ Presented ports.PresentationSink
5151 Captured chan<- ports.CaptureDone
5252 // Clock paces the renderer trim (nil: system clock).
5353 Clock ports.Clock
@@ -111,7 +111,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
111111 cursorScale := -1.0 // not loaded yet
112112 var cursorTransform ports.BufferTransform
113113 seen := map[ports.WindowID]uint64{}
114- // reports holds what the channel could not take yet, retried soon.
114+ // reports queues what the output read; every report is pushed to the
115+ // sink at once (Push never blocks), so it holds one at most.
115116 var reports presented.Queue
116117 // mergedFlips counts flips merged since the last stats line.
117118 mergedFlips := 0
@@ -217,10 +218,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
217218 }
218219 return err
219220 }
220- var retry <-chan time.Time
221- if reports.Len() > 0 {
222- retry = time.After(time.Millisecond)
223- }
224221 // Requests wait for a scene that shows their indicator, at most
225222 // capture.HoldFor (see capture.Hold).
226223 var holdDue <-chan time.Time
@@ -292,15 +289,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
292289 refuse = capture.ErrOutputOff
293290 }
294291 dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
295- case <-retry:
296- if err := checkSecurity(); err != nil {
297- if ctx.Err() != nil {
298- return nil
299- }
300- return err
301- }
302- opts.flush(&reports)
303- continue
304292 case <-trim.C():
305293 if err := checkSecurity(); err != nil {
306294 if ctx.Err() != nil {
@@ -456,9 +444,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
456444 if reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) {
457445 mergedFlips++
458446 }
459- if reports.Drain(ctx, opts.Presented) != nil {
460- return nil
461- }
447+ reports.Flush(opts.Presented)
462448 }
463449 }
464450 if err := pipeline.WaitHidden(ctx); err != nil {
@@ -627,8 +613,7 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
627613 }
628614
629615 // report queues what the output read, with flip for a drawn frame, and
630-// sends what the channel takes now; the rest is retried soon.
631-// It reports whether a flip was merged because wayland was not reading.
616+// pushes it to the sink. It reports whether a flip was merged.
632617 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) (merged bool) {
633618 if opts.Presented == nil {
634619 return false
@@ -640,13 +625,6 @@ func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[po
640625 return merged
641626 }
642627
643-// flush retries reports the channel could not take.
644-func (opts Options) flush(q *presented.Queue) {
645- if opts.Presented != nil {
646- q.Flush(opts.Presented)
647- }
648-}
649-
650628 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
651629 // time (software clock, refresh unknown), so presentation feedback and
652630 // frame callbacks follow headless frames.
@@ -47,7 +47,7 @@ type Options struct {
47 // Name and Presented report what the output has read after each47 // Name and Presented report what the output has read after each
48 // frame, so wayland can release client buffers (nil: no reports).48 // frame, so wayland can release client buffers (nil: no reports).
49 Name string49 Name string
50- Presented chan<- ports.OutputPresented50+ Presented ports.PresentationSink
51 Captured chan<- ports.CaptureDone51 Captured chan<- ports.CaptureDone
52 // Clock paces the renderer trim (nil: system clock).52 // Clock paces the renderer trim (nil: system clock).
53 Clock ports.Clock53 Clock ports.Clock
@@ -111,7 +111,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
111 cursorScale := -1.0 // not loaded yet111 cursorScale := -1.0 // not loaded yet
112 var cursorTransform ports.BufferTransform112 var cursorTransform ports.BufferTransform
113 seen := map[ports.WindowID]uint64{}113 seen := map[ports.WindowID]uint64{}
114- // reports holds what the channel could not take yet, retried soon.114+ // reports queues what the output read; every report is pushed to the
115+ // sink at once (Push never blocks), so it holds one at most.
115 var reports presented.Queue116 var reports presented.Queue
116 // mergedFlips counts flips merged since the last stats line.117 // mergedFlips counts flips merged since the last stats line.
117 mergedFlips := 0118 mergedFlips := 0
@@ -217,10 +218,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
217 }218 }
218 return err219 return err
219 }220 }
220- var retry <-chan time.Time
221- if reports.Len() > 0 {
222- retry = time.After(time.Millisecond)
223- }
224 // Requests wait for a scene that shows their indicator, at most221 // Requests wait for a scene that shows their indicator, at most
225 // capture.HoldFor (see capture.Hold).222 // capture.HoldFor (see capture.Hold).
226 var holdDue <-chan time.Time223 var holdDue <-chan time.Time
@@ -292,15 +289,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
292 refuse = capture.ErrOutputOff289 refuse = capture.ErrOutputOff
293 }290 }
294 dirty = requests.Admit(q, refuse, scene, haveScene) || dirty291 dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
295- case <-retry:
296- if err := checkSecurity(); err != nil {
297- if ctx.Err() != nil {
298- return nil
299- }
300- return err
301- }
302- opts.flush(&reports)
303- continue
304 case <-trim.C():292 case <-trim.C():
305 if err := checkSecurity(); err != nil {293 if err := checkSecurity(); err != nil {
306 if ctx.Err() != nil {294 if ctx.Err() != nil {
@@ -456,9 +444,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
456 if reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) {444 if reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads}) {
457 mergedFlips++445 mergedFlips++
458 }446 }
459- if reports.Drain(ctx, opts.Presented) != nil {447+ reports.Flush(opts.Presented)
460- return nil
461- }
462 }448 }
463 }449 }
464 if err := pipeline.WaitHidden(ctx); err != nil {450 if err := pipeline.WaitHidden(ctx); err != nil {
@@ -627,8 +613,7 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
627 }613 }
628 614
629 // report queues what the output read, with flip for a drawn frame, and615 // report queues what the output read, with flip for a drawn frame, and
630-// sends what the channel takes now; the rest is retried soon.616+// pushes it to the sink. It reports whether a flip was merged.
631-// It reports whether a flip was merged because wayland was not reading.
632 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) (merged bool) {617 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) (merged bool) {
633 if opts.Presented == nil {618 if opts.Presented == nil {
634 return false619 return false
@@ -640,13 +625,6 @@ func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[po
640 return merged625 return merged
641 }626 }
642 627
643-// flush retries reports the channel could not take.
644-func (opts Options) flush(q *presented.Queue) {
645- if opts.Presented != nil {
646- q.Flush(opts.Presented)
647- }
648-}
649-
650 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC628 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
651 // time (software clock, refresh unknown), so presentation feedback and629 // time (software clock, refresh unknown), so presentation feedback and
652 // frame callbacks follow headless frames.630 // frame callbacks follow headless frames.
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+}
@@ -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_exclude_fence_test.go +2 -2
@@ -48,7 +48,7 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
4848 scenes := make(chan ports.Scene, 2)
4949 contents := make(chan ports.SurfaceContent, 1)
5050 incoming := make(chan ports.CaptureRequest)
51- presented := make(chan ports.OutputPresented, 8)
51+ sink, presented := presentedSink(t, 8)
5252 proofs := make(chan ports.SecurityBackendEvent, 2)
5353 replies := make(chan ports.CaptureDone, 2)
5454 changes := make(chan ports.SecurityState, 1)
@@ -56,7 +56,7 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
5656 defer cancel()
5757 done := make(chan error, 1)
5858 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, nil, incoming)
6060 }()
6161 dst := sessionCaptureFile(t)
6262 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}
@@ -48,7 +48,7 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
48 scenes := make(chan ports.Scene, 2)48 scenes := make(chan ports.Scene, 2)
49 contents := make(chan ports.SurfaceContent, 1)49 contents := make(chan ports.SurfaceContent, 1)
50 incoming := make(chan ports.CaptureRequest)50 incoming := make(chan ports.CaptureRequest)
51- presented := make(chan ports.OutputPresented, 8)51+ sink, presented := presentedSink(t, 8)
52 proofs := make(chan ports.SecurityBackendEvent, 2)52 proofs := make(chan ports.SecurityBackendEvent, 2)
53 replies := make(chan ports.CaptureDone, 2)53 replies := make(chan ports.CaptureDone, 2)
54 changes := make(chan ports.SecurityState, 1)54 changes := make(chan ports.SecurityState, 1)
@@ -56,7 +56,7 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
56 defer cancel()56 defer cancel()
57 done := make(chan error, 1)57 done := make(chan error, 1)
58 go func() {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, nil, incoming)
60 }()60 }()
61 dst := sessionCaptureFile(t)61 dst := sessionCaptureFile(t)
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}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}
M internal/adapters/headless/security_test.go +4 -4
@@ -146,11 +146,11 @@ func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) {
146146 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
147147 changes := make(chan ports.SecurityState, 1)
148148 proofs := make(chan ports.SecurityBackendEvent, 2)
149- presented := make(chan ports.OutputPresented, 4)
149+ sink, presented := presentedSink(t, 4)
150150 ctx, cancel := context.WithCancel(context.Background())
151151 done := make(chan error, 1)
152152 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)
154154 }()
155155 t.Cleanup(func() {
156156 cancel()
@@ -378,13 +378,13 @@ func TestSecurityOffOutputProofDoesNotRender(t *testing.T) {
378378 r.EXPECT().Close().Return().Once()
379379 scenes := make(chan ports.Scene, 1)
380380 scenes <- ports.Scene{Off: true}
381- presented := make(chan ports.OutputPresented, 1)
381+ sink, presented := presentedSink(t, 1)
382382 changes := make(chan ports.SecurityState, 1)
383383 proofs := make(chan ports.SecurityBackendEvent, 1)
384384 ctx, cancel := context.WithCancel(context.Background())
385385 done := make(chan error, 1)
386386 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)
388388 }()
389389 securityReceive(t, presented) // Off has actually been applied by the owner.
390390 locked := ports.SecurityState{Generation: 4, Protected: true}
@@ -146,11 +146,11 @@ func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) {
146 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}146 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
147 changes := make(chan ports.SecurityState, 1)147 changes := make(chan ports.SecurityState, 1)
148 proofs := make(chan ports.SecurityBackendEvent, 2)148 proofs := make(chan ports.SecurityBackendEvent, 2)
149- presented := make(chan ports.OutputPresented, 4)149+ sink, presented := presentedSink(t, 4)
150 ctx, cancel := context.WithCancel(context.Background())150 ctx, cancel := context.WithCancel(context.Background())
151 done := make(chan error, 1)151 done := make(chan error, 1)
152 go func() {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)
154 }()154 }()
155 t.Cleanup(func() {155 t.Cleanup(func() {
156 cancel()156 cancel()
@@ -378,13 +378,13 @@ func TestSecurityOffOutputProofDoesNotRender(t *testing.T) {
378 r.EXPECT().Close().Return().Once()378 r.EXPECT().Close().Return().Once()
379 scenes := make(chan ports.Scene, 1)379 scenes := make(chan ports.Scene, 1)
380 scenes <- ports.Scene{Off: true}380 scenes <- ports.Scene{Off: true}
381- presented := make(chan ports.OutputPresented, 1)381+ sink, presented := presentedSink(t, 1)
382 changes := make(chan ports.SecurityState, 1)382 changes := make(chan ports.SecurityState, 1)
383 proofs := make(chan ports.SecurityBackendEvent, 1)383 proofs := make(chan ports.SecurityBackendEvent, 1)
384 ctx, cancel := context.WithCancel(context.Background())384 ctx, cancel := context.WithCancel(context.Background())
385 done := make(chan error, 1)385 done := make(chan error, 1)
386 go func() {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)
388 }()388 }()
389 securityReceive(t, presented) // Off has actually been applied by the owner.389 securityReceive(t, presented) // Off has actually been applied by the owner.
390 locked := ports.SecurityState{Generation: 4, Protected: true}390 locked := ports.SecurityState{Generation: 4, Protected: true}
M internal/adapters/outputkit/presented/queue.go +9 -27
@@ -3,7 +3,6 @@
33 package presented
44
55 import (
6- "context"
76 "maps"
87 "slices"
98
@@ -48,15 +47,15 @@ func (q *Queue) Push(r ports.OutputPresented) (merged bool) {
4847 return false
4948 }
5049 if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil {
51- mergeFlip(old, next)
50+ Merge(old, next)
5251 }
5352 q.unsent = slices.Delete(q.unsent, 0, 1)
5453 return true
5554 }
5655
57-// mergeFlip folds old into next: next counts it as merged and shows what
56+// Merge folds old into next: next counts it as merged and shows what
5857 // old showed, so feedbacks waiting on old are answered (as discarded).
59-func mergeFlip(old, next *ports.FlipInfo) {
58+func Merge(old, next *ports.FlipInfo) {
6059 next.Merged += 1 + old.Merged
6160 // A snapshot can be shared with previously sent flips. Clone only when
6261 // a merge actually needs to add an absent window.
@@ -76,30 +75,13 @@ func mergeFlip(old, next *ports.FlipInfo) {
7675 }
7776 }
7877
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- }
78+// Flush pushes every queued report to sink, oldest first, and empties the
79+// queue. Push never blocks.
80+func (q *Queue) Flush(sink ports.PresentationSink) {
81+ for _, r := range q.unsent {
82+ sink.Push(r)
10183 }
102- return nil
84+ q.Reset()
10385 }
10486
10587 // Len is the number of queued reports.
@@ -3,7 +3,6 @@
3 package presented3 package presented
4 4
5 import (5 import (
6- "context"
7 "maps"6 "maps"
8 "slices"7 "slices"
9 8
@@ -48,15 +47,15 @@ func (q *Queue) Push(r ports.OutputPresented) (merged bool) {
48 return false47 return false
49 }48 }
50 if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil {49 if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil {
51- mergeFlip(old, next)50+ Merge(old, next)
52 }51 }
53 q.unsent = slices.Delete(q.unsent, 0, 1)52 q.unsent = slices.Delete(q.unsent, 0, 1)
54 return true53 return true
55 }54 }
56 55
57-// mergeFlip folds old into next: next counts it as merged and shows what56+// Merge folds old into next: next counts it as merged and shows what
58 // old showed, so feedbacks waiting on old are answered (as discarded).57 // old showed, so feedbacks waiting on old are answered (as discarded).
59-func mergeFlip(old, next *ports.FlipInfo) {58+func Merge(old, next *ports.FlipInfo) {
60 next.Merged += 1 + old.Merged59 next.Merged += 1 + old.Merged
61 // A snapshot can be shared with previously sent flips. Clone only when60 // A snapshot can be shared with previously sent flips. Clone only when
62 // a merge actually needs to add an absent window.61 // a merge actually needs to add an absent window.
@@ -76,30 +75,13 @@ func mergeFlip(old, next *ports.FlipInfo) {
76 }75 }
77 }76 }
78 77
79-// Flush sends queued reports without blocking.78+// Flush pushes every queued report to sink, oldest first, and empties the
80-func (q *Queue) Flush(ch chan<- ports.OutputPresented) {79+// queue. Push never blocks.
81- for len(q.unsent) > 0 {80+func (q *Queue) Flush(sink ports.PresentationSink) {
82- select {81+ for _, r := range q.unsent {
83- case ch <- q.unsent[0]:82+ sink.Push(r)
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- }
101 }83 }
102- return nil84+ q.Reset()
103 }85 }
104 86
105 // Len is the number of queued reports.87 // Len is the number of queued reports.
M internal/adapters/outputkit/presented/queue_test.go +12 -13
@@ -1,11 +1,12 @@
11 package presented
22
33 import (
4- "context"
54 "reflect"
65 "testing"
76
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
88 "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/mock"
910 "github.com/stretchr/testify/require"
1011 )
1112
@@ -75,24 +76,22 @@ func TestQueueMergesOldestFlipPastBound(t *testing.T) {
7576 require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")
7677 }
7778
78-func TestQueueFlushStopsWhenFull(t *testing.T) {
79+func TestQueueFlushPushesInOrderAndEmpties(t *testing.T) {
7980 var q Queue
8081 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}})
8182 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))
83+ var got []ports.OutputPresented
84+ sink := portsmocks.NewMockPresentationSink(t)
85+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) })
86+ q.Flush(sink)
8787 require.Zero(t, q.Len())
88- require.Equal(t, uint64(2), (<-ch).Flip.Seq)
88+ require.Len(t, got, 2)
89+ require.Equal(t, uint64(1), got[0].Flip.Seq)
90+ require.Equal(t, uint64(2), got[1].Flip.Seq)
91+ q.Flush(sink) // empty: nothing more is pushed
92+ require.Len(t, got, 2)
8993
9094 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)
9695 q.Reset()
9796 require.Zero(t, q.Len())
9897 }
@@ -1,11 +1,12 @@
1 package presented1 package presented
2 2
3 import (3 import (
4- "context"
5 "reflect"4 "reflect"
6 "testing"5 "testing"
7 6
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
@@ -75,24 +76,22 @@ func TestQueueMergesOldestFlipPastBound(t *testing.T) {
75 require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")76 require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")
76 }77 }
77 78
78-func TestQueueFlushStopsWhenFull(t *testing.T) {79+func TestQueueFlushPushesInOrderAndEmpties(t *testing.T) {
79 var q Queue80 var q Queue
80 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}})81 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}})
81 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}})82 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}})
82- ch := make(chan ports.OutputPresented, 1)83+ var got []ports.OutputPresented
83- q.Flush(ch)84+ sink := portsmocks.NewMockPresentationSink(t)
84- require.Equal(t, 1, q.Len())85+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) })
85- require.Equal(t, uint64(1), (<-ch).Flip.Seq)86+ q.Flush(sink)
86- require.NoError(t, q.Drain(context.Background(), ch))
87 require.Zero(t, q.Len())87 require.Zero(t, q.Len())
88- require.Equal(t, uint64(2), (<-ch).Flip.Seq)88+ require.Len(t, got, 2)
89+ require.Equal(t, uint64(1), got[0].Flip.Seq)
90+ require.Equal(t, uint64(2), got[1].Flip.Seq)
91+ q.Flush(sink) // empty: nothing more is pushed
92+ require.Len(t, got, 2)
89 93
90 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 3}})94 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 q.Reset()95 q.Reset()
97 require.Zero(t, q.Len())96 require.Zero(t, q.Len())
98 }97 }
M internal/adapters/wayland/alloc_hotpath_test.go +2 -2
@@ -24,7 +24,7 @@ func TestReleaseHeldAllocations(t *testing.T) {
2424 }
2525
2626 func TestCommitDamageAllocations(t *testing.T) {
27- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
27+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
2828 surf := &surface{server: srv}
2929 commit := func() {
3030 surf.Damage(nil, 0, 0, 4, 4)
@@ -55,7 +55,7 @@ func TestCommitDamageAllocations(t *testing.T) {
5555 func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
5656 dev := newMocksyncobjDevice(t)
5757 dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil)
58- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
58+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
5959 sw, err := newSyncWaiter(func() {})
6060 if err != nil {
6161 t.Fatal(err)
@@ -24,7 +24,7 @@ func TestReleaseHeldAllocations(t *testing.T) {
24 }24 }
25 25
26 func TestCommitDamageAllocations(t *testing.T) {26 func TestCommitDamageAllocations(t *testing.T) {
27- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}27+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
28 surf := &surface{server: srv}28 surf := &surface{server: srv}
29 commit := func() {29 commit := func() {
30 surf.Damage(nil, 0, 0, 4, 4)30 surf.Damage(nil, 0, 0, 4, 4)
@@ -55,7 +55,7 @@ func TestCommitDamageAllocations(t *testing.T) {
55 func TestCommitSyncStorageIsPerUpdate(t *testing.T) {55 func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
56 dev := newMocksyncobjDevice(t)56 dev := newMocksyncobjDevice(t)
57 dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil)57 dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil)
58- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}58+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
59 sw, err := newSyncWaiter(func() {})59 sw, err := newSyncWaiter(func() {})
60 if err != nil {60 if err != nil {
61 t.Fatal(err)61 t.Fatal(err)
M internal/adapters/wayland/commit_lifecycle_test.go +4 -4
@@ -18,7 +18,7 @@ import (
1818 // Applying an older captured commit must never transiently own newer requests.
1919 // The next commit captures those requests without losing its one-shot state.
2020 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{}}
2222 surf := &surface{server: srv, xdg: &xdgSurface{}}
2323 cons := &constraint{surface: surf}
2424 srv.constraints[surf] = cons
@@ -313,7 +313,7 @@ func TestQueuedDestructionDropsUpdates(t *testing.T) {
313313
314314 // A dropped update must be cleared before a different surface reuses it.
315315 func TestDroppedUpdateRecycledWithoutState(t *testing.T) {
316- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
316+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
317317 old := &surface{server: srv, next: pendingCommit{scale: 2, barrier: true, damage: []ports.Rect{{W: 2}}}}
318318 old.queueUpdate()
319319 old.destroyed = true
@@ -329,7 +329,7 @@ func TestDroppedUpdateRecycledWithoutState(t *testing.T) {
329329 // BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's
330330 // in-process queue/apply loop. It excludes protocol dispatch and real buffers.
331331 func BenchmarkEmptySurfaceQueueApply(b *testing.B) {
332- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
332+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
333333 surf := &surface{server: srv}
334334 surf.queueUpdate()
335335 surf.queue[0].applyGraph()
@@ -381,7 +381,7 @@ func TestCapturedViewportApplyAllocations(t *testing.T) {
381381 }
382382
383383 func TestCapturedCommitApplyAllocations(t *testing.T) {
384- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
384+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
385385 surf := &surface{server: srv}
386386 apply := func() {
387387 surf.queueUpdate()
@@ -18,7 +18,7 @@ import (
18 // Applying an older captured commit must never transiently own newer requests.18 // Applying an older captured commit must never transiently own newer requests.
19 // The next commit captures those requests without losing its one-shot state.19 // The next commit captures those requests without losing its one-shot state.
20 func TestCapturedCommitKeepsLaterRequests(t *testing.T) {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 surf := &surface{server: srv, xdg: &xdgSurface{}}22 surf := &surface{server: srv, xdg: &xdgSurface{}}
23 cons := &constraint{surface: surf}23 cons := &constraint{surface: surf}
24 srv.constraints[surf] = cons24 srv.constraints[surf] = cons
@@ -313,7 +313,7 @@ func TestQueuedDestructionDropsUpdates(t *testing.T) {
313 313
314 // A dropped update must be cleared before a different surface reuses it.314 // A dropped update must be cleared before a different surface reuses it.
315 func TestDroppedUpdateRecycledWithoutState(t *testing.T) {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 old := &surface{server: srv, next: pendingCommit{scale: 2, barrier: true, damage: []ports.Rect{{W: 2}}}}317 old := &surface{server: srv, next: pendingCommit{scale: 2, barrier: true, damage: []ports.Rect{{W: 2}}}}
318 old.queueUpdate()318 old.queueUpdate()
319 old.destroyed = true319 old.destroyed = true
@@ -329,7 +329,7 @@ func TestDroppedUpdateRecycledWithoutState(t *testing.T) {
329 // BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's329 // BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's
330 // in-process queue/apply loop. It excludes protocol dispatch and real buffers.330 // in-process queue/apply loop. It excludes protocol dispatch and real buffers.
331 func BenchmarkEmptySurfaceQueueApply(b *testing.B) {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 surf := &surface{server: srv}333 surf := &surface{server: srv}
334 surf.queueUpdate()334 surf.queueUpdate()
335 surf.queue[0].applyGraph()335 surf.queue[0].applyGraph()
@@ -381,7 +381,7 @@ func TestCapturedViewportApplyAllocations(t *testing.T) {
381 }381 }
382 382
383 func TestCapturedCommitApplyAllocations(t *testing.T) {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 surf := &surface{server: srv}385 surf := &surface{server: srv}
386 apply := func() {386 apply := func() {
387 surf.queueUpdate()387 surf.queueUpdate()
M internal/adapters/wayland/fifo.go +9 -6
@@ -757,8 +757,7 @@ func sameBuffer(a, b *wayland.Buffer) bool {
757757
758758 // wakePacer makes the pacer look at its queues. Display goroutine only.
759759 // 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.
762761 func (s *Server) wakePacer() {
763762 if s.paceDepth > 0 {
764763 s.paceDirty = true
@@ -767,10 +766,14 @@ func (s *Server) wakePacer() {
767766 s.signalPacer()
768767 }
769768
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.
771771 func (s *Server) signalPacer() {
772- select {
773- case s.frameReady <- struct{}{}:
774- default:
772+ if !s.paceSignal.CompareAndSwap(false, true) {
773+ return
774+ }
775+ if s.display == nil { // unit tests that build a Server without a display
776+ return
775777 }
778+ s.display.Post(s.paceSignalFn)
776779 }
@@ -757,8 +757,7 @@ func sameBuffer(a, b *wayland.Buffer) bool {
757 757
758 // wakePacer makes the pacer look at its queues. Display goroutine only.758 // wakePacer makes the pacer look at its queues. Display goroutine only.
759 // While a commit or a tick runs (paceDepth) it only records the request: the759 // 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 of760+// outermost caller then ticks inline (paceNow), saving a posted tick.
761-// waking the pacer goroutine just so that it calls Do back.
762 func (s *Server) wakePacer() {761 func (s *Server) wakePacer() {
763 if s.paceDepth > 0 {762 if s.paceDepth > 0 {
764 s.paceDirty = true763 s.paceDirty = true
@@ -767,10 +766,14 @@ func (s *Server) wakePacer() {
767 s.signalPacer()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 func (s *Server) signalPacer() {771 func (s *Server) signalPacer() {
772- select {772+ if !s.paceSignal.CompareAndSwap(false, true) {
773- case s.frameReady <- struct{}{}:773+ return
774- default:774+ }
775+ if s.display == nil { // unit tests that build a Server without a display
776+ return
775 }777 }
778+ s.display.Post(s.paceSignalFn)
776 }779 }
M internal/adapters/wayland/fifo_test.go +3 -3
@@ -154,7 +154,7 @@ func TestMonotonicTimeClamps(t *testing.T) {
154154 // A barrier on an output that flips clears on its next flip.
155155 func TestTickFifoClearsBarrierOnFlip(t *testing.T) {
156156 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)}
157+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
158158 surf := &surface{server: s, xdg: &xdgSurface{window: &window{last: ports.ConfigureWindow{Output: "DP-2"}}}}
159159 now := time.Unix(100, 0)
160160 s.lastFlip["DP-2"] = now.Add(-time.Millisecond)
@@ -217,7 +217,7 @@ func TestQueuedParentRetainsRetiredChild(t *testing.T) {
217217 name = "dropped"
218218 }
219219 t.Run(name, func(t *testing.T) {
220- srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
220+ srv := &Server{fifoSurfaces: make(map[*surface]struct{})}
221221 parent := &surface{server: srv}
222222 child := &surface{server: srv}
223223 child.sub.parent, child.sub.synced = parent, true
@@ -277,7 +277,7 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) {
277277
278278 func TestInvisibleFifoPeriod(t *testing.T) {
279279 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)}
280+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
281281 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}
282282 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
283283 now := time.Unix(100, 0)
@@ -154,7 +154,7 @@ func TestMonotonicTimeClamps(t *testing.T) {
154 // A barrier on an output that flips clears on its next flip.154 // A barrier on an output that flips clears on its next flip.
155 func TestTickFifoClearsBarrierOnFlip(t *testing.T) {155 func TestTickFifoClearsBarrierOnFlip(t *testing.T) {
156 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}156 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)}157+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
158 surf := &surface{server: s, xdg: &xdgSurface{window: &window{last: ports.ConfigureWindow{Output: "DP-2"}}}}158 surf := &surface{server: s, xdg: &xdgSurface{window: &window{last: ports.ConfigureWindow{Output: "DP-2"}}}}
159 now := time.Unix(100, 0)159 now := time.Unix(100, 0)
160 s.lastFlip["DP-2"] = now.Add(-time.Millisecond)160 s.lastFlip["DP-2"] = now.Add(-time.Millisecond)
@@ -217,7 +217,7 @@ func TestQueuedParentRetainsRetiredChild(t *testing.T) {
217 name = "dropped"217 name = "dropped"
218 }218 }
219 t.Run(name, func(t *testing.T) {219 t.Run(name, func(t *testing.T) {
220- srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}220+ srv := &Server{fifoSurfaces: make(map[*surface]struct{})}
221 parent := &surface{server: srv}221 parent := &surface{server: srv}
222 child := &surface{server: srv}222 child := &surface{server: srv}
223 child.sub.parent, child.sub.synced = parent, true223 child.sub.parent, child.sub.synced = parent, true
@@ -277,7 +277,7 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) {
277 277
278 func TestInvisibleFifoPeriod(t *testing.T) {278 func TestInvisibleFifoPeriod(t *testing.T) {
279 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}279 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)}280+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
281 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}281 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}
282 surf := &surface{server: s, xdg: &xdgSurface{window: w}}282 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
283 now := time.Unix(100, 0)283 now := time.Unix(100, 0)
M internal/adapters/wayland/frames.go +43 -63
@@ -1,7 +1,6 @@
11 package wayland
22
33 import (
4- "context"
54 "slices"
65 "time"
76
@@ -16,8 +15,10 @@ import (
1615 // frame. They are paced per output: on its page flip when it flips, else
1716 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
1817 // 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.
18+// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. Pacing runs
19+// on the display loop: output reports arrive through the presentation sink
20+// (presented.go) and deadlines through a timerfd; the timer sleeps while no
21+// callback waits.
2122
2223 // defaultFramePeriod paces surfaces without an output (60 Hz).
2324 const defaultFramePeriod = time.Second / 60
@@ -25,72 +26,51 @@ const suspendedFramePeriod = time.Second
2526 const suspendedFrameQueue = "\x00suspended" // never a wl_output name
2627
2728 // maxPacePasses bounds the passes one tick repeats while a pass queues
28-// more work; the rest goes back to the pacer goroutine.
29+// more work; the rest is posted to the display loop.
2930 const maxPacePasses = 3
3031
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) {
74- return
75- }
76- }
77-}
78-
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) }
82-
8332 // 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.
33+// commit). Without a registered pace timer (unit tests that build a Server
34+// without a display) it falls back to signalling a tick.
8635 func (s *Server) paceNow() {
87- if s.paceTimer == nil {
36+ if !s.paceReady {
8837 s.signalPacer()
8938 return
9039 }
9140 s.paceTick(nil, nil)
9241 }
9342
43+// armPace schedules the next flip-less tick d from now on the pace timerfd
44+// (nanosecond precision, one-shot). Display goroutine only.
45+func (s *Server) armPace(d time.Duration) {
46+ if d <= 0 {
47+ d = time.Nanosecond
48+ }
49+ spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(d))}
50+ _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
51+}
52+
53+// stopPace disarms the pace timerfd. Display goroutine only.
54+func (s *Server) stopPace() {
55+ var spec unix.ItimerSpec
56+ _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
57+}
58+
59+// onPaceTimer is the display loop's callback for the pace timerfd: the
60+// deadline armed by the last tick passed.
61+func (s *Server) onPaceTimer() {
62+ var buf [8]byte
63+ _, _ = unix.Read(s.paceFD, buf[:]) // level-triggered: consume the expiry
64+ s.paceTick(nil, nil)
65+}
66+
67+// paceSignalled is the posted tick: a wake (fence, overflow) made while the
68+// display goroutine was elsewhere.
69+func (s *Server) paceSignalled() {
70+ s.paceSignal.Store(false)
71+ s.paceTick(nil, nil)
72+}
73+
9474 // paceTick answers the flips in reports, fires the due frame callbacks,
9575 // releases buffers and applies the ready FIFO commits, then arms the timer
9676 // for the next deadline (stopped when nothing waits). Display goroutine only.
@@ -109,7 +89,7 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
10989 }
11090 // The pass queued work behind its own checks (applied commits
11191 // queue callbacks, released buffers): look again, as a flip-less
112- // tick, instead of waking the pacer for it.
92+ // tick, instead of posting one.
11393 flipped, reports = nil, nil
11494 if pass+1 == maxPacePasses {
11595 s.paceDirty = false
@@ -117,16 +97,16 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
11797 break
11898 }
11999 }
120- if s.paceTimer == nil {
100+ if !s.paceReady {
121101 return
122102 }
123103 if idle {
124104 // Nothing waits: sleep until a commit queues a callback
125105 // (wakePacer) or a flip.
126- s.paceTimer.Stop()
106+ s.stopPace()
127107 return
128108 }
129- s.paceTimer.Reset(wait)
109+ s.armPace(wait)
130110 }
131111
132112 // pacePass is one pass of paceTick; it returns the time to the next
@@ -1,7 +1,6 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4- "context"
5 "slices"4 "slices"
6 "time"5 "time"
7 6
@@ -16,8 +15,10 @@ import (
16 // frame. They are paced per output: on its page flip when it flips, else15 // frame. They are paced per output: on its page flip when it flips, else
17 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a16 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
18 // second and a V-Sync game follows the screen. Cursors and unplaced17 // 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. The18+// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. Pacing runs
20-// pacer sleeps while no callback waits.19+// on the display loop: output reports arrive through the presentation sink
20+// (presented.go) and deadlines through a timerfd; the timer sleeps while no
21+// callback waits.
21 22
22 // defaultFramePeriod paces surfaces without an output (60 Hz).23 // defaultFramePeriod paces surfaces without an output (60 Hz).
23 const defaultFramePeriod = time.Second / 6024 const defaultFramePeriod = time.Second / 60
@@ -25,72 +26,51 @@ const suspendedFramePeriod = time.Second
25 const suspendedFrameQueue = "\x00suspended" // never a wl_output name26 const suspendedFrameQueue = "\x00suspended" // never a wl_output name
26 27
27 // maxPacePasses bounds the passes one tick repeats while a pass queues28 // maxPacePasses bounds the passes one tick repeats while a pass queues
28-// more work; the rest goes back to the pacer goroutine.29+// more work; the rest is posted to the display loop.
29 const maxPacePasses = 330 const maxPacePasses = 3
30 31
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) {
74- return
75- }
76- }
77-}
78-
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) }
82-
83 // paceNow runs a tick from code already on the display goroutine (a client32 // 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. Without33+// commit). Without a registered pace timer (unit tests that build a Server
85-// a running pacer (no timer) it falls back to waking it.34+// without a display) it falls back to signalling a tick.
86 func (s *Server) paceNow() {35 func (s *Server) paceNow() {
87- if s.paceTimer == nil {36+ if !s.paceReady {
88 s.signalPacer()37 s.signalPacer()
89 return38 return
90 }39 }
91 s.paceTick(nil, nil)40 s.paceTick(nil, nil)
92 }41 }
93 42
43+// armPace schedules the next flip-less tick d from now on the pace timerfd
44+// (nanosecond precision, one-shot). Display goroutine only.
45+func (s *Server) armPace(d time.Duration) {
46+ if d <= 0 {
47+ d = time.Nanosecond
48+ }
49+ spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(d))}
50+ _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
51+}
52+
53+// stopPace disarms the pace timerfd. Display goroutine only.
54+func (s *Server) stopPace() {
55+ var spec unix.ItimerSpec
56+ _ = unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
57+}
58+
59+// onPaceTimer is the display loop's callback for the pace timerfd: the
60+// deadline armed by the last tick passed.
61+func (s *Server) onPaceTimer() {
62+ var buf [8]byte
63+ _, _ = unix.Read(s.paceFD, buf[:]) // level-triggered: consume the expiry
64+ s.paceTick(nil, nil)
65+}
66+
67+// paceSignalled is the posted tick: a wake (fence, overflow) made while the
68+// display goroutine was elsewhere.
69+func (s *Server) paceSignalled() {
70+ s.paceSignal.Store(false)
71+ s.paceTick(nil, nil)
72+}
73+
94 // paceTick answers the flips in reports, fires the due frame callbacks,74 // paceTick answers the flips in reports, fires the due frame callbacks,
95 // releases buffers and applies the ready FIFO commits, then arms the timer75 // releases buffers and applies the ready FIFO commits, then arms the timer
96 // for the next deadline (stopped when nothing waits). Display goroutine only.76 // for the next deadline (stopped when nothing waits). Display goroutine only.
@@ -109,7 +89,7 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
109 }89 }
110 // The pass queued work behind its own checks (applied commits90 // The pass queued work behind its own checks (applied commits
111 // queue callbacks, released buffers): look again, as a flip-less91 // queue callbacks, released buffers): look again, as a flip-less
112- // tick, instead of waking the pacer for it.92+ // tick, instead of posting one.
113 flipped, reports = nil, nil93 flipped, reports = nil, nil
114 if pass+1 == maxPacePasses {94 if pass+1 == maxPacePasses {
115 s.paceDirty = false95 s.paceDirty = false
@@ -117,16 +97,16 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
117 break97 break
118 }98 }
119 }99 }
120- if s.paceTimer == nil {100+ if !s.paceReady {
121 return101 return
122 }102 }
123 if idle {103 if idle {
124 // Nothing waits: sleep until a commit queues a callback104 // Nothing waits: sleep until a commit queues a callback
125 // (wakePacer) or a flip.105 // (wakePacer) or a flip.
126- s.paceTimer.Stop()106+ s.stopPace()
127 return107 return
128 }108 }
129- s.paceTimer.Reset(wait)109+ s.armPace(wait)
130 }110 }
131 111
132 // pacePass is one pass of paceTick; it returns the time to the next112 // 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) {
8888
8989 func TestInvisibleFramePacingAndMigration(t *testing.T) {
9090 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{}}
9292 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
9393 root := &surface{xdg: &xdgSurface{window: w}}
9494 child := &surface{sub: subState{parent: root}}
@@ -128,7 +128,7 @@ func TestInvisibleFramePacingAndMigration(t *testing.T) {
128128 // output's queue and move when Captured changes.
129129 func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
130130 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{}}
132132 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
133133 root := &surface{xdg: &xdgSurface{window: w}}
134134 a := new(wayland.Callback)
@@ -88,7 +88,7 @@ func TestDueFrames(t *testing.T) {
88 88
89 func TestInvisibleFramePacingAndMigration(t *testing.T) {89 func TestInvisibleFramePacingAndMigration(t *testing.T) {
90 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}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 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}92 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
93 root := &surface{xdg: &xdgSurface{window: w}}93 root := &surface{xdg: &xdgSurface{window: w}}
94 child := &surface{sub: subState{parent: root}}94 child := &surface{sub: subState{parent: root}}
@@ -128,7 +128,7 @@ func TestInvisibleFramePacingAndMigration(t *testing.T) {
128 // output's queue and move when Captured changes.128 // output's queue and move when Captured changes.
129 func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {129 func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
130 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}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 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}132 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
133 root := &surface{xdg: &xdgSurface{window: w}}133 root := &surface{xdg: &xdgSurface{window: w}}
134 a := new(wayland.Callback)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
@@ -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 +41 -29
@@ -6,11 +6,13 @@ import (
66
77 "github.com/bnema/go-wayland-bindings/server/wayland"
88 "github.com/bnema/neferwl/internal/ports"
9+ "golang.org/x/sys/unix"
910 )
1011
1112 // paceFixture is a server whose surface waits on an output whose next
1213 // frame is an hour away: a commit queues its callback and nothing fires.
13-func paceFixture(timer bool) (*Server, *surface) {
14+func paceFixture(t *testing.T, timer bool) (*Server, *surface) {
15+ t.Helper()
1416 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
1517 s := &Server{
1618 outputs: []*output{out},
@@ -20,11 +22,14 @@ func paceFixture(timer bool) (*Server, *surface) {
2022 lastFlip: map[string]time.Time{},
2123 flips: map[string]ports.FlipInfo{},
2224 reports: map[string]ports.OutputPresented{},
23- frameReady: make(chan struct{}, 1),
2425 }
2526 if timer {
26- s.paceTimer = time.NewTimer(time.Hour)
27- s.paceTimer.Stop()
27+ fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ t.Cleanup(func() { unix.Close(fd) })
32+ s.paceFD, s.paceReady = fd, true
2833 }
2934 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
3035 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
@@ -33,16 +38,16 @@ func paceFixture(timer bool) (*Server, *surface) {
3338 }
3439
3540 // 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.
41+// pace timer itself and posts no tick, which would only run later on the
42+// display goroutine it is already on.
3843 func TestCommitTicksPacerInline(t *testing.T) {
39- s, surf := paceFixture(true)
44+ s, surf := paceFixture(t, true)
4045 surf.queueAndTick()
4146 if len(s.awaiting["DP-2"]) != 1 {
4247 t.Fatalf("callback not queued: %+v", s.awaiting)
4348 }
44- if len(s.frameReady) != 0 {
45- t.Fatal("commit woke the pacer goroutine")
49+ if s.paceSignal.Load() {
50+ t.Fatal("commit posted a tick")
4651 }
4752 waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")
4853 if s.paceDepth != 0 || s.paceDirty {
@@ -50,54 +55,57 @@ func TestCommitTicksPacerInline(t *testing.T) {
5055 }
5156 }
5257
53-// Without a running pacer (no timer) the wake falls back to the channel.
58+// Without a registered pace timer the wake falls back to a posted tick.
5459 func TestCommitWakesPacerWithoutTimer(t *testing.T) {
55- s, surf := paceFixture(false)
60+ s, surf := paceFixture(t, false)
5661 surf.queueAndTick()
57- if len(s.frameReady) != 1 {
58- t.Fatal("pacer not woken")
62+ if !s.paceSignal.Load() {
63+ t.Fatal("tick not signalled")
5964 }
6065 }
6166
62-// Wakes inside a tick coalesce into one more pass, not one channel wake each.
67+// Wakes inside a tick coalesce into one more pass, not one posted tick each.
6368 func TestWakePacerDefersInsideTick(t *testing.T) {
64- s, _ := paceFixture(true)
69+ s, _ := paceFixture(t, true)
6570 s.paceDepth = 1
6671 s.wakePacer()
6772 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)
73+ if s.paceSignal.Load() || !s.paceDirty {
74+ t.Fatalf("wake inside a tick: signalled %v dirty %v", s.paceSignal.Load(), s.paceDirty)
7075 }
7176 s.paceDepth = 0
7277 s.wakePacer()
73- if len(s.frameReady) != 1 {
78+ if !s.paceSignal.Load() {
7479 t.Fatal("wake outside a tick lost")
7580 }
7681 }
7782
7883 // A commit that waits (a fifo barrier) is timed by the inline tick.
7984 func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
80- s, surf := paceFixture(true)
85+ s, surf := paceFixture(t, true)
8186 surf.next.callbacks = nil
8287 surf.barrier, surf.barrierAt = true, time.Now()
8388 surf.next.wait = true
8489 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))
90+ if len(surf.queue) != 1 || s.paceSignal.Load() {
91+ t.Fatalf("queued %d, tick signalled %v", len(surf.queue), s.paceSignal.Load())
8792 }
8893 waitArmed(t, s, "timer not armed for the barrier deadline")
8994 }
9095
91-// waitArmed asserts the timer was armed: it is pending, or it already
92-// fired and holds its value.
96+// waitArmed asserts the timerfd was armed: it is pending, or it already
97+// fired and holds its expiry.
9398 func waitArmed(t *testing.T, s *Server, msg string) {
9499 t.Helper()
95- if s.paceTimer.Stop() {
100+ var cur unix.ItimerSpec
101+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
102+ t.Fatal(err)
103+ }
104+ if cur.Value != (unix.Timespec{}) {
96105 return
97106 }
98- select {
99- case <-s.paceTimer.C:
100- default:
107+ var buf [8]byte
108+ if n, _ := unix.Read(s.paceFD, buf[:]); n != 8 {
101109 t.Fatal(msg)
102110 }
103111 }
@@ -105,14 +113,18 @@ func waitArmed(t *testing.T, s *Server, msg string) {
105113 // A synchronized subsurface commit waits for its parent's commit: it does
106114 // not tick the pacer, so a burst of child commits costs no ticks.
107115 func TestSyncedCommitDoesNotTick(t *testing.T) {
108- s, surf := paceFixture(true)
116+ s, surf := paceFixture(t, true)
109117 surf.sub.parent = &surface{server: s}
110118 surf.sub.synced = true
111119 surf.queueAndTick()
112120 if len(surf.queue) != 1 || !surf.queue[0].synced {
113121 t.Fatalf("synced commit not queued: %d", len(surf.queue))
114122 }
115- if len(s.frameReady) != 0 || s.paceTimer.Stop() {
123+ var cur unix.ItimerSpec
124+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
125+ t.Fatal(err)
126+ }
127+ if s.paceSignal.Load() || cur.Value != (unix.Timespec{}) {
116128 t.Fatal("synced commit ticked the pacer")
117129 }
118130 }
@@ -6,11 +6,13 @@ import (
6 6
7 "github.com/bnema/go-wayland-bindings/server/wayland"7 "github.com/bnema/go-wayland-bindings/server/wayland"
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9+ "golang.org/x/sys/unix"
9 )10 )
10 11
11 // paceFixture is a server whose surface waits on an output whose next12 // 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 // frame is an hour away: a commit queues its callback and nothing fires.
13-func paceFixture(timer bool) (*Server, *surface) {14+func paceFixture(t *testing.T, timer bool) (*Server, *surface) {
15+ t.Helper()
14 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}16 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
15 s := &Server{17 s := &Server{
16 outputs: []*output{out},18 outputs: []*output{out},
@@ -20,11 +22,14 @@ func paceFixture(timer bool) (*Server, *surface) {
20 lastFlip: map[string]time.Time{},22 lastFlip: map[string]time.Time{},
21 flips: map[string]ports.FlipInfo{},23 flips: map[string]ports.FlipInfo{},
22 reports: map[string]ports.OutputPresented{},24 reports: map[string]ports.OutputPresented{},
23- frameReady: make(chan struct{}, 1),
24 }25 }
25 if timer {26 if timer {
26- s.paceTimer = time.NewTimer(time.Hour)27+ fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
27- s.paceTimer.Stop()28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ t.Cleanup(func() { unix.Close(fd) })
32+ s.paceFD, s.paceReady = fd, true
28 }33 }
29 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}34 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
30 surf := &surface{server: s, xdg: &xdgSurface{window: w}}35 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
@@ -33,16 +38,16 @@ func paceFixture(timer bool) (*Server, *surface) {
33 }38 }
34 39
35 // A commit on the display goroutine ticks the pacer inline: it arms the40 // 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 would41+// pace timer itself and posts no tick, which would only run later on the
37-// only call display.Do to get back to the display goroutine.42+// display goroutine it is already on.
38 func TestCommitTicksPacerInline(t *testing.T) {43 func TestCommitTicksPacerInline(t *testing.T) {
39- s, surf := paceFixture(true)44+ s, surf := paceFixture(t, true)
40 surf.queueAndTick()45 surf.queueAndTick()
41 if len(s.awaiting["DP-2"]) != 1 {46 if len(s.awaiting["DP-2"]) != 1 {
42 t.Fatalf("callback not queued: %+v", s.awaiting)47 t.Fatalf("callback not queued: %+v", s.awaiting)
43 }48 }
44- if len(s.frameReady) != 0 {49+ if s.paceSignal.Load() {
45- t.Fatal("commit woke the pacer goroutine")50+ t.Fatal("commit posted a tick")
46 }51 }
47 waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")52 waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")
48 if s.paceDepth != 0 || s.paceDirty {53 if s.paceDepth != 0 || s.paceDirty {
@@ -50,54 +55,57 @@ func TestCommitTicksPacerInline(t *testing.T) {
50 }55 }
51 }56 }
52 57
53-// Without a running pacer (no timer) the wake falls back to the channel.58+// Without a registered pace timer the wake falls back to a posted tick.
54 func TestCommitWakesPacerWithoutTimer(t *testing.T) {59 func TestCommitWakesPacerWithoutTimer(t *testing.T) {
55- s, surf := paceFixture(false)60+ s, surf := paceFixture(t, false)
56 surf.queueAndTick()61 surf.queueAndTick()
57- if len(s.frameReady) != 1 {62+ if !s.paceSignal.Load() {
58- t.Fatal("pacer not woken")63+ t.Fatal("tick not signalled")
59 }64 }
60 }65 }
61 66
62-// Wakes inside a tick coalesce into one more pass, not one channel wake each.67+// Wakes inside a tick coalesce into one more pass, not one posted tick each.
63 func TestWakePacerDefersInsideTick(t *testing.T) {68 func TestWakePacerDefersInsideTick(t *testing.T) {
64- s, _ := paceFixture(true)69+ s, _ := paceFixture(t, true)
65 s.paceDepth = 170 s.paceDepth = 1
66 s.wakePacer()71 s.wakePacer()
67 s.wakePacer()72 s.wakePacer()
68- if len(s.frameReady) != 0 || !s.paceDirty {73+ if s.paceSignal.Load() || !s.paceDirty {
69- t.Fatalf("wake inside a tick: queued %d dirty %v", len(s.frameReady), s.paceDirty)74+ t.Fatalf("wake inside a tick: signalled %v dirty %v", s.paceSignal.Load(), s.paceDirty)
70 }75 }
71 s.paceDepth = 076 s.paceDepth = 0
72 s.wakePacer()77 s.wakePacer()
73- if len(s.frameReady) != 1 {78+ if !s.paceSignal.Load() {
74 t.Fatal("wake outside a tick lost")79 t.Fatal("wake outside a tick lost")
75 }80 }
76 }81 }
77 82
78 // A commit that waits (a fifo barrier) is timed by the inline tick.83 // A commit that waits (a fifo barrier) is timed by the inline tick.
79 func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {84 func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
80- s, surf := paceFixture(true)85+ s, surf := paceFixture(t, true)
81 surf.next.callbacks = nil86 surf.next.callbacks = nil
82 surf.barrier, surf.barrierAt = true, time.Now()87 surf.barrier, surf.barrierAt = true, time.Now()
83 surf.next.wait = true88 surf.next.wait = true
84 surf.queueAndTick()89 surf.queueAndTick()
85- if len(surf.queue) != 1 || len(s.frameReady) != 0 {90+ if len(surf.queue) != 1 || s.paceSignal.Load() {
86- t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))91+ t.Fatalf("queued %d, tick signalled %v", len(surf.queue), s.paceSignal.Load())
87 }92 }
88 waitArmed(t, s, "timer not armed for the barrier deadline")93 waitArmed(t, s, "timer not armed for the barrier deadline")
89 }94 }
90 95
91-// waitArmed asserts the timer was armed: it is pending, or it already96+// waitArmed asserts the timerfd was armed: it is pending, or it already
92-// fired and holds its value.97+// fired and holds its expiry.
93 func waitArmed(t *testing.T, s *Server, msg string) {98 func waitArmed(t *testing.T, s *Server, msg string) {
94 t.Helper()99 t.Helper()
95- if s.paceTimer.Stop() {100+ var cur unix.ItimerSpec
101+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
102+ t.Fatal(err)
103+ }
104+ if cur.Value != (unix.Timespec{}) {
96 return105 return
97 }106 }
98- select {107+ var buf [8]byte
99- case <-s.paceTimer.C:108+ if n, _ := unix.Read(s.paceFD, buf[:]); n != 8 {
100- default:
101 t.Fatal(msg)109 t.Fatal(msg)
102 }110 }
103 }111 }
@@ -105,14 +113,18 @@ func waitArmed(t *testing.T, s *Server, msg string) {
105 // A synchronized subsurface commit waits for its parent's commit: it does113 // A synchronized subsurface commit waits for its parent's commit: it does
106 // not tick the pacer, so a burst of child commits costs no ticks.114 // not tick the pacer, so a burst of child commits costs no ticks.
107 func TestSyncedCommitDoesNotTick(t *testing.T) {115 func TestSyncedCommitDoesNotTick(t *testing.T) {
108- s, surf := paceFixture(true)116+ s, surf := paceFixture(t, true)
109 surf.sub.parent = &surface{server: s}117 surf.sub.parent = &surface{server: s}
110 surf.sub.synced = true118 surf.sub.synced = true
111 surf.queueAndTick()119 surf.queueAndTick()
112 if len(surf.queue) != 1 || !surf.queue[0].synced {120 if len(surf.queue) != 1 || !surf.queue[0].synced {
113 t.Fatalf("synced commit not queued: %d", len(surf.queue))121 t.Fatalf("synced commit not queued: %d", len(surf.queue))
114 }122 }
115- if len(s.frameReady) != 0 || s.paceTimer.Stop() {123+ var cur unix.ItimerSpec
124+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
125+ t.Fatal(err)
126+ }
127+ if s.paceSignal.Load() || cur.Value != (unix.Timespec{}) {
116 t.Fatal("synced commit ticked the pacer")128 t.Fatal("synced commit ticked the pacer")
117 }129 }
118 }130 }
M internal/adapters/wayland/presentation_test.go +4 -5
@@ -46,14 +46,13 @@ func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
4646 }
4747
4848 // 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) {
5050 t.Helper()
5151 dir := t.TempDir()
5252 events := make(chan ports.ClientEvent, 16)
5353 commands := make(chan ports.ClientCommand, 16)
5454 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"))
55+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
5756 if err != nil {
5857 t.Fatal(err)
5958 }
@@ -61,7 +60,7 @@ func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
6160 done := make(chan error, 1)
6261 go func() { done <- s.Run(ctx) }()
6362 t.Cleanup(func() { cancel(); <-done })
64- return s, events, commands, contents, presented, dir
63+ return s, events, commands, contents, s.Presentation(), dir
6564 }
6665
6766 func TestPresentationFeedback(t *testing.T) {
@@ -156,7 +155,7 @@ func TestPresentationFeedback(t *testing.T) {
156155 }
157156 }
158157 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}}}
158+ 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}}})
160159 }
161160 // With no flip, hiding must discard a waiting feedback immediately.
162161 hidden := feedback()
@@ -46,14 +46,13 @@ func (p *feedbackEvents) Dispatch(e *wlturbo.Event) {
46 }46 }
47 47
48 // presentServer is a server whose flips the test sends.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()50 t.Helper()
51 dir := t.TempDir()51 dir := t.TempDir()
52 events := make(chan ports.ClientEvent, 16)52 events := make(chan ports.ClientEvent, 16)
53 commands := make(chan ports.ClientCommand, 16)53 commands := make(chan ports.ClientCommand, 16)
54 contents := make(chan ports.SurfaceContent, 64)54 contents := make(chan ports.SurfaceContent, 64)
55- presented := make(chan ports.OutputPresented, 16)55+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
56- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents, Presented: presented}, logging.For(context.Background(), "wayland"))
57 if err != nil {56 if err != nil {
58 t.Fatal(err)57 t.Fatal(err)
59 }58 }
@@ -61,7 +60,7 @@ func presentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
61 done := make(chan error, 1)60 done := make(chan error, 1)
62 go func() { done <- s.Run(ctx) }()61 go func() { done <- s.Run(ctx) }()
63 t.Cleanup(func() { cancel(); <-done })62 t.Cleanup(func() { cancel(); <-done })
64- return s, events, commands, contents, presented, dir63+ return s, events, commands, contents, s.Presentation(), dir
65 }64 }
66 65
67 func TestPresentationFeedback(t *testing.T) {66 func TestPresentationFeedback(t *testing.T) {
@@ -156,7 +155,7 @@ func TestPresentationFeedback(t *testing.T) {
156 }155 }
157 }156 }
158 flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) {157 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}}}158+ 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 }159 }
161 // With no flip, hiding must discard a waiting feedback immediately.160 // With no flip, hiding must discard a waiting feedback immediately.
162 hidden := feedback()161 hidden := feedback()
A internal/adapters/wayland/presented.go +112 -0
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "sync"
6+ "sync/atomic"
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. Each
13+// output already bounds its own queue (presented.MaxUnsent), so the ring only
14+// fills when the display loop is stalled.
15+const maxPresentRing = 64
16+
17+// presentSink is the ports.PresentationSink wayland offers to the outputs:
18+// output owner goroutines Push reports, the display goroutine drains them and
19+// paces frames. The ring is a transport queue between those goroutines, not
20+// window state: the mutex guards only the two slices and the pending flag.
21+type presentSink struct {
22+ s *Server
23+
24+ mu sync.Mutex
25+ ring, spare []ports.OutputPresented
26+ pending bool // a drain is posted and has not taken the ring yet
27+ drainFn func()
28+ drains atomic.Uint64 // finished drains
29+ scratchFlips map[string]bool
30+}
31+
32+func newPresentSink(s *Server) *presentSink {
33+ p := &presentSink{
34+ s: s,
35+ ring: make([]ports.OutputPresented, 0, maxPresentRing),
36+ spare: make([]ports.OutputPresented, 0, maxPresentRing),
37+ scratchFlips: make(map[string]bool),
38+ }
39+ p.drainFn = p.drain
40+ return p
41+}
42+
43+// Presentation returns the sink outputs report to. Push never blocks.
44+func (s *Server) Presentation() ports.PresentationSink { return s.sink }
45+
46+// Push queues a report for the display goroutine and wakes it; one wake
47+// serves every report pushed before the drain runs.
48+func (p *presentSink) Push(r ports.OutputPresented) {
49+ p.mu.Lock()
50+ if len(p.ring) >= maxPresentRing {
51+ p.dropOldestLocked()
52+ }
53+ p.ring = append(p.ring, r)
54+ post := !p.pending
55+ p.pending = true
56+ p.mu.Unlock()
57+ if post {
58+ p.s.display.Post(p.drainFn)
59+ }
60+}
61+
62+// dropOldestLocked makes room: the oldest report that a later report of the
63+// same output follows is folded into it (flips merge as in presented.Queue).
64+func (p *presentSink) dropOldestLocked() {
65+ victim := 0
66+ for i := range p.ring {
67+ if next := p.nextOfOutputLocked(i); next > 0 {
68+ if old, nf := p.ring[i].Flip, p.ring[next].Flip; old != nil && nf != nil {
69+ presented.Merge(old, nf)
70+ }
71+ victim = i
72+ break
73+ }
74+ }
75+ p.ring = slices.Delete(p.ring, victim, victim+1)
76+}
77+
78+// nextOfOutputLocked returns the index of the next report of i's output, 0
79+// when none follows.
80+func (p *presentSink) nextOfOutputLocked(i int) int {
81+ for j := i + 1; j < len(p.ring); j++ {
82+ if p.ring[j].Output == p.ring[i].Output {
83+ return j
84+ }
85+ }
86+ return 0
87+}
88+
89+// drain runs on the display goroutine: it paces frames for every report that
90+// arrived since the last drain as one tick, so a burst of flips fires one
91+// burst of frame callbacks per output.
92+func (p *presentSink) drain() {
93+ p.mu.Lock()
94+ batch := p.ring
95+ p.ring, p.spare = p.spare[:0], nil
96+ p.pending = false
97+ p.mu.Unlock()
98+ flipped := p.scratchFlips
99+ clear(flipped)
100+ for _, r := range batch {
101+ flipped[r.Output] = flipped[r.Output] || paces(r)
102+ }
103+ if len(batch) > 0 {
104+ p.s.paceTick(flipped, batch)
105+ }
106+ // The reports carry maps: do not retain them in the spare batch.
107+ clear(batch)
108+ p.mu.Lock()
109+ p.spare = batch[:0]
110+ p.mu.Unlock()
111+ p.drains.Add(1)
112+}
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "sync"
6+ "sync/atomic"
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. Each
13+// output already bounds its own queue (presented.MaxUnsent), so the ring only
14+// fills when the display loop is stalled.
15+const maxPresentRing = 64
16+
17+// presentSink is the ports.PresentationSink wayland offers to the outputs:
18+// output owner goroutines Push reports, the display goroutine drains them and
19+// paces frames. The ring is a transport queue between those goroutines, not
20+// window state: the mutex guards only the two slices and the pending flag.
21+type presentSink struct {
22+ s *Server
23+
24+ mu sync.Mutex
25+ ring, spare []ports.OutputPresented
26+ pending bool // a drain is posted and has not taken the ring yet
27+ drainFn func()
28+ drains atomic.Uint64 // finished drains
29+ scratchFlips map[string]bool
30+}
31+
32+func newPresentSink(s *Server) *presentSink {
33+ p := &presentSink{
34+ s: s,
35+ ring: make([]ports.OutputPresented, 0, maxPresentRing),
36+ spare: make([]ports.OutputPresented, 0, maxPresentRing),
37+ scratchFlips: make(map[string]bool),
38+ }
39+ p.drainFn = p.drain
40+ return p
41+}
42+
43+// Presentation returns the sink outputs report to. Push never blocks.
44+func (s *Server) Presentation() ports.PresentationSink { return s.sink }
45+
46+// Push queues a report for the display goroutine and wakes it; one wake
47+// serves every report pushed before the drain runs.
48+func (p *presentSink) Push(r ports.OutputPresented) {
49+ p.mu.Lock()
50+ if len(p.ring) >= maxPresentRing {
51+ p.dropOldestLocked()
52+ }
53+ p.ring = append(p.ring, r)
54+ post := !p.pending
55+ p.pending = true
56+ p.mu.Unlock()
57+ if post {
58+ p.s.display.Post(p.drainFn)
59+ }
60+}
61+
62+// dropOldestLocked makes room: the oldest report that a later report of the
63+// same output follows is folded into it (flips merge as in presented.Queue).
64+func (p *presentSink) dropOldestLocked() {
65+ victim := 0
66+ for i := range p.ring {
67+ if next := p.nextOfOutputLocked(i); next > 0 {
68+ if old, nf := p.ring[i].Flip, p.ring[next].Flip; old != nil && nf != nil {
69+ presented.Merge(old, nf)
70+ }
71+ victim = i
72+ break
73+ }
74+ }
75+ p.ring = slices.Delete(p.ring, victim, victim+1)
76+}
77+
78+// nextOfOutputLocked returns the index of the next report of i's output, 0
79+// when none follows.
80+func (p *presentSink) nextOfOutputLocked(i int) int {
81+ for j := i + 1; j < len(p.ring); j++ {
82+ if p.ring[j].Output == p.ring[i].Output {
83+ return j
84+ }
85+ }
86+ return 0
87+}
88+
89+// drain runs on the display goroutine: it paces frames for every report that
90+// arrived since the last drain as one tick, so a burst of flips fires one
91+// burst of frame callbacks per output.
92+func (p *presentSink) drain() {
93+ p.mu.Lock()
94+ batch := p.ring
95+ p.ring, p.spare = p.spare[:0], nil
96+ p.pending = false
97+ p.mu.Unlock()
98+ flipped := p.scratchFlips
99+ clear(flipped)
100+ for _, r := range batch {
101+ flipped[r.Output] = flipped[r.Output] || paces(r)
102+ }
103+ if len(batch) > 0 {
104+ p.s.paceTick(flipped, batch)
105+ }
106+ // The reports carry maps: do not retain them in the spare batch.
107+ clear(batch)
108+ p.mu.Lock()
109+ p.spare = batch[:0]
110+ p.mu.Unlock()
111+ p.drains.Add(1)
112+}
A internal/adapters/wayland/presented_test.go +116 -0
@@ -0,0 +1,116 @@
1+package wayland
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/fifo"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Flips pushed while the display goroutine is busy are paced by one drain:
14+// one tick, one burst of frame callbacks per output, not one per stale flip.
15+func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {
16+ s, _, _, _, sink, _ := presentServer(t)
17+ release, started := make(chan struct{}), make(chan struct{})
18+ finished := make(chan struct{})
19+ go func() {
20+ s.display.Do(func() { close(started); <-release })
21+ close(finished)
22+ }()
23+ <-started
24+ for i := range 5 {
25+ sink.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1), HardwareClock: true}})
26+ }
27+ close(release)
28+ <-finished
29+ var drains, seq uint64
30+ var reported bool
31+ s.display.Do(func() {
32+ drains = s.sink.drains.Load()
33+ seq = s.flips["HEADLESS-1"].Seq
34+ _, reported = s.reports["HEADLESS-1"]
35+ })
36+ if drains != 1 {
37+ t.Fatalf("%d drains for 5 flips pushed while busy, want 1", drains)
38+ }
39+ if !reported || seq != 5 {
40+ t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)
41+ }
42+}
43+
44+// A full ring merges the oldest flip of an output into its next one.
45+func TestPresentationRingMergesPastBound(t *testing.T) {
46+ p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing)}
47+ for i := range maxPresentRing {
48+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
49+ }
50+ p.mu.Lock()
51+ p.dropOldestLocked()
52+ p.mu.Unlock()
53+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {
54+ t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)
55+ }
56+}
57+
58+// Pushing a report and draining it on the display goroutine allocates nothing
59+// in steady state. The count includes the display goroutine: AllocsPerRun
60+// reads the global malloc counter.
61+func TestPresentationPushAllocations(t *testing.T) {
62+ if raceEnabled {
63+ t.Skip("the race detector makes the purego wake callback allocate")
64+ }
65+ s, _, _, _, sink, _ := presentServer(t)
66+ r := ports.OutputPresented{Output: "HEADLESS-1"}
67+ for range 4 {
68+ sink.Push(r)
69+ s.display.Do(func() {})
70+ }
71+ allocs := testing.AllocsPerRun(200, func() {
72+ want := s.sink.drains.Load() + 1
73+ sink.Push(r)
74+ for s.sink.drains.Load() < want { // wait for the drain without allocating
75+ runtime.Gosched()
76+ }
77+ })
78+ if allocs != 0 {
79+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
80+ }
81+}
82+
83+// A fifo barrier on an output that does not flip clears at barrierAt+period,
84+// driven by the pace timerfd: the next frame is not late by a timer tick.
85+func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
86+ s, events, _, contents, dir := contentServer(t)
87+ c := protocolClient(t, s, dir)
88+ w, surf, _ := surfaceMapper(t, c, events)()
89+ manager := bindProtocol(t, c, "wp_fifo_manager_v1")
90+ f := c.AllocateID()
91+ registerProtocol(t, c, f)
92+ requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
93+ buf := shmBuffer(t, c)
94+ drainContents(contents)
95+
96+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
97+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
98+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
99+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
100+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ var barrierAt time.Time
106+ var period time.Duration
107+ s.display.Do(func() {
108+ root := s.windows[w.ID].xdg.surface
109+ barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
110+ })
111+ nextContent(t, contents, w.ID) // the first commit
112+ late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
113+ if late < -time.Millisecond || late > 2*time.Millisecond {
114+ t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
115+ }
116+}
@@ -0,0 +1,116 @@
1+package wayland
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/fifo"
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Flips pushed while the display goroutine is busy are paced by one drain:
14+// one tick, one burst of frame callbacks per output, not one per stale flip.
15+func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {
16+ s, _, _, _, sink, _ := presentServer(t)
17+ release, started := make(chan struct{}), make(chan struct{})
18+ finished := make(chan struct{})
19+ go func() {
20+ s.display.Do(func() { close(started); <-release })
21+ close(finished)
22+ }()
23+ <-started
24+ for i := range 5 {
25+ sink.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1), HardwareClock: true}})
26+ }
27+ close(release)
28+ <-finished
29+ var drains, seq uint64
30+ var reported bool
31+ s.display.Do(func() {
32+ drains = s.sink.drains.Load()
33+ seq = s.flips["HEADLESS-1"].Seq
34+ _, reported = s.reports["HEADLESS-1"]
35+ })
36+ if drains != 1 {
37+ t.Fatalf("%d drains for 5 flips pushed while busy, want 1", drains)
38+ }
39+ if !reported || seq != 5 {
40+ t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)
41+ }
42+}
43+
44+// A full ring merges the oldest flip of an output into its next one.
45+func TestPresentationRingMergesPastBound(t *testing.T) {
46+ p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing)}
47+ for i := range maxPresentRing {
48+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
49+ }
50+ p.mu.Lock()
51+ p.dropOldestLocked()
52+ p.mu.Unlock()
53+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {
54+ t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)
55+ }
56+}
57+
58+// Pushing a report and draining it on the display goroutine allocates nothing
59+// in steady state. The count includes the display goroutine: AllocsPerRun
60+// reads the global malloc counter.
61+func TestPresentationPushAllocations(t *testing.T) {
62+ if raceEnabled {
63+ t.Skip("the race detector makes the purego wake callback allocate")
64+ }
65+ s, _, _, _, sink, _ := presentServer(t)
66+ r := ports.OutputPresented{Output: "HEADLESS-1"}
67+ for range 4 {
68+ sink.Push(r)
69+ s.display.Do(func() {})
70+ }
71+ allocs := testing.AllocsPerRun(200, func() {
72+ want := s.sink.drains.Load() + 1
73+ sink.Push(r)
74+ for s.sink.drains.Load() < want { // wait for the drain without allocating
75+ runtime.Gosched()
76+ }
77+ })
78+ if allocs != 0 {
79+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
80+ }
81+}
82+
83+// A fifo barrier on an output that does not flip clears at barrierAt+period,
84+// driven by the pace timerfd: the next frame is not late by a timer tick.
85+func TestPaceTimerfdClearsBarrierOnDeadline(t *testing.T) {
86+ s, events, _, contents, dir := contentServer(t)
87+ c := protocolClient(t, s, dir)
88+ w, surf, _ := surfaceMapper(t, c, events)()
89+ manager := bindProtocol(t, c, "wp_fifo_manager_v1")
90+ f := c.AllocateID()
91+ registerProtocol(t, c, f)
92+ requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
93+ buf := shmBuffer(t, c)
94+ drainContents(contents)
95+
96+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
97+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
98+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
99+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
100+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ var barrierAt time.Time
106+ var period time.Duration
107+ s.display.Do(func() {
108+ root := s.windows[w.ID].xdg.surface
109+ barrierAt, period = root.barrierAt, s.outputPeriod(s.fifoOutput(root))
110+ })
111+ nextContent(t, contents, w.ID) // the first commit
112+ late := nextContent(t, contents, w.ID).Sub(barrierAt.Add(period))
113+ if late < -time.Millisecond || late > 2*time.Millisecond {
114+ t.Fatalf("queued commit applied %v after barrierAt+%v", late, period)
115+ }
116+}
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
@@ -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/server.go +42 -18
@@ -71,9 +71,6 @@ type Channels struct {
7171 Workspaces <-chan ports.Workspaces
7272 // Cursors receives the cursor the client under the pointer asks for.
7373 Cursors chan<- ports.CursorChange
74- // Presented paces frame callbacks on the outputs' page flips; outputs
75- // that do not flip (idle, headless) are paced at their refresh rate.
76- Presented <-chan ports.OutputPresented
7774 // Captures carries accepted capture requests, never blocking. Each
7875 // accepted request holds one of ports.MaxCaptureInflight credits until its
7976 // CaptureDone arrives on Captured, whether or not the client is still
@@ -154,18 +151,20 @@ type Server struct {
154151 awaiting map[string][]*wayland.Callback
155152 frameOwners map[*wayland.Callback]*surface
156153 frameDue map[string]time.Time
157- frameReady chan struct{}
158- // paceTimer is the pacer's deadline, armed by ticks on the display
159- // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or
160- // tick (display goroutine only).
161- paceTimer *time.Timer
162- paceDepth int
163- paceDirty bool
164- // Pacer scratch: periods is used on the display goroutine, while
165- // flipped and frameReports belong to pace and its synchronous Do call.
154+ // paceFD is the pace deadline timerfd, armed by ticks on the display
155+ // goroutine (paceReady: registered with the display loop);
156+ // paceDepth/paceDirty defer wakePacer inside a commit or tick (display
157+ // goroutine only). paceSignal coalesces posted ticks (paceSignalFn).
158+ paceFD int
159+ paceReady bool
160+ paceSignal atomic.Bool
161+ paceSignalFn func()
162+ paceDepth int
163+ paceDirty bool
164+ // sink receives the outputs' reports (presented.go).
165+ sink *presentSink
166+ // framePeriods is the tick's scratch (display goroutine only).
166167 framePeriods map[string]time.Duration
167- flipped map[string]bool
168- frameReports []ports.OutputPresented
169168 // held buffers wait until no output reads them (release.go); reports
170169 // are the outputs' latest ports.OutputPresented.
171170 held []heldBuffer
@@ -319,7 +318,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
319318 display: d,
320319 awaiting: map[string][]*wayland.Callback{},
321320 frameDue: map[string]time.Time{},
322- frameReady: make(chan struct{}, 1),
323321 reports: map[string]ports.OutputPresented{},
324322 env: linuxProcEnv{},
325323 name: name,
@@ -420,7 +418,12 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
420418 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
421419 }
422420 s.syncWait.log = log
421+ s.sink = newPresentSink(s)
422+ s.paceSignalFn = s.paceSignalled
423423 s.cleanup = func() {
424+ if s.paceReady {
425+ unix.Close(s.paceFD)
426+ }
424427 if s.securityStop >= 0 {
425428 unix.Close(s.securityStop)
426429 }
@@ -441,6 +444,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
441444 d.Close()
442445 return nil, err
443446 }
447+ if err = s.openPaceTimer(); err != nil {
448+ s.cleanup()
449+ d.Close()
450+ return nil, err
451+ }
444452 if err = registerGlobals(d, opts, s); err != nil {
445453 s.cleanup()
446454 d.Close()
@@ -451,6 +459,24 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
451459 }
452460 return s, nil
453461 }
462+
463+// openPaceTimer creates the pace timerfd and registers it with the display
464+// loop (before Run, as AddFD requires).
465+func (s *Server) openPaceTimer() error {
466+ fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
467+ if err != nil {
468+ return err
469+ }
470+ // libwayland duplicates fd: ours stays open for TimerfdSettime and is
471+ // closed by cleanup; the source goes with the display.
472+ if _, err = s.display.AddFD(fd, s.onPaceTimer); err != nil {
473+ unix.Close(fd)
474+ return err
475+ }
476+ s.paceFD, s.paceReady = fd, true
477+ return nil
478+}
479+
454480 func (s *Server) SocketName() string { return s.name }
455481 func (s *Server) Run(ctx context.Context) error {
456482 defer s.cleanup()
@@ -464,9 +490,8 @@ func (s *Server) Run(ctx context.Context) error {
464490 s.log.Info().Str("socket", s.name).Msg("starting wayland")
465491 s.started = time.Now()
466492 s.ctx = ctx
467- s.paceTimer = time.NewTimer(defaultFramePeriod)
468493 var wg sync.WaitGroup
469- wg.Add(15)
494+ wg.Add(14)
470495 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
471496 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
472497 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -507,7 +532,6 @@ func (s *Server) Run(ctx context.Context) error {
507532 }
508533 }
509534 }()
510- go func() { defer wg.Done(); s.pace(ctx) }()
511535 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
512536 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
513537 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
@@ -71,9 +71,6 @@ type Channels struct {
71 Workspaces <-chan ports.Workspaces71 Workspaces <-chan ports.Workspaces
72 // Cursors receives the cursor the client under the pointer asks for.72 // Cursors receives the cursor the client under the pointer asks for.
73 Cursors chan<- ports.CursorChange73 Cursors chan<- ports.CursorChange
74- // Presented paces frame callbacks on the outputs' page flips; outputs
75- // that do not flip (idle, headless) are paced at their refresh rate.
76- Presented <-chan ports.OutputPresented
77 // Captures carries accepted capture requests, never blocking. Each74 // Captures carries accepted capture requests, never blocking. Each
78 // accepted request holds one of ports.MaxCaptureInflight credits until its75 // accepted request holds one of ports.MaxCaptureInflight credits until its
79 // CaptureDone arrives on Captured, whether or not the client is still76 // CaptureDone arrives on Captured, whether or not the client is still
@@ -154,18 +151,20 @@ type Server struct {
154 awaiting map[string][]*wayland.Callback151 awaiting map[string][]*wayland.Callback
155 frameOwners map[*wayland.Callback]*surface152 frameOwners map[*wayland.Callback]*surface
156 frameDue map[string]time.Time153 frameDue map[string]time.Time
157- frameReady chan struct{}154+ // paceFD is the pace deadline timerfd, armed by ticks on the display
158- // paceTimer is the pacer's deadline, armed by ticks on the display155+ // goroutine (paceReady: registered with the display loop);
159- // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or156+ // paceDepth/paceDirty defer wakePacer inside a commit or tick (display
160- // tick (display goroutine only).157+ // goroutine only). paceSignal coalesces posted ticks (paceSignalFn).
161- paceTimer *time.Timer158+ paceFD int
162- paceDepth int159+ paceReady bool
163- paceDirty bool160+ paceSignal atomic.Bool
164- // Pacer scratch: periods is used on the display goroutine, while161+ paceSignalFn func()
165- // flipped and frameReports belong to pace and its synchronous Do call.162+ paceDepth int
163+ paceDirty bool
164+ // sink receives the outputs' reports (presented.go).
165+ sink *presentSink
166+ // framePeriods is the tick's scratch (display goroutine only).
166 framePeriods map[string]time.Duration167 framePeriods map[string]time.Duration
167- flipped map[string]bool
168- frameReports []ports.OutputPresented
169 // held buffers wait until no output reads them (release.go); reports168 // held buffers wait until no output reads them (release.go); reports
170 // are the outputs' latest ports.OutputPresented.169 // are the outputs' latest ports.OutputPresented.
171 held []heldBuffer170 held []heldBuffer
@@ -319,7 +318,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
319 display: d,318 display: d,
320 awaiting: map[string][]*wayland.Callback{},319 awaiting: map[string][]*wayland.Callback{},
321 frameDue: map[string]time.Time{},320 frameDue: map[string]time.Time{},
322- frameReady: make(chan struct{}, 1),
323 reports: map[string]ports.OutputPresented{},321 reports: map[string]ports.OutputPresented{},
324 env: linuxProcEnv{},322 env: linuxProcEnv{},
325 name: name,323 name: name,
@@ -420,7 +418,12 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
420 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap418 s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
421 }419 }
422 s.syncWait.log = log420 s.syncWait.log = log
421+ s.sink = newPresentSink(s)
422+ s.paceSignalFn = s.paceSignalled
423 s.cleanup = func() {423 s.cleanup = func() {
424+ if s.paceReady {
425+ unix.Close(s.paceFD)
426+ }
424 if s.securityStop >= 0 {427 if s.securityStop >= 0 {
425 unix.Close(s.securityStop)428 unix.Close(s.securityStop)
426 }429 }
@@ -441,6 +444,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
441 d.Close()444 d.Close()
442 return nil, err445 return nil, err
443 }446 }
447+ if err = s.openPaceTimer(); err != nil {
448+ s.cleanup()
449+ d.Close()
450+ return nil, err
451+ }
444 if err = registerGlobals(d, opts, s); err != nil {452 if err = registerGlobals(d, opts, s); err != nil {
445 s.cleanup()453 s.cleanup()
446 d.Close()454 d.Close()
@@ -451,6 +459,24 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
451 }459 }
452 return s, nil460 return s, nil
453 }461 }
462+
463+// openPaceTimer creates the pace timerfd and registers it with the display
464+// loop (before Run, as AddFD requires).
465+func (s *Server) openPaceTimer() error {
466+ fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
467+ if err != nil {
468+ return err
469+ }
470+ // libwayland duplicates fd: ours stays open for TimerfdSettime and is
471+ // closed by cleanup; the source goes with the display.
472+ if _, err = s.display.AddFD(fd, s.onPaceTimer); err != nil {
473+ unix.Close(fd)
474+ return err
475+ }
476+ s.paceFD, s.paceReady = fd, true
477+ return nil
478+}
479+
454 func (s *Server) SocketName() string { return s.name }480 func (s *Server) SocketName() string { return s.name }
455 func (s *Server) Run(ctx context.Context) error {481 func (s *Server) Run(ctx context.Context) error {
456 defer s.cleanup()482 defer s.cleanup()
@@ -464,9 +490,8 @@ func (s *Server) Run(ctx context.Context) error {
464 s.log.Info().Str("socket", s.name).Msg("starting wayland")490 s.log.Info().Str("socket", s.name).Msg("starting wayland")
465 s.started = time.Now()491 s.started = time.Now()
466 s.ctx = ctx492 s.ctx = ctx
467- s.paceTimer = time.NewTimer(defaultFramePeriod)
468 var wg sync.WaitGroup493 var wg sync.WaitGroup
469- wg.Add(15)494+ wg.Add(14)
470 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()495 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
471 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()496 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
472 go func() { defer wg.Done(); s.forwardLeases(ctx) }()497 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -507,7 +532,6 @@ func (s *Server) Run(ctx context.Context) error {
507 }532 }
508 }533 }
509 }()534 }()
510- go func() { defer wg.Done(); s.pace(ctx) }()
511 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()535 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
512 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()536 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
513 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()537 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
M internal/adapters/wayland/surface_perf_test.go +1 -1
@@ -35,7 +35,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
3535 // The top-level test is listed in Makefile perf-check; this subtest guards
3636 // the per-commit update storage after its first retirement.
3737 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{})}
3939 surf := &surface{server: srv}
4040 commit := func() {
4141 surf.queueUpdate()
@@ -35,7 +35,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
35 // The top-level test is listed in Makefile perf-check; this subtest guards35 // The top-level test is listed in Makefile perf-check; this subtest guards
36 // the per-commit update storage after its first retirement.36 // the per-commit update storage after its first retirement.
37 t.Run("queueUpdateSteadyState", func(t *testing.T) {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 surf := &surface{server: srv}39 surf := &surface{server: srv}
40 commit := func() {40 commit := func() {
41 surf.queueUpdate()41 surf.queueUpdate()
M internal/adapters/wayland/syncobj_test.go +4 -5
@@ -49,7 +49,7 @@ type syncHarness struct {
4949 c *wlturbo.Display
5050 dev *mocksyncobjDevice
5151 contents chan ports.SurfaceContent
52- presented chan ports.OutputPresented
52+ presented ports.PresentationSink
5353 surf uint32
5454 syncSurf uint32
5555 timeline uint32
@@ -100,9 +100,8 @@ func newSyncHarness(t *testing.T) *syncHarness {
100100 events := make(chan ports.ClientEvent, 16)
101101 commands := make(chan ports.ClientCommand, 16)
102102 h.contents = make(chan ports.SurfaceContent, 64)
103- h.presented = make(chan ports.OutputPresented, 16)
104103 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"))
104+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands, Contents: h.contents}, logging.For(context.Background(), "wayland"))
106105 if err != nil {
107106 t.Fatal(err)
108107 }
@@ -118,7 +117,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
118117 }
119118 h.mu.Unlock()
120119 })
121- h.s = s
120+ h.s, h.presented = s, s.Presentation()
122121 h.c = protocolClient(t, s, dir)
123122 w, surf, xdg := surfaceMapper(t, h.c, events)()
124123 registerProtocol(t, h.c, xdg)
@@ -275,7 +274,7 @@ func TestSyncobjAcquireAndRelease(t *testing.T) {
275274 t.Fatal("acquire fence closed before release")
276275 }
277276 // 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}})
279278 deadline := time.Now().Add(2 * time.Second)
280279 for len(h.signalled()) == 0 && time.Now().Before(deadline) {
281280 if err := h.c.Roundtrip(); err != nil {
@@ -49,7 +49,7 @@ type syncHarness struct {
49 c *wlturbo.Display49 c *wlturbo.Display
50 dev *mocksyncobjDevice50 dev *mocksyncobjDevice
51 contents chan ports.SurfaceContent51 contents chan ports.SurfaceContent
52- presented chan ports.OutputPresented52+ presented ports.PresentationSink
53 surf uint3253 surf uint32
54 syncSurf uint3254 syncSurf uint32
55 timeline uint3255 timeline uint32
@@ -100,9 +100,8 @@ func newSyncHarness(t *testing.T) *syncHarness {
100 events := make(chan ports.ClientEvent, 16)100 events := make(chan ports.ClientEvent, 16)
101 commands := make(chan ports.ClientCommand, 16)101 commands := make(chan ports.ClientCommand, 16)
102 h.contents = make(chan ports.SurfaceContent, 64)102 h.contents = make(chan ports.SurfaceContent, 64)
103- h.presented = make(chan ports.OutputPresented, 16)
104 sup := ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}}103 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"))104+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands, Contents: h.contents}, logging.For(context.Background(), "wayland"))
106 if err != nil {105 if err != nil {
107 t.Fatal(err)106 t.Fatal(err)
108 }107 }
@@ -118,7 +117,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
118 }117 }
119 h.mu.Unlock()118 h.mu.Unlock()
120 })119 })
121- h.s = s120+ h.s, h.presented = s, s.Presentation()
122 h.c = protocolClient(t, s, dir)121 h.c = protocolClient(t, s, dir)
123 w, surf, xdg := surfaceMapper(t, h.c, events)()122 w, surf, xdg := surfaceMapper(t, h.c, events)()
124 registerProtocol(t, h.c, xdg)123 registerProtocol(t, h.c, xdg)
@@ -275,7 +274,7 @@ func TestSyncobjAcquireAndRelease(t *testing.T) {
275 t.Fatal("acquire fence closed before release")274 t.Fatal("acquire fence closed before release")
276 }275 }
277 // The output read the second content: the first buffer is released.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 deadline := time.Now().Add(2 * time.Second)278 deadline := time.Now().Add(2 * time.Second)
280 for len(h.signalled()) == 0 && time.Now().Before(deadline) {279 for len(h.signalled()) == 0 && time.Now().Before(deadline) {
281 if err := h.c.Roundtrip(); err != nil {280 if err := h.c.Roundtrip(); err != nil {
M internal/adapters/wayland/transform_test.go +1 -1
@@ -234,7 +234,7 @@ func TestOpaqueRect(t *testing.T) {
234234 // Transform and opaque region requested after a queued commit belong to
235235 // the next commit.
236236 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{}}
238238 surf := &surface{server: srv}
239239 surf.next.transform = 1
240240 surf.next.opaque, surf.next.opaqueSet = []ports.Rect{{W: 4, H: 4}}, true
@@ -234,7 +234,7 @@ func TestOpaqueRect(t *testing.T) {
234 // Transform and opaque region requested after a queued commit belong to234 // Transform and opaque region requested after a queued commit belong to
235 // the next commit.235 // the next commit.
236 func TestCapturedTransformAndOpaqueKeepLaterRequests(t *testing.T) {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 surf := &surface{server: srv}238 surf := &surface{server: srv}
239 surf.next.transform = 1239 surf.next.transform = 1
240 surf.next.opaque, surf.next.opaqueSet = []ports.Rect{{W: 4, H: 4}}, true240 surf.next.opaque, surf.next.opaqueSet = []ports.Rect{{W: 4, H: 4}}, true
M internal/app/app.go +14 -20
@@ -138,29 +138,23 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
138138 }
139139 }
140140
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- }
141+// framesTap is the sink outputs report to: it passes every report to wayland
142+// and tells core about each flip (a frame for running animations). A frame
143+// core has not read yet is enough, so frames never wait.
144+type framesTap struct {
145+ sink ports.PresentationSink
146+ frames chan<- ports.OutputFrame
147+}
148+
149+// Push implements ports.PresentationSink; it never blocks.
150+func (t framesTap) Push(p ports.OutputPresented) {
151+ if p.Flip != nil {
158152 select {
159- case out <- p:
160- case <-ctx.Done():
161- return
153+ case t.frames <- ports.OutputFrame{Output: p.Output}:
154+ default:
162155 }
163156 }
157+ t.sink.Push(p)
164158 }
165159
166160 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
@@ -138,29 +138,23 @@ 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 is141+// framesTap is the sink outputs report to: it passes every report to wayland
142-// also a frame for core; a frame core has not read yet is enough, so142+// and tells core about each flip (a frame for running animations). A frame
143-// frames never wait.143+// core has not read yet is enough, so frames never wait.
144-func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {144+type framesTap struct {
145- for {145+ sink ports.PresentationSink
146- var p ports.OutputPresented146+ frames chan<- ports.OutputFrame
147- select {147+}
148- case <-ctx.Done():148+
149- return149+// Push implements ports.PresentationSink; it never blocks.
150- case p = <-in:150+func (t framesTap) Push(p ports.OutputPresented) {
151- }151+ if p.Flip != nil {
152- if p.Flip != nil {
153- select {
154- case frames <- ports.OutputFrame{Output: p.Output}:
155- default:
156- }
157- }
158 select {152 select {
159- case out <- p:153+ case t.frames <- ports.OutputFrame{Output: p.Output}:
160- case <-ctx.Done():154+ default:
161- return
162 }155 }
163 }156 }
157+ t.sink.Push(p)
164 }158 }
165 159
166 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {160 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
M internal/app/app_test.go +24 -0
@@ -17,7 +17,10 @@ import (
1717 "github.com/bnema/neferwl/internal/adapters/config"
1818 "github.com/bnema/neferwl/internal/adapters/logging"
1919 "github.com/bnema/neferwl/internal/adapters/xkb"
20+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
2021 "github.com/bnema/neferwl/internal/ports"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
2124 )
2225
2326 func TestHeadlessConfigReload(t *testing.T) {
@@ -423,3 +426,24 @@ func TestHeadlessTwoOutputs(t *testing.T) {
423426 }
424427 }
425428 }
429+
430+// framesTap passes every report to wayland and tells core about a flip only,
431+// without ever blocking on a full frames channel.
432+func TestFramesTapForwardsReportsAndFlipFrames(t *testing.T) {
433+ var got []ports.OutputPresented
434+ sink := portsmocks.NewMockPresentationSink(t)
435+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) })
436+ frames := make(chan ports.OutputFrame, 1)
437+ tap := framesTap{sink: sink, frames: frames}
438+
439+ tap.Push(ports.OutputPresented{Output: "DP-2"})
440+ require.Empty(t, frames, "a report without a flip is not a frame")
441+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 1}})
442+ require.Equal(t, ports.OutputFrame{Output: "DP-2"}, <-frames)
443+
444+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 2}})
445+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 3}}) // frames full: must not block
446+ require.Len(t, frames, 1)
447+ require.Len(t, got, 4, "wayland receives every report")
448+ require.Equal(t, uint64(3), got[3].Flip.Seq)
449+}
@@ -17,7 +17,10 @@ import (
17 "github.com/bnema/neferwl/internal/adapters/config"17 "github.com/bnema/neferwl/internal/adapters/config"
18 "github.com/bnema/neferwl/internal/adapters/logging"18 "github.com/bnema/neferwl/internal/adapters/logging"
19 "github.com/bnema/neferwl/internal/adapters/xkb"19 "github.com/bnema/neferwl/internal/adapters/xkb"
20+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
20 "github.com/bnema/neferwl/internal/ports"21 "github.com/bnema/neferwl/internal/ports"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
21 )24 )
22 25
23 func TestHeadlessConfigReload(t *testing.T) {26 func TestHeadlessConfigReload(t *testing.T) {
@@ -423,3 +426,24 @@ func TestHeadlessTwoOutputs(t *testing.T) {
423 }426 }
424 }427 }
425 }428 }
429+
430+// framesTap passes every report to wayland and tells core about a flip only,
431+// without ever blocking on a full frames channel.
432+func TestFramesTapForwardsReportsAndFlipFrames(t *testing.T) {
433+ var got []ports.OutputPresented
434+ sink := portsmocks.NewMockPresentationSink(t)
435+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) })
436+ frames := make(chan ports.OutputFrame, 1)
437+ tap := framesTap{sink: sink, frames: frames}
438+
439+ tap.Push(ports.OutputPresented{Output: "DP-2"})
440+ require.Empty(t, frames, "a report without a flip is not a frame")
441+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 1}})
442+ require.Equal(t, ports.OutputFrame{Output: "DP-2"}, <-frames)
443+
444+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 2}})
445+ tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 3}}) // frames full: must not block
446+ require.Len(t, frames, 1)
447+ require.Len(t, got, 4, "wayland receives every report")
448+ require.Equal(t, uint64(3), got[3].Flip.Seq)
449+}
M internal/app/outputrouting.go +1 -1
@@ -16,7 +16,7 @@ type outputChannels struct {
1616 scenes <-chan []ports.Scene
1717 contents <-chan ports.SurfaceContent
1818 cursorChanges <-chan ports.CursorChange
19- presented chan<- ports.OutputPresented
19+ presented ports.PresentationSink
2020 captures <-chan ports.CaptureRequest
2121 captured chan<- ports.CaptureDone
2222 formats chan<- ports.OutputFormats
@@ -16,7 +16,7 @@ type outputChannels struct {
16 scenes <-chan []ports.Scene16 scenes <-chan []ports.Scene
17 contents <-chan ports.SurfaceContent17 contents <-chan ports.SurfaceContent
18 cursorChanges <-chan ports.CursorChange18 cursorChanges <-chan ports.CursorChange
19- presented chan<- ports.OutputPresented19+ presented ports.PresentationSink
20 captures <-chan ports.CaptureRequest20 captures <-chan ports.CaptureRequest
21 captured chan<- ports.CaptureDone21 captured chan<- ports.CaptureDone
22 formats chan<- ports.OutputFormats22 formats chan<- ports.OutputFormats
M internal/app/session.go +1 -3
@@ -351,12 +351,10 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
351351 defer workers.Done()
352352 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
353353 }()
354- workers.Add(1)
355- go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
356354
357355 go func() {
358356 defer workers.Done()
359- outputIO := p.outputs(security)
357+ outputIO := p.outputs(security, framesTap{sink: server.Presentation(), frames: p.frames})
360358 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
361359 if hw != nil {
362360 done <- safe("output", func() error {
@@ -351,12 +351,10 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
351 defer workers.Done()351 defer workers.Done()
352 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)352 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
353 }()353 }()
354- workers.Add(1)
355- go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
356 354
357 go func() {355 go func() {
358 defer workers.Done()356 defer workers.Done()
359- outputIO := p.outputs(security)357+ outputIO := p.outputs(security, framesTap{sink: server.Presentation(), frames: p.frames})
360 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))358 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
361 if hw != nil {359 if hw != nil {
362 done <- safe("output", func() error {360 done <- safe("output", func() error {
M internal/app/wiring.go +7 -13
@@ -41,12 +41,9 @@ type pipes struct {
4141 // config file.
4242 scales chan ports.ScaleChanged
4343
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+ contents chan ports.SurfaceContent
45+ // Outputs report to wayland through its presentation sink and tell core
46+ // about each flip (frames) to move running slides.
5047 frames chan ports.OutputFrame
5148
5249 captures chan ports.CaptureRequest
@@ -94,10 +91,8 @@ func newPipes(realSession bool) *pipes {
9491 configured: make(chan ports.ConfigChanged, 8),
9592 configErrors: make(chan error, 8),
9693
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+ contents: make(chan ports.SurfaceContent, 64),
95+ frames: make(chan ports.OutputFrame, 8),
10196
10297 captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
10398 captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
@@ -149,7 +144,6 @@ func (p *pipes) wayland() wayland.Channels {
149144 Workspaces: p.workspaces,
150145 Contents: p.contents,
151146 Cursors: p.cursorChanges,
152- Presented: p.presented,
153147 Captures: p.captures,
154148 Captured: p.captured,
155149 OutputFormats: p.outputFormats,
@@ -164,7 +158,7 @@ func (p *pipes) wayland() wayland.Channels {
164158 }
165159
166160 // outputs is the outputs' side (DRM or headless).
167-func (p *pipes) outputs(security ports.SessionSecurity) outputChannels {
161+func (p *pipes) outputs(security ports.SessionSecurity, presented ports.PresentationSink) outputChannels {
168162 return outputChannels{
169163 security: security,
170164 securityChanges: p.securityChanges,
@@ -173,7 +167,7 @@ func (p *pipes) outputs(security ports.SessionSecurity) outputChannels {
173167 scenes: p.renderScenes,
174168 contents: p.contents,
175169 cursorChanges: p.cursorChanges,
176- presented: p.flips,
170+ presented: presented,
177171 captures: p.captures,
178172 captured: p.captured,
179173 formats: p.outputFormats,
@@ -41,12 +41,9 @@ type pipes struct {
41 // config file.41 // config file.
42 scales chan ports.ScaleChanged42 scales chan ports.ScaleChanged
43 43
44- contents chan ports.SurfaceContent44+ contents chan ports.SurfaceContent
45- presented chan ports.OutputPresented45+ // Outputs report to wayland through its presentation sink and tell core
46- // Outputs report on flips; relayPresented passes them to wayland46+ // about each flip (frames) to move running slides.
47- // (presented) and tells core about each flip (frames) to move running
48- // slides.
49- flips chan ports.OutputPresented
50 frames chan ports.OutputFrame47 frames chan ports.OutputFrame
51 48
52 captures chan ports.CaptureRequest49 captures chan ports.CaptureRequest
@@ -94,10 +91,8 @@ func newPipes(realSession bool) *pipes {
94 configured: make(chan ports.ConfigChanged, 8),91 configured: make(chan ports.ConfigChanged, 8),
95 configErrors: make(chan error, 8),92 configErrors: make(chan error, 8),
96 93
97- contents: make(chan ports.SurfaceContent, 64),94+ contents: make(chan ports.SurfaceContent, 64),
98- presented: make(chan ports.OutputPresented, 64),95+ frames: make(chan ports.OutputFrame, 8),
99- flips: make(chan ports.OutputPresented, 64),
100- frames: make(chan ports.OutputFrame, 8),
101 96
102 captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),97 captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
103 captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),98 captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
@@ -149,7 +144,6 @@ func (p *pipes) wayland() wayland.Channels {
149 Workspaces: p.workspaces,144 Workspaces: p.workspaces,
150 Contents: p.contents,145 Contents: p.contents,
151 Cursors: p.cursorChanges,146 Cursors: p.cursorChanges,
152- Presented: p.presented,
153 Captures: p.captures,147 Captures: p.captures,
154 Captured: p.captured,148 Captured: p.captured,
155 OutputFormats: p.outputFormats,149 OutputFormats: p.outputFormats,
@@ -164,7 +158,7 @@ func (p *pipes) wayland() wayland.Channels {
164 }158 }
165 159
166 // outputs is the outputs' side (DRM or headless).160 // outputs is the outputs' side (DRM or headless).
167-func (p *pipes) outputs(security ports.SessionSecurity) outputChannels {161+func (p *pipes) outputs(security ports.SessionSecurity, presented ports.PresentationSink) outputChannels {
168 return outputChannels{162 return outputChannels{
169 security: security,163 security: security,
170 securityChanges: p.securityChanges,164 securityChanges: p.securityChanges,
@@ -173,7 +167,7 @@ func (p *pipes) outputs(security ports.SessionSecurity) outputChannels {
173 scenes: p.renderScenes,167 scenes: p.renderScenes,
174 contents: p.contents,168 contents: p.contents,
175 cursorChanges: p.cursorChanges,169 cursorChanges: p.cursorChanges,
176- presented: p.flips,170+ presented: presented,
177 captures: p.captures,171 captures: p.captures,
178 captured: p.captured,172 captured: p.captured,
179 formats: p.outputFormats,173 formats: p.outputFormats,
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+}
@@ -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+}
M internal/ports/output_events.go +4 -0
@@ -94,6 +94,10 @@ type OutputPresented struct {
9494 ChildReads map[WindowID]uint64
9595 }
9696
97+// PresentationSink receives output → wayland reports. Push never blocks;
98+// it wakes the consumer. Owner goroutines of outputs call it.
99+type PresentationSink interface{ Push(OutputPresented) }
100+
97101 // OutputFormats carries output → wayland the dmabuf formats an output can
98102 // scan out directly (its primary plane's, that the renderer also samples,
99103 // so a refused buffer can still be composed). Device is the KMS device
@@ -94,6 +94,10 @@ type OutputPresented struct {
94 ChildReads map[WindowID]uint6494 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.
99+type PresentationSink interface{ Push(OutputPresented) }
100+
97 // OutputFormats carries output → wayland the dmabuf formats an output can101 // OutputFormats carries output → wayland the dmabuf formats an output can
98 // scan out directly (its primary plane's, that the renderer also samples,102 // scan out directly (its primary plane's, that the renderer also samples,
99 // so a refused buffer can still be composed). Device is the KMS device103 // so a refused buffer can still be composed). Device is the KMS device

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close