detached 0d28346

Merge pull request 'perf: pace frames and hand content on the display loop without relay goroutines' (#45) from perf/small-damage into main

commit
0d28346b0fca03afd87264c406d3aa46a0e9011b
author
bnema · · 2026-10-14 04:38 UTC
merge of
3b19033 + c8242ea · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
116 files · +3440 -837
M .mockery.yml +7 -0
@@ -15,8 +15,10 @@ packages:
1515 interfaces:
1616 CaptureFrame:
1717 Clock:
18+ ContentReturn:
1819 Notifier:
1920 OutputScaleStore:
21+ PresentationSink:
2022 Renderer:
2123 Seat:
2224 SessionSecurity:
@@ -96,6 +98,11 @@ packages:
9698 dir: internal/app
9799 pkgname: app
98100 filename: leaseinventory_mock_test.go
101+ contentAttacher:
102+ config:
103+ dir: internal/app
104+ pkgname: app
105+ filename: contentattacher_mock_test.go
99106 github.com/bnema/neferwl/internal/adapters/statefile:
100107 interfaces:
101108 procTree:
M Makefile +2 -2
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := busretry|outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -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/headless ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations)$$' -count=1
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
M docs/performance.md +3 -1
@@ -40,7 +40,7 @@ GODEBUG=gctrace=1 neferwl --backend=drm
4040
4141 `make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off).
4242
43-Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion` and `input-keys`; name them to turn them on.
43+Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on.
4444
4545 ## Frame stats
4646
@@ -54,4 +54,6 @@ Every 10 s, each output logs a `stats` entry:
5454 | `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
5555 | `redrawn_pixels` | Pixels drawn |
5656
57+If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s.
58+
5759 Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
M go.mod +1 -1
@@ -6,7 +6,7 @@ require (
66 github.com/bnema/go-wayland-bindings v0.1.0
77 github.com/bnema/kvconf v0.1.2
88 github.com/bnema/purego v0.13.0-bnema.1
9- github.com/bnema/purego-libwayland v0.9.0
9+ github.com/bnema/purego-libwayland v0.10.0
1010 github.com/bnema/purego-vulkan v0.6.0
1111 github.com/bnema/wlturbo v0.5.0
1212 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2 generated file: collapsed
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, nil, discardSink(t), nil, nil)
435435 }()
436436 for {
437437 select {
M internal/adapters/drm/capture_test.go +7 -7
@@ -363,14 +363,14 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
363363 o.NewCaptureRenderer = func(int, int) (ports.Renderer, error) { return child, nil }
364364
365365 scenes := make(chan ports.Scene)
366- contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene
366+ contents := newContentFeed(t, 0) // unbuffered: taken before the scene
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.in(), contents.ret, nil, sink, captures, captured)
374374 }()
375375 count := func() int {
376376 commitMu.Lock()
@@ -379,7 +379,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
379379 }
380380 waitFor(t, func() bool { return count() == 2 })
381381
382- contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}
382+ contents.send(ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}})
383383 // The request waits for the scene; once a display frame is pending no
384384 // request is served, so it must be queued before the scene arrives.
385385 req := sessionRequest(t, 1, false)
@@ -411,7 +411,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
411411
412412 // A newer buffer of a display window arrives while the display commit is
413413 // pending: the GPU may still read the older one, so it is not reported.
414- contents <- ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}
414+ contents.send(ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}})
415415 _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished
416416 require.NoError(t, err)
417417 lifted := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) == 0 })
@@ -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, 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, nil, discardSink(t), captures, captured)
567567 }()
568568 count := func() int {
569569 commitMu.Lock()
M internal/adapters/drm/commit_alloc_test.go +6 -0
@@ -53,6 +53,12 @@ func TestCommitFrameAllocations(t *testing.T) {
5353 }
5454 })
5555 t.Run("success path", func(t *testing.T) {
56+ if raceEnabled {
57+ // The race runtime moves testify's count by a few
58+ // allocations per call, as much as the margin below.
59+ // perf-check runs it without; the subtest above stays exact.
60+ t.Skip("testify allocates unevenly under the race detector")
61+ }
5662 o, c := scanoutAllocsOutput(t)
5763 k := newMockkms(t)
5864 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).Return(nil)
M internal/adapters/drm/commit_test.go +7 -7
@@ -400,7 +400,7 @@ func enableAfterStartAwayModesetsOnce(t *testing.T) {
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, nil, discardSink(t), nil, nil)
404404 }()
405405 count := func(flags uint32) int {
406406 commitMu.Lock()
@@ -516,12 +516,12 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
516516 return commitRec{}, false
517517 }
518518 scenes := make(chan ports.Scene, 1)
519- contents := make(chan ports.SurfaceContent, 1)
520- presented := make(chan ports.OutputPresented, 8)
519+ contents := newContentFeed(t, 1)
520+ sink, presented := presentedSink(t, 8)
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.in(), contents.ret, nil, sink, nil, nil)
525525 }()
526526 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
527527 var frame commitRec
@@ -532,7 +532,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
532532 var cur commitRec
533533 waitFor(t, func() bool { var ok bool; cur, ok = last(userState); return ok })
534534 // A window this output does not show commits while the cursor flies.
535- contents <- ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}}
535+ contents.send(ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}})
536536 synctest.Wait()
537537 flips <- eventOf(cur)
538538 select {
@@ -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, nil, sink, nil, nil)
609609 }()
610610 count := func() int {
611611 commitMu.Lock()
A internal/adapters/drm/content_feed_test.go +39 -0
@@ -0,0 +1,39 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+)
10+
11+// contentFeed is the wayland side of Run's content input in tests: batches go
12+// out on ch, and the generated ContentReturn mock checks that each comes back
13+// reset.
14+type contentFeed struct {
15+ ch chan *ports.ContentBatch
16+ ret *portsmocks.MockContentReturn
17+ returned chan struct{}
18+}
19+
20+// newContentFeed makes a feed whose channel holds buffer batches.
21+func newContentFeed(t *testing.T, buffer int) *contentFeed {
22+ t.Helper()
23+ f := &contentFeed{ch: make(chan *ports.ContentBatch, buffer), ret: portsmocks.NewMockContentReturn(t), returned: make(chan struct{}, 64)}
24+ f.ret.EXPECT().Return(mock.Anything).Run(func(b *ports.ContentBatch) {
25+ if len(b.Items) != 0 {
26+ t.Errorf("batch returned with %d items, want it reset", len(b.Items))
27+ }
28+ f.returned <- struct{}{}
29+ }).Return().Maybe()
30+ return f
31+}
32+
33+// send hands the output one batch holding cs.
34+func (f *contentFeed) send(cs ...ports.SurfaceContent) {
35+ f.ch <- &ports.ContentBatch{Items: cs}
36+}
37+
38+// in is the channel Run reads.
39+func (f *contentFeed) in() <-chan *ports.ContentBatch { return f.ch }
M internal/adapters/drm/frame_lifecycle.go +3 -1
@@ -31,7 +31,9 @@ type pendingFrame struct {
3131 queued uint64
3232 zeroCopy ports.WindowID // window shown without composition
3333 async bool
34- shows map[ports.WindowID]uint64
34+ // animating: the scene shows a running animation; its flip wakes core.
35+ animating bool
36+ shows map[ports.WindowID]uint64
3537 // fences are duplicates of the fences the commit waits on, owned until
3638 // the commit ends. composed is set when the frame was rendered.
3739 fences []*os.File
M internal/adapters/drm/frame_submit.go +2 -2
@@ -34,7 +34,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
3434 }
3535 // shows is what the frame puts on screen: presentation feedback of
3636 // windows it does not draw is discarded, not presented.
37- f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)}
37+ f := pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.shownBy(scene, seen)}
3838 o.wantContent(scene, surfaces)
3939 // Requests that cannot be served (exclusion of another session, a
4040 // workspace that moved) are failed here, never answered from the
@@ -82,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
8282 }
8383 if decision.fb != 0 {
8484 c := decision.content
85- if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct {
85+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct {
8686 presented = err == nil
8787 return true, err
8888 }
A internal/adapters/drm/frames_test.go +70 -0
@@ -0,0 +1,70 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// A flip of an animating scene tells core the next frame is due; the flip of
11+// any other frame does not. A frames channel core has not read stays full: the
12+// owner never waits. An output without a frames channel (tests, nobody to wake)
13+// just flips.
14+func TestCompletedFrames(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ buffer int // frames channel capacity; 0: no channel
18+ animating []bool // the flips, one per frame
19+ want []ports.OutputFrame
20+ }{
21+ {name: "a settled scene wakes nobody", buffer: 2, animating: []bool{false}},
22+ {name: "an animating scene wakes core once", buffer: 2, animating: []bool{false, true}, want: []ports.OutputFrame{{Output: "DP-1"}}},
23+ {name: "a full channel never blocks", buffer: 1, animating: []bool{true, true, true}, want: []ports.OutputFrame{{Output: "DP-1"}}},
24+ {name: "no frames channel", animating: []bool{true}},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ o, _, commits := testOutput(t)
29+ o.cursor = nil
30+ var frames chan ports.OutputFrame
31+ if tt.buffer > 0 {
32+ frames = make(chan ports.OutputFrame, tt.buffer)
33+ o.Frames = frames
34+ }
35+ seen := map[ports.WindowID]uint64{}
36+ for i, animating := range tt.animating {
37+ require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: animating}))
38+ require.True(t, o.completed(eventOf((*commits)[i]), seen))
39+ }
40+ if frames != nil {
41+ require.Equal(t, tt.want, drain(frames))
42+ }
43+ })
44+ }
45+}
46+
47+func drain(ch chan ports.OutputFrame) []ports.OutputFrame {
48+ var out []ports.OutputFrame
49+ for len(ch) > 0 {
50+ out = append(out, <-ch)
51+ }
52+ return out
53+}
54+
55+// The frame send of an animating flip allocates nothing, full channel or not.
56+func TestAnimatingFrameSendAllocations(t *testing.T) {
57+ o, _, _ := testOutput(t)
58+ frames := make(chan ports.OutputFrame, 1)
59+ o.Frames = frames
60+ if n := testing.AllocsPerRun(100, func() {
61+ o.wakeCore() // fills the channel, or finds it full
62+ select {
63+ case <-frames:
64+ default:
65+ }
66+ o.wakeCore()
67+ }); n != 0 {
68+ t.Fatalf("wakeCore: %v allocations", n)
69+ }
70+}
A internal/adapters/drm/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package drm
4+
5+const raceEnabled = false
M internal/adapters/drm/output.go +26 -19
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/neferwl/internal/adapters/clock"
1313 "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
14+ "github.com/bnema/neferwl/internal/adapters/outputkit/corewake"
1415 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
1516 "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
1617
@@ -27,10 +28,13 @@ import (
2728 // bounded (stuckTimeout, then a modeset).
2829 type Output struct {
2930 // Set before Run. The backend owns instance registration and wake routing.
30- Security ports.SessionSecurity
31- SecurityChanges <-chan ports.SecurityState
32- Instance ports.OutputInstance
33- SecurityEvents chan<- ports.SecurityBackendEvent
31+ Security ports.SessionSecurity
32+ SecurityChanges <-chan ports.SecurityState
33+ Instance ports.OutputInstance
34+ SecurityEvents chan<- ports.SecurityBackendEvent
35+ // Frames, set before Run, tells core a flip of an animating scene
36+ // completed (nil: nobody is told). Sends never block.
37+ Frames chan<- ports.OutputFrame
3438 securityState ports.SecurityState
3539 securityPrepared bool
3640 securityInvalid bool
@@ -113,9 +117,8 @@ type Output struct {
113117 reports presented.Queue
114118 // Last immutable shows snapshot sent in a flip. Never mutate it: wayland
115119 // may still be reading a prior report on another goroutine.
116- showsSnapshot map[ports.WindowID]uint64
120+ shows surfaces.ShowsSnapshot
117121 directShowsSnapshot map[ports.WindowID]uint64
118- showsScratch map[ports.WindowID]uint64
119122 // Tearing (ADR 006): async commits of scanned-out buffers that ask for
120123 // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence
121124 // is set when an async commit may carry IN_FENCE_FD (probed once).
@@ -1002,8 +1005,8 @@ func (o *Output) Close() {
10021005 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
10031006
10041007 // Run renders scenes and commits them until ctx ends. active reports seat
1005-// enable/disable. What the output shows and read is reported on reportsCh.
1006-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, reportsCh chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
1008+// enable/disable. What the output shows and read is pushed to reports.
1009+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, reports ports.PresentationSink, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
10071010 defer func() {
10081011 p := recover()
10091012 if p != nil {
@@ -1229,11 +1232,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12291232 readWait = readWait || o.capped
12301233 }
12311234 }
1232- o.reports.Flush(reportsCh)
1233- // An unsent report is retried soon, not only on the next event;
1234- // so is one that waits for an uncommitted frame's fence.
1235+ o.reports.Flush(reports)
1236+ // A report that waits for an uncommitted frame's fence is retried
1237+ // soon, not only on the next event.
12351238 var retry <-chan time.Time
1236- if o.reports.Len() > 0 || readWait {
1239+ if readWait {
12371240 retryTimer.Reset(time.Millisecond)
12381241 retry = retryTimer.C
12391242 } else {
@@ -1429,14 +1432,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14291432 o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)
14301433 stateDirty = true
14311434 }
1432- case c := <-contents:
1433- table.Update(c, scene)
1434- if c.Seq > seen[c.ID] {
1435- seen[c.ID] = c.Seq
1436- reportDirty = true
1437- }
1435+ case b := <-contents:
14381436 // Windows on other outputs or workspaces do not need a frame.
1439- dirty = dirty || capture.Shows(scene, c.ID)
1437+ batchDirty, batchSeen := table.Apply(b, scene, seen, returns)
1438+ dirty = dirty || batchDirty
1439+ reportDirty = reportDirty || batchSeen
14401440 case <-stats.C():
14411441 now := clk.Now()
14421442 pipeline.Retire(scene) // an idle child of an ended session goes
@@ -1647,9 +1647,16 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
16471647 o.queued = 0
16481648 }
16491649 o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: o.refreshPeriod(), ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen)
1650+ if f.animating {
1651+ o.wakeCore()
1652+ }
16501653 return true
16511654 }
16521655
1656+// wakeCore tells core the next frame of a running animation is due. A frame
1657+// core has not read yet is enough, so the owner never waits.
1658+func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) }
1659+
16531660 // setCursor loads a cursor for scale and output transform t into the cursor
16541661 // images.
16551662 func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
M internal/adapters/drm/output_test.go +33 -26
@@ -14,7 +14,6 @@ import (
1414 "testing/synctest"
1515 "time"
1616
17- "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
1817 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1918 "github.com/bnema/neferwl/internal/ports"
2019 "github.com/bnema/zerowrap"
@@ -356,11 +355,12 @@ func TestReportsKeepEveryFlip(t *testing.T) {
356355 if o.reports.Len() != 2 || o.reports.Unsent()[0].Flip.Seq != 1 || o.reports.Unsent()[1].Flip.Seq != 2 || o.reports.Unsent()[1].Seen[1] != 3 {
357356 t.Fatalf("unsent %+v", o.reports.Unsent())
358357 }
359- for i := range presented.MaxUnsent + 3 {
358+ for i := range 20 {
360359 o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen)
361360 }
362- if o.reports.Len() != presented.MaxUnsent || o.reports.Unsent()[0].Flip.Merged == 0 {
363- t.Fatalf("cap: %d reports, first merged %d", o.reports.Len(), o.reports.Unsent()[0].Flip.Merged)
361+ // The wayland sink alone merges flips: the output queue keeps them all.
362+ if o.reports.Len() != 22 || o.reports.Unsent()[2].Flip.Merged != 0 {
363+ t.Fatalf("%d reports, merged %d", o.reports.Len(), o.reports.Unsent()[2].Flip.Merged)
364364 }
365365 }
366366
@@ -597,19 +597,26 @@ func runReportsSeenAfterFlip(t *testing.T) {
597597 return n
598598 }
599599 scenes := make(chan ports.Scene, 1)
600- contents := make(chan ports.SurfaceContent, 1)
601- presented := make(chan ports.OutputPresented, 8)
600+ contents := newContentFeed(t, 1)
601+ sink, presented := presentedSink(t, 8)
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.in(), contents.ret, 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 })
609609 // Frame 1 is in flight: content 3 replaces what it may be reading.
610- contents <- ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{}}
610+ // One batch carries two windows: both are applied and the batch goes
611+ // back to wayland (reset) at once, whatever the frame in flight reads.
612+ contents.send(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{}}, ports.SurfaceContent{ID: 2, Seq: 4, SHM: &ports.SHMBuffer{}})
611613 synctest.Wait()
612614 select {
615+ case <-contents.returned:
616+ default:
617+ t.Fatal("batch not returned")
618+ }
619+ select {
613620 case p := <-presented:
614621 t.Fatalf("report while a frame is in flight: %+v", p)
615622 default:
@@ -620,7 +627,7 @@ func runReportsSeenAfterFlip(t *testing.T) {
620627 flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
621628 select {
622629 case p := <-presented:
623- if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 {
630+ if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 || p.Seen[2] != 4 {
624631 t.Fatalf("report %+v", p)
625632 }
626633 case <-time.After(2 * time.Second):
@@ -679,7 +686,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
679686 ctx, cancel := context.WithCancel(context.Background())
680687 done := make(chan error, 1)
681688 go func() {
682- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
689+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, nil, discardSink(t), nil, nil)
683690 }()
684691 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100,
685692 Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}},
@@ -760,7 +767,7 @@ func TestRunTrimsRendererOnStatsTick(t *testing.T) {
760767 active <- false
761768 done := make(chan error, 1)
762769 go func() {
763- done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
770+ done <- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, discardSink(t), nil, nil)
764771 }()
765772 ticks <- now
766773 <-trimmed
@@ -814,7 +821,7 @@ func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) {
814821 }
815822 done := make(chan error, 1)
816823 go func() {
817- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, requests, replies)
824+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, nil, requests, replies)
818825 }()
819826 // The unbuffered handoff proves Run owns the request before any scene.
820827 select {
@@ -880,7 +887,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
880887 ctx, cancel := context.WithCancel(context.Background())
881888 done := make(chan error, 1)
882889 go func() {
883- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), requests, replies)
890+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, discardSink(t), requests, replies)
884891 }()
885892 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}
886893 select {
@@ -940,7 +947,7 @@ func TestRunPanicReportsReadinessError(t *testing.T) {
940947 t.Fatal("missing readiness")
941948 }
942949 }()
943- o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil)
950+ o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil, nil)
944951 }
945952
946953 // stampRun starts Run on an output with unbuffered scene and content
@@ -950,7 +957,7 @@ func TestRunPanicReportsReadinessError(t *testing.T) {
950957 type stampRun struct {
951958 o *Output
952959 scenes chan ports.Scene
953- contents chan ports.SurfaceContent
960+ contents *contentFeed
954961 flips chan flipEvent
955962 wake chan ports.SecurityState
956963 state *atomic.Uint64
@@ -962,7 +969,7 @@ type stampRun struct {
962969 func startStampRun(t *testing.T) *stampRun {
963970 o, k, commits, commitMu := testOutputMu(t)
964971 o.cursor, o.tearing = nil, false
965- sr := &stampRun{o: o, scenes: make(chan ports.Scene), contents: make(chan ports.SurfaceContent), flips: make(chan flipEvent, 1), wake: make(chan ports.SecurityState, 1), state: &atomic.Uint64{}}
972+ sr := &stampRun{o: o, scenes: make(chan ports.Scene), contents: newContentFeed(t, 0), flips: make(chan flipEvent, 1), wake: make(chan ports.SecurityState, 1), state: &atomic.Uint64{}}
966973 o.flipped = sr.flips
967974 securityGate(t, o, sr.state)
968975 o.SecurityChanges = sr.wake
@@ -1000,7 +1007,7 @@ func startStampRun(t *testing.T) *stampRun {
10001007 ctx, cancel := context.WithCancel(context.Background())
10011008 done := make(chan error, 1)
10021009 go func() {
1003- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents, nil, make(chan ports.OutputPresented, 8), nil, nil)
1010+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), sr.scenes, sr.contents.in(), sr.contents.ret, nil, discardSink(t), nil, nil)
10041011 }()
10051012 // stop ends Run: its state is ours to read afterwards.
10061013 sr.stop = func() {
@@ -1024,7 +1031,7 @@ func TestRunStampsWhenFrameWasWanted(t *testing.T) {
10241031 scene.Seq = 2
10251032 before := monotonic()
10261033 sr.scenes <- scene
1027- sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // Run went round its loop
1034+ sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}}) // Run went round its loop
10281035 flipSent := monotonic()
10291036 sr.flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
10301037 waitFor(t, func() bool { return sr.frameCommit() == 2 })
@@ -1047,11 +1054,11 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
10471054 waitFor(t, func() bool { return sr.frameCommit() == 1 })
10481055 scene.Seq = 2
10491056 sr.scenes <- scene
1050- sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now
1057+ sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}}) // stamped by now
10511058 // A new security generation, not protected.
10521059 sr.state.Store(2)
10531060 sr.wake <- ports.SecurityState{}
1054- sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset
1061+ sr.contents.send(ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}}) // Run went round and reset
10551062 sr.stop()
10561063 if sr.o.wantedAt != 0 {
10571064 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)
@@ -1069,8 +1076,8 @@ func TestOutputSameSeqRendersOnce(t *testing.T) {
10691076 sr.scenes <- scene
10701077 // Unshown content only sends Run round its loop: the flip and the held
10711078 // scene are handled by the time the second one is taken.
1072- sr.contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
1073- sr.contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
1079+ sr.contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}})
1080+ sr.contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}})
10741081 if n := sr.frameCommit(); n != 1 {
10751082 t.Fatalf("%d frames after a scene with the held Seq", n)
10761083 }
@@ -1128,11 +1135,11 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) {
11281135 return c, ok
11291136 }
11301137 scenes := make(chan ports.Scene, 1)
1131- contents := make(chan ports.SurfaceContent, 1)
1138+ contents := newContentFeed(t, 1)
11321139 ctx, cancel := context.WithCancel(context.Background())
11331140 done := make(chan error, 1)
11341141 go func() {
1135- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, make(chan ports.OutputPresented, 64), nil, nil)
1142+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, discardSink(t), nil, nil)
11361143 }()
11371144 acq, acqW, _ := os.Pipe()
11381145 defer acq.Close()
@@ -1140,11 +1147,11 @@ func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) {
11401147 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 10, H: 10}}
11411148 scenes <- ports.Scene{Seq: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}}
11421149 full := ports.SurfaceContent{ID: 1, Seq: 1, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 5, Planes: []ports.DMABufPlane{{}}}, Acquire: acq}
1143- contents <- full
1150+ contents.send(full)
11441151 drawn(1, func(c ports.SurfaceContent, ok bool) bool { return ok && c.Acquire == acq })
11451152 // The window unmaps: its empty content arrives before the scene that
11461153 // draws it leaving (fading). The last content stays, fence dropped.
1147- contents <- ports.SurfaceContent{ID: 1, Seq: 2}
1154+ contents.send(ports.SurfaceContent{ID: 1, Seq: 2})
11481155 kept, _ := drawn(1, func(c ports.SurfaceContent, ok bool) bool { return !ok || c.Acquire == nil })
11491156 if kept.DMABuf != full.DMABuf || kept.Seq != 1 {
11501157 t.Fatalf("frame after the empty content drew %+v, want the last content", kept)
M internal/adapters/drm/power_test.go +2 -2
@@ -50,7 +50,7 @@ func runOutputPower(t *testing.T) {
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, nil, nil, 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, nil, nil, nil, discardSink(t), nil, nil)
178178 }()
179179 if err := <-ready; err != nil {
180180 t.Fatal(err)
A internal/adapters/drm/presented_sink_test.go +32 -0
@@ -0,0 +1,32 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+)
10+
11+// presentedSink is a generated PresentationSink mock that records every push
12+// on the returned channel. A full channel drops the report: Push never blocks,
13+// as the port requires.
14+func presentedSink(t *testing.T, size int) (ports.PresentationSink, chan ports.OutputPresented) {
15+ t.Helper()
16+ ch := make(chan ports.OutputPresented, size)
17+ sink := portsmocks.NewMockPresentationSink(t)
18+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) {
19+ select {
20+ case ch <- r:
21+ default:
22+ }
23+ }).Maybe()
24+ return sink, ch
25+}
26+
27+// discardSink is a PresentationSink for tests that ignore the reports.
28+func discardSink(t *testing.T) ports.PresentationSink {
29+ t.Helper()
30+ sink, _ := presentedSink(t, 1)
31+ return sink
32+}
M internal/adapters/drm/protection_retry_test.go +1 -1
@@ -43,7 +43,7 @@ func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*o
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, nil, discardSink(t), nil, nil)
4747 }()
4848 return events, done, cancel
4949 }
A internal/adapters/drm/race_test.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build race
2+
3+package drm
4+
5+// raceEnabled: the race runtime changes allocation counts, which budgets
6+// measured against a mocked call cannot tell apart from a regression.
7+const raceEnabled = true
M internal/adapters/drm/reacquire_test.go +1 -1
@@ -96,7 +96,7 @@ func newRetryHarness(t *testing.T) retryHarness {
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, nil, discardSink(t), nil, nil)
100100 }()
101101 synctest.Wait()
102102 return retryHarness{
M internal/adapters/drm/report.go +6 -20
@@ -1,41 +1,27 @@
11 package drm
22
33 import (
4- "maps"
5-
6- "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
74 "github.com/bnema/neferwl/internal/ports"
85 )
96
107 // Reports to wayland: what the output shows and has read.
118
129 // report queues what the output shows and has read, so replaced client
13-// buffers can be released (see presented.Queue for merging).
10+// buffers can be released (see presented.Queue for coalescing).
1411 func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1512 o.capped = false
1613 var reads map[ports.WindowID]uint64
1714 if o.capHidden != nil {
1815 seen, reads, o.capped = o.capHidden(seen)
1916 }
20- if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) {
21- o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
22- }
17+ o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads})
2318 }
2419
25-// shownBy returns an immutable snapshot of the content Seq of drawn windows.
26-// The scratch map is private to the output; a snapshot may still be read by
27-// wayland after a subsequent flip, so never refill a previously sent map.
20+// shownBy returns an immutable snapshot of the content Seq of drawn windows
21+// (see surfaces.ShowsSnapshot). A closed window drawn from its kept content is
22+// not shown to its client: the empty content has no frame to present.
2823 func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
29- if o.showsScratch == nil {
30- o.showsScratch = make(map[ports.WindowID]uint64)
31- }
32- // A closed window drawn from its kept content is not shown to its
33- // client: the empty content has no frame to present.
34- surfaces.FillShown(o.showsScratch, seen, s, o.kept)
35- if !maps.Equal(o.showsSnapshot, o.showsScratch) {
36- o.showsSnapshot = maps.Clone(o.showsScratch)
37- }
38- return o.showsSnapshot
24+ return o.shows.FromSeen(s, seen, o.kept)
3925 }
4026
4127 func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 {
M internal/adapters/drm/seat_disable_test.go +1 -1
@@ -85,7 +85,7 @@ func TestRunPreparesSeatDisable(t *testing.T) {
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, nil, discardSink(t), nil, nil)
8989 }()
9090 if !o.PrepareSeatDisable(5 * time.Second) {
9191 t.Fatal("no ack")
M internal/adapters/drm/security.go +2 -0
@@ -113,6 +113,8 @@ func (o *Output) prepareSecurity(ctx context.Context, r ports.Renderer) error {
113113
114114 // Protected frames never enter client-fence KMS paths or capture pipelines.
115115 // Locker content is composed only after black preparation for this epoch.
116+// They never wake core: the pendingFrame has no animating flag, so core falls
117+// back to its frame timer while a lock is up. This is intentional.
116118 func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64) error {
117119 if !o.securityPrepared || !o.sceneCurrent(scene) {
118120 return errSecurityScene
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, nil, discardSink(t), nil, nil)
6565 }()
6666 receive := func() ports.SecurityBackendEvent {
6767 t.Helper()
M internal/adapters/drm/security_resume_test.go +1 -1
@@ -61,7 +61,7 @@ func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
6161 defer cancel()
6262 done := make(chan error, 1)
6363 go func() {
64- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil)
64+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil, nil)
6565 }()
6666 active <- true
6767 if stage == "master-loss" {
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, nil, discardSink(t), nil, nil)
6161 }()
6262 scenes <- ports.Scene{Seq: 99, Scale: 1}
6363 select {
M internal/adapters/drm/security_startup_test.go +2 -2
@@ -43,7 +43,7 @@ func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
4343 defer cancel()
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, scenes, nil, nil, nil, captures, captured)
46+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, nil, captures, captured)
4747 }()
4848 select {
4949 case ev := <-events:
@@ -107,7 +107,7 @@ func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) {
107107 r.EXPECT().Close().Return().Once()
108108 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
109109 k.EXPECT().rmFB(mock.Anything).Return(nil)
110- err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil)
110+ err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil, nil)
111111 if !errors.Is(err, context.Canceled) {
112112 t.Fatalf("startup cancellation: %v", err)
113113 }
M internal/adapters/drm/stuck_test.go +6 -4
@@ -30,9 +30,10 @@ func runStuck(t *testing.T, n int, errs ...error) []commitRec {
3030 type stuckRun struct {
3131 t *testing.T
3232 scenes chan ports.Scene
33- contents chan ports.SurfaceContent
33+ contents *contentFeed
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: newContentFeed(t, 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.in(), h.contents.ret, nil, h.sink, nil, nil)
7577 }()
7678 h.count = func() int {
7779 commitMu.Lock()
@@ -194,7 +196,7 @@ func TestSeenWaitsForRefusedFrameFence(t *testing.T) {
194196 h.scenes <- ports.Scene{Seq: 1}
195197 // EBUSY, then the retry loses DRM master: nothing is pending.
196198 waitFor(t, func() bool { return h.count() == 4 })
197- h.contents <- ports.SurfaceContent{ID: 1, Seq: 7}
199+ h.contents.send(ports.SurfaceContent{ID: 1, Seq: 7})
198200 select {
199201 case p := <-h.presented:
200202 t.Fatalf("seen reported while refused frames may be read: %+v", p)
M internal/adapters/drm/vrr_flip_gap_test.go +1 -1
@@ -115,7 +115,7 @@ func TestRunWaitsVRRFlipGap(t *testing.T) {
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, nil, discardSink(t), nil, nil)
119119 }()
120120 // A composed fullscreen window: a game frame under VRR.
121121 game := ports.Scene{OutputWidth: 200, OutputHeight: 100,
M internal/adapters/headless/capture_bounds_test.go +2 -2
@@ -27,7 +27,7 @@ func TestScreenshotReadbackFailureDoesNotWriteBlackPNG(t *testing.T) {
2727 r.EXPECT().Close().Return().Once()
2828 done := make(chan error, 1)
2929 go func() {
30- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
30+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
3131 }()
3232 select {
3333 case <-attempted:
@@ -64,7 +64,7 @@ func TestCaptureBatchBoundedAndRecycledWhileIdle(t *testing.T) {
6464 replies := make(chan ports.CaptureDone, capture.MaxRequests+1)
6565 done := make(chan error, 1)
6666 go func() {
67- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
67+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
6868 }()
6969 for i := range capture.MaxRequests + 1 {
7070 f, err := os.CreateTemp(t.TempDir(), "capture")
M internal/adapters/headless/capture_test.go +8 -8
@@ -74,7 +74,7 @@ func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wa
7474 replies := make(chan ports.CaptureDone, 8)
7575 done := make(chan error, 1)
7676 go func() {
77- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
77+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
7878 }()
7979 // Requests wait for a scene: all of them belong to its first frame.
8080 for _, q := range reqs {
@@ -163,12 +163,12 @@ 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 },
171- }, scenes, nil, nil, incoming)
171+ }, scenes, nil, nil, nil, incoming)
172172 }()
173173 q := ports.CaptureRequest{ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174174 incoming <- q
@@ -241,7 +241,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
241241 done := make(chan error, 1)
242242 go func() {
243243 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies,
244- NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming)
244+ NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, nil, incoming)
245245 }()
246246 incoming <- ports.CaptureRequest{ID: 1, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
247247 scenes <- sessionScene()
@@ -312,7 +312,7 @@ func TestCaptureWaitsForItsIndicatorScene(t *testing.T) {
312312 replies := make(chan ports.CaptureDone, 4)
313313 done := make(chan error, 1)
314314 go func() {
315- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
315+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
316316 }()
317317 bare := ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}}
318318 scenes <- bare
@@ -356,7 +356,7 @@ func TestCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
356356 replies := make(chan ports.CaptureDone, 4)
357357 done := make(chan error, 1)
358358 go func() {
359- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
359+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
360360 }()
361361 scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
362362 start := time.Now()
@@ -406,7 +406,7 @@ func TestCaptureDeliveredOnlyWhenTheIndicatedFramePresents(t *testing.T) {
406406 replies := make(chan ports.CaptureDone, 4)
407407 done := make(chan error, 1)
408408 go func() {
409- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
409+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
410410 }()
411411 incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
412412 scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}},
A internal/adapters/headless/content_feed_test.go +39 -0
@@ -0,0 +1,39 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+)
10+
11+// contentFeed is the wayland side of Run's content input in tests: batches go
12+// out on ch, and the generated ContentReturn mock checks that each comes back
13+// reset.
14+type contentFeed struct {
15+ ch chan *ports.ContentBatch
16+ ret *portsmocks.MockContentReturn
17+ returned chan *ports.ContentBatch
18+}
19+
20+// newContentFeed makes a feed whose channel holds buffer batches.
21+func newContentFeed(t *testing.T, buffer int) *contentFeed {
22+ t.Helper()
23+ f := &contentFeed{ch: make(chan *ports.ContentBatch, buffer), ret: portsmocks.NewMockContentReturn(t), returned: make(chan *ports.ContentBatch, 64)}
24+ f.ret.EXPECT().Return(mock.Anything).Run(func(b *ports.ContentBatch) {
25+ if len(b.Items) != 0 {
26+ t.Errorf("batch returned with %d items, want it reset", len(b.Items))
27+ }
28+ f.returned <- b
29+ }).Return().Maybe()
30+ return f
31+}
32+
33+// send hands the output one batch holding cs.
34+func (f *contentFeed) send(cs ...ports.SurfaceContent) {
35+ f.ch <- &ports.ContentBatch{Items: cs}
36+}
37+
38+// in is the channel Run reads.
39+func (f *contentFeed) in() <-chan *ports.ContentBatch { return f.ch }
A internal/adapters/headless/flip_alloc_test.go +28 -0
@@ -0,0 +1,28 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// Steady-state flip reporting: the Shows snapshot is reused while it does not
11+// change, and a changed one never mutates what an earlier flip handed out.
12+func TestFlipShowsAllocations(t *testing.T) {
13+ var f flipShows
14+ scene := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
15+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Seq: 1}, 2: {ID: 2, Seq: 2}}
16+ first := f.flipInfo(scene, contents, nil)
17+ require.Equal(t, map[ports.WindowID]uint64{1: 1}, first.Shows)
18+
19+ require.Zero(t, testing.AllocsPerRun(100, func() { f.of(scene, contents, nil) }), "unchanged shows")
20+ // The FlipInfo is the one allocation left: the sink's merge writes into it.
21+ require.LessOrEqual(t, testing.AllocsPerRun(100, func() { f.flipInfo(scene, contents, nil) }), 1.0, "flip report")
22+
23+ contents[1] = ports.SurfaceContent{ID: 1, Seq: 3}
24+ second := f.flipInfo(scene, contents, nil)
25+ require.Equal(t, map[ports.WindowID]uint64{1: 1}, first.Shows, "an earlier snapshot is immutable")
26+ require.Equal(t, map[ports.WindowID]uint64{1: 3}, second.Shows)
27+ require.NotSame(t, first, second)
28+}
A internal/adapters/headless/frames_test.go +66 -0
@@ -0,0 +1,66 @@
1+package headless
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// runWithFrames runs an output with a frames channel of the given size and
13+// returns what it needs to feed scenes and stop it.
14+func runWithFrames(t *testing.T, size int) (scenes chan ports.Scene, frames chan ports.OutputFrame, f *frames, stop func()) {
15+ t.Helper()
16+ scenes = make(chan ports.Scene, 8)
17+ frames = make(chan ports.OutputFrame, size)
18+ r, rec := recordingRenderer(t, nil)
19+ sink, _ := presentedSink(t, 16)
20+ ctx, cancel := context.WithCancel(context.Background())
21+ done := make(chan error, 1)
22+ go func() {
23+ done <- Run(ctx, Options{Width: 2, Height: 2, Name: "HEADLESS-1", Presented: sink, Frames: frames,
24+ NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
25+ }()
26+ return scenes, frames, rec, func() {
27+ cancel()
28+ require.NoError(t, <-done)
29+ }
30+}
31+
32+// The presentation of an animating scene tells core the next frame is due;
33+// the one of a settled scene does not.
34+func TestAnimatingSceneSendsFrame(t *testing.T) {
35+ scenes, frames, rec, stop := runWithFrames(t, 4)
36+ defer stop()
37+
38+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Animating: true}
39+ select {
40+ case got := <-frames:
41+ require.Equal(t, ports.OutputFrame{Output: "HEADLESS-1"}, got)
42+ case <-time.After(3 * time.Second):
43+ t.Fatal("no frame for an animating scene")
44+ }
45+
46+ // The scene an animation settles on: drawn, nobody woken.
47+ scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2}
48+ waitFrames(t, rec, 2)
49+ // A third scene is drawn after the settled one (the owner handles scenes
50+ // in order): had the settled frame woken core, its frame precedes the
51+ // third's in the channel.
52+ scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2}
53+ waitFrames(t, rec, 3)
54+ require.Empty(t, frames)
55+}
56+
57+// A frames channel core has not read stays full: the owner never waits.
58+func TestAnimatingFramesNeverBlock(t *testing.T) {
59+ scenes, frames, rec, stop := runWithFrames(t, 1)
60+ defer stop()
61+ for seq := uint64(1); seq <= 3; seq++ {
62+ scenes <- ports.Scene{Seq: seq, OutputWidth: 2, OutputHeight: 2, Animating: true}
63+ waitFrames(t, rec, int(seq))
64+ }
65+ require.Len(t, frames, 1)
66+}
M internal/adapters/headless/leaving_test.go +3 -2
@@ -10,15 +10,16 @@ import (
1010 // A window drawn from a content its client withdrew (closed, fading out) is
1111 // not reported shown: the empty content has no frame to present.
1212 func TestFlipInfoSkipsKeptContent(t *testing.T) {
13+ var shows flipShows
1314 table := surfaces.New()
1415 s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}}
1516 table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s)
1617 table.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, s)
17- if f := flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 {
18+ if f := shows.flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 {
1819 t.Fatalf("control: %v", f.Shows)
1920 }
2021 table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s)
21- f := flipInfo(s, table.Map(), table.Kept)
22+ f := shows.flipInfo(s, table.Map(), table.Kept)
2223 if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 {
2324 t.Fatalf("a leaving window is reported shown: %v", f.Shows)
2425 }
M internal/adapters/headless/output.go +41 -49
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/neferwl/internal/adapters/clock"
1515 "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
16+ "github.com/bnema/neferwl/internal/adapters/outputkit/corewake"
1617 "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
1718 "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
1819 "github.com/bnema/neferwl/internal/adapters/syncfile"
@@ -47,8 +48,11 @@ type Options struct {
4748 // Name and Presented report what the output has read after each
4849 // frame, so wayland can release client buffers (nil: no reports).
4950 Name string
50- Presented chan<- ports.OutputPresented
51- Captured chan<- ports.CaptureDone
51+ Presented ports.PresentationSink
52+ // Frames tells core a presented scene is animating: its next frame is
53+ // due. Sends never block (nil: nobody is told).
54+ Frames chan<- ports.OutputFrame
55+ Captured chan<- ports.CaptureDone
5256 // Clock paces the renderer trim (nil: system clock).
5357 Clock ports.Clock
5458 }
@@ -57,7 +61,7 @@ type Options struct {
5761 // windows it no longer draws.
5862 const trimEvery = 10 * time.Second
5963
60-func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
64+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
6165 r, err := opts.NewRenderer(opts.Width, opts.Height)
6266 if err != nil {
6367 return fmt.Errorf("create renderer: %w", err)
@@ -111,8 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
111115 cursorScale := -1.0 // not loaded yet
112116 var cursorTransform ports.BufferTransform
113117 seen := map[ports.WindowID]uint64{}
114- // reports holds what the channel could not take yet, retried soon.
118+ // reports queues what the output read; every report is pushed to the
119+ // sink at once (Push never blocks), so it holds one at most.
115120 var reports presented.Queue
121+ var shows flipShows
116122 var security ports.SecurityState
117123 securityKnown := false
118124 failRequests := func(reason error) {
@@ -186,10 +192,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
186192 // A closed window's last content goes once no scene draws it.
187193 table.Prune(scene)
188194 }
189- update := func(c ports.SurfaceContent) {
190- seen[c.ID] = max(seen[c.ID], c.Seq)
191- dirty = dirty || capture.Shows(scene, c.ID)
192- table.Update(c, scene)
195+ update := func(b *ports.ContentBatch) {
196+ batchDirty, _ := table.Apply(b, scene, seen, returns)
197+ dirty = dirty || batchDirty
193198 }
194199 clk := opts.Clock
195200 if clk == nil {
@@ -215,10 +220,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
215220 }
216221 return err
217222 }
218- var retry <-chan time.Time
219- if reports.Len() > 0 {
220- retry = time.After(time.Millisecond)
221- }
222223 // Requests wait for a scene that shows their indicator, at most
223224 // capture.HoldFor (see capture.Hold).
224225 var holdDue <-chan time.Time
@@ -290,15 +291,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
290291 refuse = capture.ErrOutputOff
291292 }
292293 dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
293- case <-retry:
294- if err := checkSecurity(); err != nil {
295- if ctx.Err() != nil {
296- return nil
297- }
298- return err
299- }
300- opts.flush(&reports)
301- continue
302294 case <-trim.C():
303295 if err := checkSecurity(); err != nil {
304296 if ctx.Err() != nil {
@@ -312,6 +304,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
312304 if err := r.Trim(clk.Now()); err != nil {
313305 return fmt.Errorf("trim renderer: %w", err)
314306 }
307+ opts.Log.Info().Str("output", opts.Name).Int("frames", frame).Msg("stats")
315308 continue
316309 case s, ok := <-scenes:
317310 if !ok {
@@ -325,7 +318,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
325318 return err
326319 }
327320 acceptScene(s)
328- case c, ok := <-contents:
321+ case b, ok := <-contents:
329322 if !ok {
330323 contents = nil
331324 continue
@@ -336,7 +329,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
336329 }
337330 return err
338331 }
339- update(c)
332+ update(b)
340333 case c, ok := <-cursor:
341334 if !ok {
342335 cursor = nil
@@ -367,7 +360,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
367360 }
368361 acceptScene(s)
369362 }
370- case c, ok := <-contents:
363+ case b, ok := <-contents:
371364 if !ok {
372365 contents = nil
373366 } else {
@@ -377,7 +370,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
377370 }
378371 return err
379372 }
380- update(c)
373+ update(b)
381374 }
382375 default:
383376 break drain
@@ -448,9 +441,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
448441 _, reads, _ := pipeline.CapHiddenSeen(seen)
449442 if len(reads) > 0 {
450443 reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
451- if reports.Drain(ctx, opts.Presented) != nil {
452- return nil
453- }
444+ reports.Flush(opts.Presented)
454445 }
455446 }
456447 if err := pipeline.WaitHidden(ctx); err != nil {
@@ -546,7 +537,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
546537 pipeline.EndGate(nil)
547538 requests.List = capture.Waiting(requests.List, scene)
548539 frame++
549- opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
540+ opts.report(&reports, shows.flipInfo(scene, drawn, table.Kept), seen)
541+ if scene.Animating {
542+ corewake.Send(opts.Frames, opts.Name)
543+ }
550544 if opts.ScreenshotDir != "" && !opts.protected() {
551545 shot := r.Pixels()
552546 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -617,37 +611,35 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
617611 }
618612
619613 // report queues what the output read, with flip for a drawn frame, and
620-// sends what the channel takes now; the rest is retried soon.
614+// pushes it to the sink.
621615 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
622616 if opts.Presented == nil {
623617 return
624618 }
625- if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {
626- opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
627- }
619+ q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen})
628620 q.Flush(opts.Presented)
629621 }
630622
631-// flush retries reports the channel could not take.
632-func (opts Options) flush(q *presented.Queue) {
633- if opts.Presented != nil {
634- q.Flush(opts.Presented)
635- }
623+// flipShows builds the Shows of successive flips. Its owner goroutine is the
624+// only user. The Shows snapshot is shared with earlier flips of the same
625+// content (see surfaces.ShowsSnapshot): never modify it.
626+type flipShows struct {
627+ snapshot surfaces.ShowsSnapshot
628+}
629+
630+// of returns the content Seq of the windows scene showed to their client.
631+func (f *flipShows) of(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
632+ // A closed window drawn from its kept content is not shown to its
633+ // client.
634+ return f.snapshot.FromContents(scene, contents, kept)
636635 }
637636
638637 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
639638 // time (software clock, refresh unknown), so presentation feedback and
640-// frame callbacks follow headless frames.
641-func flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
639+// frame callbacks follow headless frames. The FlipInfo itself is new per
640+// flip (the sink counts merges on it); only its Shows is shared.
641+func (f *flipShows) flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
642642 var ts unix.Timespec
643643 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
644- shows := map[ports.WindowID]uint64{}
645- for id, c := range contents {
646- // A closed window drawn from its kept content is not shown to
647- // its client.
648- if surfaces.Shown(scene, id, kept) {
649- shows[id] = c.Seq
650- }
651- }
652- return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}
644+ return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: f.of(scene, contents, kept)}
653645 }
M internal/adapters/headless/output_test.go +73 -23
@@ -1,6 +1,7 @@
11 package headless
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "image"
@@ -9,6 +10,7 @@ import (
910 "maps"
1011 "os"
1112 "path/filepath"
13+ "strings"
1214 "sync"
1315 "testing"
1416 "time"
@@ -16,6 +18,7 @@ import (
1618 "github.com/bnema/neferwl/internal/adapters/logging"
1719 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1820 "github.com/bnema/neferwl/internal/ports"
21+ "github.com/bnema/zerowrap"
1922 "github.com/stretchr/testify/mock"
2023 )
2124
@@ -68,17 +71,17 @@ func waitFrames(t *testing.T, f *frames, n int) {
6871 func TestRun(t *testing.T) {
6972 dir := t.TempDir()
7073 scenes := make(chan ports.Scene, 8)
71- contents := make(chan ports.SurfaceContent, 8)
74+ contents := newContentFeed(t, 8)
7275 r, f := recordingRenderer(t, nil)
73- contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
76+ contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
7477 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
7578 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
7679 scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
77- contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
80+ contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
7881 ctx, cancel := context.WithCancel(context.Background())
7982 done := make(chan error, 1)
8083 go func() {
81- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
84+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
8285 }()
8386 waitFrames(t, f, 1)
8487 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
@@ -86,7 +89,7 @@ func TestRun(t *testing.T) {
8689 }
8790 // An empty content of a window the scene still draws (it unmapped
8891 // and fades out) keeps the last one; a scene without it drops it.
89- contents <- ports.SurfaceContent{ID: 1}
92+ contents.send(ports.SurfaceContent{ID: 1})
9093 waitFrames(t, f, 2)
9194 if _, c := f.snapshot(); len(c) < 2 {
9295 t.Fatal("no second frame")
@@ -120,20 +123,20 @@ func TestRun(t *testing.T) {
120123 // A scene whose Seq the output already holds is not drawn again.
121124 func TestHeadlessSameSeqRendersOnce(t *testing.T) {
122125 scenes := make(chan ports.Scene)
123- contents := make(chan ports.SurfaceContent)
126+ contents := newContentFeed(t, 0)
124127 r, f := recordingRenderer(t, nil)
125128 ctx, cancel := context.WithCancel(context.Background())
126129 done := make(chan error, 1)
127130 go func() {
128- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
131+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
129132 }()
130133 scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
131134 scenes <- scene
132135 waitFrames(t, f, 1)
133136 scenes <- scene
134137 // Unshown content sends Run round its loop: the held scene is handled.
135- contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
136- contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
138+ contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}})
139+ contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}})
137140 if s, _ := f.snapshot(); len(s) != 1 {
138141 t.Fatalf("%d frames for a scene with the held Seq", len(s))
139142 }
@@ -152,22 +155,22 @@ func TestHeadlessSameSeqRendersOnce(t *testing.T) {
152155 // Content of a window the output does not show draws no frame.
153156 func TestRunSkipsContentNotShown(t *testing.T) {
154157 scenes := make(chan ports.Scene, 1)
155- contents := make(chan ports.SurfaceContent, 2)
158+ contents := newContentFeed(t, 2)
156159 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}}
157160 r, f := recordingRenderer(t, nil)
158161 ctx, cancel := context.WithCancel(context.Background())
159162 done := make(chan error, 1)
160163 go func() {
161- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
164+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
162165 }()
163166 waitFrames(t, f, 1)
164- contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
165- contents <- ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}
167+ contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
168+ contents.send(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}})
166169 time.Sleep(50 * time.Millisecond)
167170 if s, _ := f.snapshot(); len(s) != 1 {
168171 t.Fatalf("%d frames for content not shown", len(s))
169172 }
170- contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
173+ contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
171174 waitFrames(t, f, 2)
172175 if _, c := f.snapshot(); c[1][2].SHM == nil {
173176 t.Error("content not shown was dropped")
@@ -182,11 +185,11 @@ func TestErrors(t *testing.T) {
182185 expected := errors.New("failure")
183186 scenes := make(chan ports.Scene, 1)
184187 scenes <- ports.Scene{}
185- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
188+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
186189 t.Fatal(err)
187190 }
188191 r, _ := recordingRenderer(t, expected)
189- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
192+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
190193 t.Fatal(err)
191194 }
192195 }
@@ -214,8 +217,10 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
214217 return nil
215218 }).Twice()
216219 done := make(chan error, 1)
220+ var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
221+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
217222 go func() {
218- done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
223+ done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
219224 }()
220225 ticks <- now
221226 if got := <-trimmed; !got.Equal(now) {
@@ -228,6 +233,15 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
228233 if s, _ := f.snapshot(); len(s) != 0 {
229234 t.Fatalf("%d frames for a trim", len(s))
230235 }
236+ // A trim tick that succeeded logs the periodic stats once.
237+ if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
238+ t.Fatalf("%d stats entries: %s", got, statsLog.String())
239+ }
240+ for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`} {
241+ if !strings.Contains(statsLog.String(), want) {
242+ t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
243+ }
244+ }
231245 }
232246
233247 func TestWritePNGAtomic(t *testing.T) {
@@ -258,15 +272,15 @@ func TestWritePNGAtomic(t *testing.T) {
258272
259273 func TestRunPassesLayerContent(t *testing.T) {
260274 scenes := make(chan ports.Scene, 1)
261- contents := make(chan ports.SurfaceContent, 1)
275+ contents := newContentFeed(t, 1)
262276 layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
263277 scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
264- contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}}
278+ contents.send(ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}})
265279 r, f := recordingRenderer(t, nil)
266280 ctx, cancel := context.WithCancel(context.Background())
267281 done := make(chan error, 1)
268282 go func() {
269- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
283+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
270284 }()
271285 deadline := time.After(3 * time.Second)
272286 for {
@@ -302,7 +316,7 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
302316 ctx, cancel := context.WithCancel(context.Background())
303317 done := make(chan error, 1)
304318 go func() {
305- done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, requests)
319+ done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, requests)
306320 }()
307321 scenes <- ports.Scene{Background: "#000000"}
308322 waitFrames(t, frames, 1)
@@ -363,7 +377,7 @@ func TestHeadlessRawHDRScreenshot(t *testing.T) {
363377 ctx, cancel := context.WithCancel(context.Background())
364378 done := make(chan error, 1)
365379 go func() {
366- done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
380+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
367381 }()
368382 select {
369383 case <-rendered:
@@ -438,7 +452,7 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
438452 ctx, cancel := context.WithCancel(context.Background())
439453 done := make(chan error, 1)
440454 go func() {
441- done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
455+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
442456 }()
443457 select {
444458 case f := <-formats:
@@ -458,3 +472,39 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
458472 })
459473 }
460474 }
475+
476+// One batch with two windows applies both before the frame they show in, and
477+// goes back to wayland reset and exactly once.
478+func TestRunAppliesWholeBatch(t *testing.T) {
479+ scenes := make(chan ports.Scene, 1)
480+ contents := newContentFeed(t, 1)
481+ shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
482+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
483+ r, f := recordingRenderer(t, nil)
484+ ctx, cancel := context.WithCancel(context.Background())
485+ done := make(chan error, 1)
486+ go func() {
487+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
488+ }()
489+ waitFrames(t, f, 1)
490+ contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}})
491+ waitFrames(t, f, 2)
492+ if _, c := f.snapshot(); len(c[1]) != 2 || c[1][1].SHM == nil || c[1][2].SHM == nil {
493+ t.Errorf("batch not applied whole: %+v", c[1])
494+ }
495+ select {
496+ case b := <-contents.returned:
497+ if len(b.Items) != 0 {
498+ t.Errorf("batch returned with %d items", len(b.Items))
499+ }
500+ case <-time.After(2 * time.Second):
501+ t.Fatal("batch not returned")
502+ }
503+ cancel()
504+ if err := <-done; err != nil {
505+ t.Fatal(err)
506+ }
507+ if len(contents.returned) != 0 {
508+ t.Error("batch returned more than once")
509+ }
510+}
A internal/adapters/headless/presented_sink_test.go +25 -0
@@ -0,0 +1,25 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+)
10+
11+// presentedSink is a generated PresentationSink mock that records every push
12+// on the returned channel. A full channel drops the report: Push never blocks,
13+// as the port requires.
14+func presentedSink(t *testing.T, size int) (ports.PresentationSink, chan ports.OutputPresented) {
15+ t.Helper()
16+ ch := make(chan ports.OutputPresented, size)
17+ sink := portsmocks.NewMockPresentationSink(t)
18+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) {
19+ select {
20+ case ch <- r:
21+ default:
22+ }
23+ }).Maybe()
24+ return sink, ch
25+}
M internal/adapters/headless/security_capture_test.go +2 -2
@@ -61,7 +61,7 @@ func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
6161 incoming := make(chan ports.CaptureRequest)
6262 replies := make(chan ports.CaptureDone, 2)
6363 go func() {
64- done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
64+ done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming)
6565 }()
6666 dst := sessionCaptureFile(t)
6767 incoming <- ports.CaptureRequest{ID: 91, Exclude: excluded, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
@@ -124,7 +124,7 @@ func TestTransitionDuringHeadlessRenderWaitRetainsFenceAndRejectsCapture(t *test
124124 ctx, cancel := context.WithCancel(context.Background())
125125 done := make(chan error, 1)
126126 go func() {
127- done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
127+ done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming)
128128 }()
129129 dst := sessionCaptureFile(t)
130130 incoming <- ports.CaptureRequest{ID: 92, Dst: dst}
M internal/adapters/headless/security_exclude_fence_test.go +4 -4
@@ -46,9 +46,9 @@ func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
4646 }
4747 // Native capture and Pixels have no expectations; neither is admitted.
4848 scenes := make(chan ports.Scene, 2)
49- contents := make(chan ports.SurfaceContent, 1)
49+ contents := newContentFeed(t, 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,11 +56,11 @@ 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.in(), contents.ret, 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}
63- contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}}
63+ contents.send(ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}})
6464 scene := sessionScene()
6565 scene.Security = admitted
6666 scene.Scale = 1
M internal/adapters/headless/security_test.go +11 -11
@@ -58,14 +58,14 @@ func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) {
5858 scenes := make(chan ports.Scene, 3)
5959 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
6060 scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}}
61- contents := make(chan ports.SurfaceContent, 1)
61+ contents := newContentFeed(t, 1)
6262 acquire, acquireSignal, err := os.Pipe()
6363 if err != nil {
6464 t.Fatal(err)
6565 }
6666 defer acquire.Close()
6767 defer acquireSignal.Close()
68- contents <- ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire}
68+ contents.send(ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire})
6969 proof := make(chan ports.SecurityBackendEvent, 4)
7070 replies := make(chan ports.CaptureDone, 4)
7171 requests := make(chan ports.CaptureRequest, 2)
@@ -78,7 +78,7 @@ func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) {
7878 done := make(chan error, 1)
7979 dir := t.TempDir()
8080 go func() {
81- done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, requests)
81+ done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, requests)
8282 }()
8383 t.Cleanup(func() {
8484 cancel()
@@ -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, nil)
154154 }()
155155 t.Cleanup(func() {
156156 cancel()
@@ -191,7 +191,7 @@ func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) {
191191 ctx, cancel := context.WithCancel(context.Background())
192192 done := make(chan error, 1)
193193 go func() {
194- done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
194+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
195195 }()
196196 locked := ports.SecurityState{Generation: 2, Protected: true}
197197 state.Store(locked)
@@ -289,7 +289,7 @@ func TestSecurityRawHDRSuppressed(t *testing.T) {
289289 ctx, cancel := context.WithCancel(context.Background())
290290 done := make(chan error, 1)
291291 go func() {
292- done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
292+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
293293 }()
294294 // The black protection frame follows the frame under test (or is
295295 // the only one): the raw block is over once it is rendered.
@@ -336,7 +336,7 @@ func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
336336 ctx, cancel := context.WithCancel(context.Background())
337337 done := make(chan error, 1)
338338 go func() {
339- done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, incoming)
339+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, incoming)
340340 }()
341341 file, err := os.CreateTemp(t.TempDir(), "queued")
342342 if err != nil {
@@ -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, nil)
388388 }()
389389 securityReceive(t, presented) // Off has actually been applied by the owner.
390390 locked := ports.SecurityState{Generation: 4, Protected: true}
@@ -442,7 +442,7 @@ func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing
442442 defer cancel()
443443 done := make(chan error, 1)
444444 go func() {
445- done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
445+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming)
446446 }()
447447 file, err := os.CreateTemp(t.TempDir(), "capture")
448448 if err != nil {
M internal/adapters/logging/logging.go +7 -2
@@ -28,8 +28,13 @@ var components = map[string]bool{
2828 // categories are debug switches narrower than a component, only on when
2929 // named: "all" leaves them off. input-motion logs every pointer motion,
3030 // input-keys every key (typed text), drm-flip every frame and commit
31-// completion with its timing. Naming one turns on debug for its component.
32-var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}
31+// completion with its timing, wayland-damage every fresh client commit with
32+// its damage rects, render-damage every rendered frame with its redrawn
33+// area. Naming one turns on debug for its component.
34+var categories = map[string]string{
35+ "input-motion": "input", "input-keys": "input", "drm-flip": "drm",
36+ "wayland-damage": "wayland", "render-damage": "render",
37+}
3338
3439 type debugKey struct{}
3540 type levelKey struct{}
M internal/adapters/logging/logging_test.go +6 -1
@@ -168,8 +168,13 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) {
168168
169169 // Narrow categories are on only when named, never through "all".
170170 func TestEnabledCategories(t *testing.T) {
171- for _, category := range []string{"input-motion", "input-keys", "drm-flip"} {
171+ // The component each narrow category logs under; "" is not asserted.
172+ owners := map[string]string{"input-motion": "", "input-keys": "", "drm-flip": "", "wayland-damage": "wayland", "render-damage": "render"}
173+ for category, owner := range owners {
172174 component := categories[category]
175+ if owner != "" && component != owner {
176+ t.Errorf("%s belongs to %q, want %q", category, component, owner)
177+ }
173178 for _, tc := range []struct {
174179 debug string
175180 want bool
A internal/adapters/outputkit/corewake/corewake.go +19 -0
@@ -0,0 +1,19 @@
1+// Package corewake is how an output asks core for the next frame of a running
2+// animation.
3+package corewake
4+
5+import "github.com/bnema/neferwl/internal/ports"
6+
7+// Send tells core the next frame of a running animation of output is due.
8+// A frame core has not read yet is enough, so the owner never waits: a full
9+// channel drops the wake. A nil ch (tests, nobody to wake) does nothing.
10+// It allocates nothing.
11+func Send(ch chan<- ports.OutputFrame, output string) {
12+ if ch == nil {
13+ return
14+ }
15+ select {
16+ case ch <- ports.OutputFrame{Output: output}:
17+ default:
18+ }
19+}
A internal/adapters/outputkit/corewake/corewake_test.go +30 -0
@@ -0,0 +1,30 @@
1+package corewake
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// Send never blocks: a frame core has not read is enough.
11+func TestSend(t *testing.T) {
12+ ch := make(chan ports.OutputFrame, 1)
13+ Send(ch, "DP-1")
14+ Send(ch, "DP-1") // full: dropped, not blocked
15+ require.Equal(t, []ports.OutputFrame{{Output: "DP-1"}}, []ports.OutputFrame{<-ch})
16+ require.Empty(t, ch)
17+ require.NotPanics(t, func() { Send(nil, "DP-1") })
18+}
19+
20+func TestSendAllocations(t *testing.T) {
21+ ch := make(chan ports.OutputFrame, 1)
22+ require.Zero(t, testing.AllocsPerRun(100, func() {
23+ Send(ch, "DP-1")
24+ select {
25+ case <-ch:
26+ default:
27+ }
28+ Send(ch, "DP-1")
29+ }))
30+}
M internal/adapters/outputkit/presented/queue.go +20 -48
@@ -3,22 +3,19 @@
33 package presented
44
55 import (
6- "context"
76 "maps"
8- "slices"
97
108 "github.com/bnema/neferwl/internal/ports"
119 )
1210
13-// MaxUnsent bounds reports waiting for wayland; past it the oldest flip is
14-// merged into the next one.
15-const MaxUnsent = 16
16-
17-// Queue holds reports wayland has not read yet. Its owner goroutine is the
18-// only user. The Seen and ChildReads maps of a queued report are immutable
19-// snapshots: wayland may still read an earlier report on another goroutine,
20-// so a snapshot is replaced by a clone when its content changes and an
21-// unchanged report allocates nothing.
11+// Queue holds the reports an output has not pushed to wayland yet. The owner
12+// goroutine flushes it every loop turn, so it holds the few reports of one
13+// turn; the sink alone bounds what waits for the display loop (and merges
14+// flips when that stalls). Its owner goroutine is the only user. The Seen and
15+// ChildReads maps of a queued report are immutable snapshots: wayland may
16+// still read an earlier report on another goroutine, so a snapshot is
17+// replaced by a clone when its content changes and an unchanged report
18+// allocates nothing.
2219 type Queue struct {
2320 unsent []ports.OutputPresented
2421 seen, reads map[ports.WindowID]uint64
@@ -26,9 +23,8 @@ type Queue struct {
2623
2724 // Push queues r. Its Seen and ChildReads may be the caller's scratch maps:
2825 // the queue keeps snapshots. A flip-less report replaces the newest queued
29-// one when that one has no flip, or keeps its flip; flips are merged only
30-// past MaxUnsent. Push reports whether a flip was merged that way.
31-func (q *Queue) Push(r ports.OutputPresented) (merged bool) {
26+// one when that one has no flip, or keeps its flip; every flip is queued.
27+func (q *Queue) Push(r ports.OutputPresented) {
3228 if !maps.Equal(q.seen, r.Seen) {
3329 q.seen = maps.Clone(r.Seen)
3430 }
@@ -41,22 +37,15 @@ func (q *Queue) Push(r ports.OutputPresented) (merged bool) {
4137 r.Flip = q.unsent[n-1].Flip
4238 }
4339 q.unsent[n-1] = r
44- return false
40+ return
4541 }
4642 q.unsent = append(q.unsent, r)
47- if len(q.unsent) <= MaxUnsent {
48- return false
49- }
50- if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil {
51- mergeFlip(old, next)
52- }
53- q.unsent = slices.Delete(q.unsent, 0, 1)
54- return true
5543 }
5644
57-// mergeFlip folds old into next: next counts it as merged and shows what
45+// Merge folds old into next: next counts it as merged and shows what
5846 // old showed, so feedbacks waiting on old are answered (as discarded).
59-func mergeFlip(old, next *ports.FlipInfo) {
47+// Wayland's presentation sink uses it when its ring overflows.
48+func Merge(old, next *ports.FlipInfo) {
6049 next.Merged += 1 + old.Merged
6150 // A snapshot can be shared with previously sent flips. Clone only when
6251 // a merge actually needs to add an absent window.
@@ -76,30 +65,13 @@ func mergeFlip(old, next *ports.FlipInfo) {
7665 }
7766 }
7867
79-// Flush sends queued reports without blocking.
80-func (q *Queue) Flush(ch chan<- ports.OutputPresented) {
81- for len(q.unsent) > 0 {
82- select {
83- case ch <- q.unsent[0]:
84- q.unsent = slices.Delete(q.unsent, 0, 1)
85- default:
86- return
87- }
88- }
89-}
90-
91-// Drain sends every queued report, blocking until wayland reads them or ctx
92-// ends.
93-func (q *Queue) Drain(ctx context.Context, ch chan<- ports.OutputPresented) error {
94- for len(q.unsent) > 0 {
95- select {
96- case ch <- q.unsent[0]:
97- q.unsent = slices.Delete(q.unsent, 0, 1)
98- case <-ctx.Done():
99- return ctx.Err()
100- }
68+// Flush pushes every queued report to sink, oldest first, and empties the
69+// queue. Push never blocks.
70+func (q *Queue) Flush(sink ports.PresentationSink) {
71+ for _, r := range q.unsent {
72+ sink.Push(r)
10173 }
102- return nil
74+ q.Reset()
10375 }
10476
10577 // Len is the number of queued reports.
M internal/adapters/outputkit/presented/queue_test.go +31 -28
@@ -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
@@ -55,44 +56,46 @@ func TestQueueKeepsEveryFlip(t *testing.T) {
5556 require.Equal(t, uint64(3), u[1].Seen[1])
5657 }
5758
58-// Past MaxUnsent the oldest flip folds into the next: counted as merged and
59-// its shown windows carried, without mutating a shared Shows snapshot.
60-func TestQueueMergesOldestFlipPastBound(t *testing.T) {
61- var q Queue
59+// Merge folds a flip into the next: counted as merged and its shown windows
60+// carried, without mutating a shared Shows snapshot.
61+func TestMergeCarriesShowsWithoutMutatingSharedSnapshot(t *testing.T) {
6262 shared := map[ports.WindowID]uint64{2: 5}
63- q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1, Shows: map[ports.WindowID]uint64{1: 4}}})
64- q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2, Shows: shared}})
65- merged := false
66- for i := range MaxUnsent - 1 {
67- merged = q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: uint64(10 + i)}}) || merged
63+ old := &ports.FlipInfo{Seq: 1, Merged: 2, Shows: map[ports.WindowID]uint64{1: 4}}
64+ next := &ports.FlipInfo{Seq: 2, Shows: shared}
65+ Merge(old, next)
66+ require.Equal(t, 3, next.Merged)
67+ require.Equal(t, map[ports.WindowID]uint64{1: 4, 2: 5}, next.Shows)
68+ require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")
69+}
70+
71+// The queue never merges flips: every flip of a turn is pushed to the sink.
72+func TestQueueDoesNotBoundFlips(t *testing.T) {
73+ var q Queue
74+ for i := range 40 {
75+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
6876 }
69- require.True(t, merged)
7077 u := q.Unsent()
71- require.Len(t, u, MaxUnsent)
72- require.Equal(t, uint64(2), u[0].Flip.Seq)
73- require.Equal(t, 1, u[0].Flip.Merged)
74- require.Equal(t, map[ports.WindowID]uint64{1: 4, 2: 5}, u[0].Flip.Shows)
75- require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")
78+ require.Len(t, u, 40)
79+ require.Zero(t, u[0].Flip.Merged)
80+ require.Equal(t, uint64(1), u[0].Flip.Seq)
7681 }
7782
78-func TestQueueFlushStopsWhenFull(t *testing.T) {
83+func TestQueueFlushPushesInOrderAndEmpties(t *testing.T) {
7984 var q Queue
8085 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}})
8186 q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}})
82- ch := make(chan ports.OutputPresented, 1)
83- q.Flush(ch)
84- require.Equal(t, 1, q.Len())
85- require.Equal(t, uint64(1), (<-ch).Flip.Seq)
86- require.NoError(t, q.Drain(context.Background(), ch))
87+ var got []ports.OutputPresented
88+ sink := portsmocks.NewMockPresentationSink(t)
89+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) })
90+ q.Flush(sink)
8791 require.Zero(t, q.Len())
88- require.Equal(t, uint64(2), (<-ch).Flip.Seq)
92+ require.Len(t, got, 2)
93+ require.Equal(t, uint64(1), got[0].Flip.Seq)
94+ require.Equal(t, uint64(2), got[1].Flip.Seq)
95+ q.Flush(sink) // empty: nothing more is pushed
96+ require.Len(t, got, 2)
8997
9098 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)
9699 q.Reset()
97100 require.Zero(t, q.Len())
98101 }
M internal/adapters/outputkit/surfaces/surfaces.go +69 -5
@@ -4,6 +4,8 @@
44 package surfaces
55
66 import (
7+ "maps"
8+
79 "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
810 "github.com/bnema/neferwl/internal/ports"
911 )
@@ -106,13 +108,75 @@ func Shown(scene ports.Scene, id ports.WindowID, kept func(ports.WindowID) bool)
106108 return scene.Shows(id) && (kept == nil || !kept(id))
107109 }
108110
109-// FillShown clears dst and fills it with the windows of seen that scene
110-// showed to their client (Shown), at their seen Seq.
111-func FillShown(dst, seen map[ports.WindowID]uint64, scene ports.Scene, kept func(ports.WindowID) bool) {
112- clear(dst)
111+// Apply takes every content of b into the table, as the output's owner does
112+// when it receives a batch: Update per item against scene, seen advanced to
113+// the highest Seq per window, then the batch reset and handed back through
114+// ret. Reset-then-return is the ownership rule of ports.ContentBatch; b is
115+// not the caller's after Apply.
116+//
117+// dirty reports a content of a window scene draws (windows on other outputs
118+// or workspaces need no frame). seenChanged reports a window whose seen Seq
119+// moved forward: a new report is due.
120+func (t *Table) Apply(b *ports.ContentBatch, scene ports.Scene, seen map[ports.WindowID]uint64, ret ports.ContentReturn) (dirty, seenChanged bool) {
121+ for i := range b.Items {
122+ c := b.Items[i]
123+ t.Update(c, scene)
124+ if c.Seq > seen[c.ID] {
125+ seen[c.ID] = c.Seq
126+ seenChanged = true
127+ }
128+ dirty = dirty || capture.Shows(scene, c.ID)
129+ }
130+ b.Reset()
131+ ret.Return(b)
132+ return dirty, seenChanged
133+}
134+
135+// ShowsSnapshot builds the Shows of successive flips. Its owner goroutine is
136+// the only user. A flip's Shows map is an immutable snapshot: wayland may
137+// still read the one of an earlier flip on another goroutine, so the scratch
138+// map is never sent and a snapshot is replaced by a clone only when it
139+// changed. The zero value is ready; an unchanged result allocates nothing.
140+type ShowsSnapshot struct {
141+ scratch, snapshot map[ports.WindowID]uint64
142+}
143+
144+// FromSeen returns the windows of seen that scene showed to their client
145+// (Shown), at their seen Seq. The result is shared with earlier flips of the
146+// same content: never modify it.
147+func (s *ShowsSnapshot) FromSeen(scene ports.Scene, seen map[ports.WindowID]uint64, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
148+ s.begin()
113149 for id, seq := range seen {
114150 if Shown(scene, id, kept) {
115- dst[id] = seq
151+ s.scratch[id] = seq
116152 }
117153 }
154+ return s.settle()
155+}
156+
157+// FromContents returns the windows of contents that scene showed to their
158+// client (Shown), at their content Seq. The result is shared with earlier
159+// flips of the same content: never modify it.
160+func (s *ShowsSnapshot) FromContents(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) map[ports.WindowID]uint64 {
161+ s.begin()
162+ for id, c := range contents {
163+ if Shown(scene, id, kept) {
164+ s.scratch[id] = c.Seq
165+ }
166+ }
167+ return s.settle()
168+}
169+
170+func (s *ShowsSnapshot) begin() {
171+ if s.scratch == nil {
172+ s.scratch = make(map[ports.WindowID]uint64)
173+ }
174+ clear(s.scratch)
175+}
176+
177+func (s *ShowsSnapshot) settle() map[ports.WindowID]uint64 {
178+ if s.snapshot == nil || !maps.Equal(s.snapshot, s.scratch) {
179+ s.snapshot = maps.Clone(s.scratch)
180+ }
181+ return s.snapshot
118182 }
M internal/adapters/outputkit/surfaces/surfaces_test.go +64 -5
@@ -4,6 +4,7 @@ import (
44 "os"
55 "testing"
66
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
78 "github.com/bnema/neferwl/internal/ports"
89 )
910
@@ -125,14 +126,72 @@ func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
125126
126127 // Both backends report a window shown only when the scene draws it and its
127128 // content is not kept.
128-func TestFillShown(t *testing.T) {
129+func TestShowsSnapshotSkipsKept(t *testing.T) {
129130 kept := func(id ports.WindowID) bool { return id == 2 }
130- dst := map[ports.WindowID]uint64{9: 9}
131- FillShown(dst, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, shows(1, 2), kept)
132- if len(dst) != 1 || dst[1] != 3 {
133- t.Fatalf("shown %v", dst)
131+ var snap ShowsSnapshot
132+ got := snap.FromSeen(shows(1, 2), map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, kept)
133+ if len(got) != 1 || got[1] != 3 {
134+ t.Fatalf("shown from seen %v", got)
135+ }
136+ var fromContents ShowsSnapshot
137+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Seq: 3}, 2: {ID: 2, Seq: 4}, 3: {ID: 3, Seq: 5}}
138+ got = fromContents.FromContents(shows(1, 2), contents, kept)
139+ if len(got) != 1 || got[1] != 3 {
140+ t.Fatalf("shown from contents %v", got)
134141 }
135142 if !Shown(shows(2), 2, nil) {
136143 t.Fatal("nil kept counts nothing as kept")
137144 }
138145 }
146+
147+// An unchanged snapshot is reused without allocating; a changed one never
148+// mutates a map already handed out.
149+func TestShowsSnapshotImmutable(t *testing.T) {
150+ var snap ShowsSnapshot
151+ scene := shows(1)
152+ seen := map[ports.WindowID]uint64{1: 1, 2: 2}
153+ first := snap.FromSeen(scene, seen, nil)
154+ if allocs := testing.AllocsPerRun(100, func() { snap.FromSeen(scene, seen, nil) }); allocs != 0 {
155+ t.Errorf("unchanged shows: %.1f allocs, want 0", allocs)
156+ }
157+ seen[1] = 3
158+ second := snap.FromSeen(scene, seen, nil)
159+ if first[1] != 1 || second[1] != 3 {
160+ t.Fatalf("snapshot mutated: old %v new %v", first, second)
161+ }
162+}
163+
164+// Apply takes the batch into the table, advances seen, reports what needs a
165+// frame or a report, then resets the batch before giving it back.
166+func TestApply(t *testing.T) {
167+ tb := New()
168+ seen := map[ports.WindowID]uint64{1: 5}
169+ b := &ports.ContentBatch{Items: []ports.SurfaceContent{
170+ {ID: 1, Seq: 4, SHM: &ports.SHMBuffer{Pool: 1}}, // older than seen
171+ {ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}},
172+ }}
173+ ret := portsmocks.NewMockContentReturn(t)
174+ ret.EXPECT().Return(b).Run(func(got *ports.ContentBatch) {
175+ if len(got.Items) != 0 {
176+ t.Errorf("batch returned before its reset: %d items", len(got.Items))
177+ }
178+ }).Once()
179+ dirty, changed := tb.Apply(b, shows(2), seen, ret)
180+ if !dirty || !changed {
181+ t.Fatalf("dirty %v changed %v, want both", dirty, changed)
182+ }
183+ if seen[1] != 5 || seen[2] != 1 {
184+ t.Fatalf("seen %v", seen)
185+ }
186+ if len(tb.Map()) != 2 {
187+ t.Fatalf("table %v", tb.Map())
188+ }
189+
190+ // A window no scene draws and no newer Seq: nothing to do.
191+ b = &ports.ContentBatch{Items: []ports.SurfaceContent{{ID: 1, Seq: 5, SHM: &ports.SHMBuffer{Pool: 1}}}}
192+ ret.EXPECT().Return(b).Once()
193+ dirty, changed = tb.Apply(b, shows(2), seen, ret)
194+ if dirty || changed {
195+ t.Fatalf("dirty %v changed %v, want neither", dirty, changed)
196+ }
197+}
M internal/adapters/vulkan/compose.go +4 -3
@@ -413,12 +413,13 @@ func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]ui
413413 clear[i] = math.Float32bits(float32(srgbToLinear(float64(bg[i]) / 255)))
414414 }
415415 }
416- attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear}
416+ attachment, info := &r.call.attach, &r.call.render
417+ *attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear}
417418 if keep {
418419 attachment.LoadOp = vk.AttachmentLoadOpLoad
419420 }
420- info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
421- d.CmdBeginRendering(cmd, &info)
421+ *info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: attachment}
422+ d.CmdBeginRendering(cmd, info)
422423 pipeline := c.pipeline
423424 if r.hdrNits > 0 {
424425 pipeline = c.hdrPipeline
M internal/adapters/vulkan/damage.go +2 -0
@@ -265,6 +265,8 @@ var (
265265 // separator present in only one scene adds its own. A new Scene field
266266 // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a
267267 // new SceneWindow field is checked by TestSceneWindowFieldCount.
268+// Scene.Animating is not compared: it schedules the next frame, it draws
269+// nothing.
268270 func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) {
269271 if old.Security != cur.Security || old.Output != cur.Output ||
270272 old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
M internal/adapters/vulkan/damage_delta_test.go +3 -1
@@ -17,7 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- const fields = 21
20+ // 22 with Animating, which sceneDelta intentionally ignores: it only
21+ // schedules the next frame, it draws nothing.
22+ const fields = 22
2123 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2224 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2325 }
M internal/adapters/vulkan/frame.go +47 -17
@@ -32,6 +32,27 @@ type frameSlot struct {
3232 busy bool // submitted, fence not yet seen signalled
3333 }
3434
35+// callScratch holds the structs Render and its record helpers pass to Vulkan
36+// by pointer. The purego call makes a pointer to a local escape, so a local
37+// would be a heap allocation per frame; these live in the Renderer instead
38+// (owned by one goroutine, and Vulkan reads each only during its call). Every
39+// field is set in full before use; two uses that overlap in time never share a
40+// field.
41+type callScratch struct {
42+ cmd vk.CommandBuffer
43+ begin vk.CommandBufferBeginInfo
44+ submit vk.SubmitInfo
45+ target vk.ImageMemoryBarrier // the render target's, kept to the frame's end
46+ client vk.ImageMemoryBarrier // a client image's (ownership)
47+ attach vk.RenderingAttachmentInfo
48+ render vk.RenderingInfo
49+ getFD vk.SemaphoreGetFdInfoKHR
50+ fd int32
51+ hdrIn vk.ImageMemoryBarrier // recordHDR
52+ hdrOut vk.ImageMemoryBarrier
53+ hdrNits float32
54+}
55+
3556 // deferredFree runs once frame completed.
3657 type deferredFree struct {
3758 frame uint64
@@ -186,7 +207,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
186207 dmg.finish()
187208 r.dropPools()
188209 partial := !dmg.all()
210+ redrawn := sw * sh
189211 if partial {
212+ redrawn = dmg.area.Dx() * dmg.area.Dy()
190213 // The pass keeps the image: repaint the background under the
191214 // region, then everything clipped to it.
192215 // Keep the background first without allocating a second draw slice.
@@ -195,23 +218,27 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
195218 ds[0] = r.fillDraw(dmg.area, parseColor(s.Background))
196219 ds = dmg.clip(ds)
197220 r.scratchDraws = ds
198- r.redrawn += dmg.area.Dx() * dmg.area.Dy()
199- } else {
200- r.redrawn += sw * sh
221+ }
222+ r.redrawn += redrawn
223+ if r.traceDamage {
224+ r.log.Debug().Int("redrawn", redrawn).Int("draws", len(ds)).Bool("partial", partial).Msg("frame damage")
201225 }
202226 r.orient(ds, s.Transform)
203227 d := r.dd
228+ sc := &r.call
204229 cmd := slot.cmd
230+ sc.cmd = cmd
205231 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil {
206232 return nil, err
207233 }
208- begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
209- if err := checked("vkBeginCommandBuffer", d.BeginCommandBuffer(cmd, &begin)); err != nil {
234+ sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit}
235+ if err := checked("vkBeginCommandBuffer", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil {
210236 return nil, err
211237 }
212238 // A full redraw clears the target: its old contents never matter
213239 // (UNDEFINED). A partial one keeps them.
214- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: tg.image, SubresourceRange: colorRange}
240+ b := &sc.target
241+ *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: tg.image, SubresourceRange: colorRange}
215242 if partial {
216243 b.OldLayout = tg.layout
217244 b.DstAccessMask |= vk.AccessColorAttachmentReadBit
@@ -220,7 +247,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
220247 // Acquire from the display (foreign queue family).
221248 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family
222249 }
223- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
250+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b)
224251 // Client buffers come from the foreign queue family (their driver)
225252 // and go back to it after the frame, so the next frame acquires them.
226253 dmas := r.frameDMAs[:0]
@@ -252,10 +279,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
252279 // Release to the display: KMS scans it out after the fence.
253280 b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0
254281 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
255- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
282+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b)
256283 } else {
257284 b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit
258- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
285+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b)
259286 }
260287 }
261288 if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil {
@@ -279,7 +306,8 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
279306 sem := sem
280307 r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
281308 }
282- submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}
309+ sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
310+ submit := &sc.submit
283311 if cap(r.frameStages) < len(waits) {
284312 r.frameStages = make([]vk.PipelineStageFlags, len(waits))
285313 }
@@ -293,7 +321,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
293321 if r.syncFD {
294322 submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &slot.done
295323 }
296- if err := checked("vkQueueSubmit", d.QueueSubmit(r.queue, 1, &submit, slot.fence)); err != nil {
324+ if err := checked("vkQueueSubmit", d.QueueSubmit(r.queue, 1, submit, slot.fence)); err != nil {
297325 return nil, err
298326 }
299327 runtime.KeepAlive(stages)
@@ -318,9 +346,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
318346 if !r.syncFD {
319347 return nil, r.waitFrame(frame)
320348 }
321- var fd int32
322- get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: slot.done, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
323- if d.GetSemaphoreFdKHR(r.device, &get, &fd) != vk.Success {
349+ sc.fd = 0
350+ sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: slot.done, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit}
351+ if d.GetSemaphoreFdKHR(r.device, &sc.getFD, &sc.fd) != vk.Success {
324352 // The frame is drawn: finish it on the CPU. The semaphore stays
325353 // signalled: replace it, so the slot's next submit signals a
326354 // fresh one.
@@ -331,6 +359,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
331359 slot.done = 0
332360 return nil, r.newDone(slot)
333361 }
362+ fd := sc.fd
334363 if fd < 0 {
335364 // Already signalled: the driver may return -1.
336365 return nil, r.waitFrame(frame)
@@ -346,7 +375,8 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
346375 // family, GENERAL: their contents survive) and the fragment shader.
347376 func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) {
348377 for _, im := range dmas {
349- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange}
378+ b := &r.call.client
379+ *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange}
350380 if im.yuv {
351381 b.SubresourceRange.AspectMask = aspectPlane0 | aspectPlane1
352382 }
@@ -356,12 +386,12 @@ func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire boo
356386 b.DstAccessMask = vk.AccessShaderReadBit
357387 // The frame waits on the client's fence at the fragment
358388 // stage: the transfer must come after that wait.
359- r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &b)
389+ r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, b)
360390 } else {
361391 b.OldLayout, b.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutGeneral
362392 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
363393 b.SrcAccessMask = vk.AccessShaderReadBit
364- r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
394+ r.dd.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b)
365395 }
366396 }
367397 }
M internal/adapters/vulkan/hdr.go +13 -11
@@ -172,26 +172,28 @@ func (r *Renderer) destroyHDR() {
172172 // recordHDR converts the fully composed linear HDR image in the same command buffer.
173173 func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
174174 d, h := r.dd, &r.hdr
175- in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
176- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
177- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
178- d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
179- attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
180- info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
181- d.CmdBeginRendering(cmd, &info)
175+ in, b := &r.call.hdrIn, &r.call.hdrOut
176+ attachment, info := &r.call.attach, &r.call.render
177+ *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
178+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in)
179+ *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
180+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b)
181+ *attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
182+ *info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: attachment}
183+ d.CmdBeginRendering(cmd, info)
182184 d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline)
183185 d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil)
184- nits := float32(r.hdrNits)
185- d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits))
186+ r.call.hdrNits = float32(r.hdrNits)
187+ d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&r.call.hdrNits))
186188 d.CmdDraw(cmd, 3, 1, 0, 0)
187189 d.CmdEndRendering(cmd)
188190 b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0
189191 b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral
190192 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
191- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
193+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, b)
192194 in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
193195 in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
194- d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
196+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in)
195197 }
196198
197199 // dropHDROwn frees the HDR composition image with the capture pass that
M internal/adapters/vulkan/renderer.go +8 -0
@@ -41,6 +41,8 @@ type Renderer struct {
4141 modPlanes map[uint64]uint32
4242 // log reports the chosen target modifier; the zero value discards.
4343 log zerowrap.Logger
44+ // traceDamage logs every frame's redrawn area (--debug=render-damage).
45+ traceDamage bool
4446 // hdrReadback makes exported HDR targets transfer sources, for GPU tests
4547 // and headless --screenshot-raw (SetHDRReadback); never DRM.
4648 hdrReadback bool
@@ -108,6 +110,8 @@ type Renderer struct {
108110 frameAcquires map[*imported]*os.File
109111 frameWaits []vk.Semaphore
110112 frameStages []vk.PipelineStageFlags
113+ // call holds the structs a per-frame Vulkan call is handed by pointer.
114+ call callScratch
111115 }
112116
113117 // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable.
@@ -359,6 +363,10 @@ func (r *Renderer) TakeRedrawn() int {
359363 // events. Not on the port; the zero logger discards.
360364 func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l }
361365
366+// SetTraceDamage logs the redrawn pixels, draw count and partial flag of
367+// every frame at debug level (--debug=render-damage). Off by default.
368+func (r *Renderer) SetTraceDamage(on bool) { r.traceDamage = on }
369+
362370 // QueuePriority is the global priority the queue got: realtime, high or
363371 // default.
364372 func (r *Renderer) QueuePriority() string { return r.queuePriority }
M internal/adapters/vulkan/scene_perf_test.go +53 -2
@@ -3,6 +3,7 @@ package vulkan
33 import (
44 "image"
55 "image/color"
6+ "slices"
67 "testing"
78
89 "github.com/bnema/neferwl/internal/ports"
@@ -40,8 +41,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
4041 }
4142 }
4243 frame()
43- if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
44- t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
44+ if allocs := testing.AllocsPerRun(20, frame); allocs > 3 {
45+ t.Errorf("small steady-state Render: %.1f allocs/frame, want <=3 (syncFD path: %v)", allocs, r.syncFD)
4546 } else {
4647 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
4748 }
@@ -136,6 +137,56 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
136137 }
137138 }
138139
140+// A steady frame that draws a client dmabuf also records the ownership
141+// barriers (acquire and release) of its image, which allocate nothing. The
142+// frame still allocates more than the SHM scene: importing the client's
143+// read fence and its retire closure (about 4 per frame). Skipped without a
144+// GPU or /dev/udmabuf.
145+func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
146+ r, err := New(80, 32)
147+ if err != nil {
148+ t.Skipf("Vulkan unavailable: %v", err)
149+ }
150+ defer r.Close()
151+ linear := ports.DMABufFormat{Format: fourcc('A', 'R', '2', '4'), Modifier: 0}
152+ if !slices.Contains(r.DMABuf().Formats, linear) {
153+ t.Skipf("linear ARGB8888 not importable")
154+ }
155+ f := udmabuf(t, 64, 16, func(int, int) [4]byte { return [4]byte{0, 0, 255, 255} })
156+ buf := &ports.DMABuf{ID: 7, Width: 64, Height: 16, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
157+ scene := ports.Scene{Seq: 1, Background: "#000000", Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 64, H: 16}}}}
158+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Surface: 1, Seq: 1, Version: 1, Width: 64, Height: 16, DMABuf: buf}}
159+ frame := func() {
160+ if err := render(r, scene, contents); err != nil {
161+ t.Fatal(err)
162+ }
163+ }
164+ frame()
165+ if len(r.imports) != 1 {
166+ t.Fatalf("imports %d: the frame does not take the dmabuf path", len(r.imports))
167+ }
168+ // Each new scene Seq draws the dmabuf again, with its barriers.
169+ var moving [4]ports.Scene
170+ for i := range moving {
171+ moving[i] = scene
172+ moving[i].Seq = uint64(10 + i)
173+ moving[i].Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4 + i, Y: 4, W: 64, H: 16}}}
174+ }
175+ n := 0
176+ moved := func() {
177+ n++
178+ if err := render(r, moving[n%len(moving)], contents); err != nil {
179+ t.Fatal(err)
180+ }
181+ }
182+ moved()
183+ allocs := testing.AllocsPerRun(20, moved)
184+ t.Logf("dmabuf Render: %.1f allocs/frame", allocs)
185+ if allocs > 7 {
186+ t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD)
187+ }
188+}
189+
139190 // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged
140191 // subsurface tree. Each child has its own real wl_shm pool.
141192 func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
M internal/adapters/wayland/alloc_hotpath_test.go +11 -37
@@ -1,7 +1,6 @@
11 package wayland
22
33 import (
4- "context"
54 "testing"
65 "time"
76
@@ -24,7 +23,12 @@ func TestReleaseHeldAllocations(t *testing.T) {
2423 }
2524
2625 func TestCommitDamageAllocations(t *testing.T) {
27- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
26+ if raceEnabled {
27+ // AllocsPerRun counts every goroutine, and the race runtime
28+ // allocates on its own now and then. perf-check runs it without.
29+ t.Skip("the race detector allocates in the background")
30+ }
31+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
2832 surf := &surface{server: srv}
2933 commit := func() {
3034 surf.Damage(nil, 0, 0, 4, 4)
@@ -55,7 +59,7 @@ func TestCommitDamageAllocations(t *testing.T) {
5559 func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
5660 dev := newMocksyncobjDevice(t)
5761 dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil)
58- srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
62+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}}
5963 sw, err := newSyncWaiter(func() {})
6064 if err != nil {
6165 t.Fatal(err)
@@ -88,18 +92,16 @@ func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
8892
8993 func TestEmitContentAllocations(t *testing.T) {
9094 s := &Server{
91- channels: Channels{Contents: make(chan ports.SurfaceContent)},
92- contents: map[ports.WindowID]ports.SurfaceContent{},
93- contentSeq: map[ports.WindowID]uint64{},
94- damage: map[ports.WindowID][]ports.SeqDamage{},
95- contentReady: make(chan struct{}, 1),
95+ contentSeq: map[ports.WindowID]uint64{},
96+ damage: map[ports.WindowID][]ports.SeqDamage{},
97+ latest: map[ports.WindowID]ports.SurfaceContent{},
9698 }
9799 c := ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}
98100 var held []ports.SurfaceContent // readers retaining published histories
99101 var snapshots [][]ports.SeqDamage
100102 for range 3 * damageHistoryCap {
101103 s.emitContent(c, damage{full: true})
102- got := s.contents[1]
104+ got := s.latest[1]
103105 held = append(held, got)
104106 snapshots = append(snapshots, append([]ports.SeqDamage(nil), got.DamageHistory...))
105107 }
@@ -123,31 +125,3 @@ func TestEmitContentAllocations(t *testing.T) {
123125 }
124126 t.Logf("emitContent: %.2f allocs", allocs)
125127 }
126-
127-// A forwarder blocked on a stale frame is woken by the next publication
128-// and sends the newer frame instead.
129-func TestForwardContentsSendsNewerFrame(t *testing.T) {
130- contents := make(chan ports.SurfaceContent)
131- s := &Server{
132- channels: Channels{Contents: contents},
133- contents: map[ports.WindowID]ports.SurfaceContent{},
134- contentSeq: map[ports.WindowID]uint64{},
135- damage: map[ports.WindowID][]ports.SeqDamage{},
136- contentReady: make(chan struct{}, 1),
137- }
138- ctx, cancel := context.WithCancel(context.Background())
139- done := make(chan struct{})
140- go func() { defer close(done); s.forwardContents(ctx) }()
141- defer func() { cancel(); <-done }()
142- s.emitContent(ports.SurfaceContent{ID: 1, Seq: 1, Solid: &ports.SolidColor{}}, damage{full: true})
143- time.Sleep(10 * time.Millisecond) // let the forwarder block on seq 1
144- s.emitContent(ports.SurfaceContent{ID: 1, Seq: 2, Solid: &ports.SolidColor{}}, damage{full: true})
145- select {
146- case c := <-contents:
147- if c.Seq != 2 {
148- t.Fatalf("stale frame %d forwarded, want 2", c.Seq)
149- }
150- case <-time.After(2 * time.Second):
151- t.Fatal("no frame forwarded")
152- }
153-}
M internal/adapters/wayland/commit_lifecycle_test.go +4 -4
@@ -18,7 +18,7 @@ import (
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()
A internal/adapters/wayland/contentlink.go +177 -0
@@ -0,0 +1,177 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "sync/atomic"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// batchesPerLink is how many batches circulate between wayland and one
11+// output: one wayland fills, one waits in the channel, one the output reads.
12+const batchesPerLink = 3
13+
14+// batchItems is the initial capacity of a batch: windows beyond it grow the
15+// batch once, and it keeps the capacity.
16+const batchItems = 8
17+
18+// contentLink is the content path to one running output. The display
19+// goroutine owns cur and the sending side of in; the output owns the batch it
20+// received until it returns it. free holds the batches nobody uses: it has
21+// room for all of them, so Return never blocks.
22+//
23+// The link is also the ports.ContentReturn handed to the output, and the
24+// handle DetachOutput takes: a detach removes this link and no other, even
25+// when a later attach of the same name replaced it.
26+type contentLink struct {
27+ s *Server
28+ name string
29+ in chan *ports.ContentBatch
30+ free chan *ports.ContentBatch
31+ cur *ports.ContentBatch
32+ // pending: cur holds contents that could not be sent yet (the channel is
33+ // full, or every batch is out). Return only wakes the display when it is
34+ // set, so a steady stream of frames costs the display no extra wake.
35+ pending atomic.Bool
36+}
37+
38+// Return gives a consumed (reset) batch back and, when contents wait for one,
39+// has the display goroutine send them. Safe from any goroutine, also after
40+// the link was detached: the batch only goes back to its own link.
41+func (l *contentLink) Return(b *ports.ContentBatch) {
42+ l.free <- b // never full: the link owns batchesPerLink batches
43+ // The display stores pending before it looks at free once more, so
44+ // either it sees this batch or this load sees pending.
45+ if l.pending.Load() && l.s.flushPosted.CompareAndSwap(false, true) {
46+ l.s.display.Post(l.s.flushLinksFn)
47+ }
48+}
49+
50+// add puts c in the batch being filled, replacing the window's earlier
51+// content: the output only needs the newest one (see ports.ContentBatch).
52+func (l *contentLink) add(c ports.SurfaceContent) {
53+ items := l.cur.Items
54+ for i := range items {
55+ if items[i].ID == c.ID {
56+ items[i] = c
57+ return
58+ }
59+ }
60+ l.cur.Items = append(items, c)
61+}
62+
63+// try sends the batch being filled when the channel is empty and a batch is
64+// free to fill next. Display goroutine only.
65+func (l *contentLink) try() bool {
66+ if len(l.in) != 0 {
67+ return false
68+ }
69+ select {
70+ case next := <-l.free:
71+ l.in <- l.cur // empty, and only this goroutine sends
72+ l.cur = next
73+ return true
74+ default:
75+ return false
76+ }
77+}
78+
79+// flushLink sends l's batch when the output can take it; otherwise l keeps
80+// filling it and asks Return for a wake.
81+func (s *Server) flushLink(l *contentLink) {
82+ if len(l.cur.Items) == 0 {
83+ l.pending.Store(false)
84+ return
85+ }
86+ if l.try() {
87+ l.pending.Store(false)
88+ return
89+ }
90+ l.pending.Store(true)
91+ if l.try() { // a Return may have slipped in before pending was set
92+ l.pending.Store(false)
93+ }
94+}
95+
96+// flushLinks retries every link that waits for a free batch. Display
97+// goroutine; posted by Return.
98+func (s *Server) flushLinks() {
99+ s.flushPosted.Store(false)
100+ for _, l := range s.links {
101+ if l.pending.Load() {
102+ s.flushLink(l)
103+ }
104+ }
105+}
106+
107+// attachOutput starts feeding the output called name; a link of the same name
108+// is replaced (dropped from the feed: its handle then detaches nothing). The
109+// returned ContentReturn is the link, and the handle for detachOutput. The
110+// first batch holds the latest content of every live window,
111+// so the output starts with all of them drawn. Display goroutine, or before
112+// Run.
113+func (s *Server) attachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
114+ for i, old := range s.links {
115+ if old.name == name {
116+ s.links = slices.Delete(s.links, i, i+1)
117+ break
118+ }
119+ }
120+ l := &contentLink{
121+ s: s,
122+ name: name,
123+ in: make(chan *ports.ContentBatch, 1),
124+ free: make(chan *ports.ContentBatch, batchesPerLink),
125+ }
126+ for range batchesPerLink - 1 {
127+ l.free <- &ports.ContentBatch{Items: make([]ports.SurfaceContent, 0, batchItems)}
128+ }
129+ l.cur = &ports.ContentBatch{Items: make([]ports.SurfaceContent, 0, max(batchItems, len(s.latest)))}
130+ for _, c := range s.latest {
131+ l.add(c)
132+ }
133+ s.links = append(s.links, l)
134+ s.flushLink(l)
135+ return l.in, l
136+}
137+
138+// detachOutput stops feeding the link ret, the handle attachOutput returned.
139+// A handle whose link was replaced by a later attach of its name, or that is
140+// not a link, detaches nothing: a newer link is never removed. Display
141+// goroutine, or before Run. The output may still Return its last batch.
142+func (s *Server) detachOutput(ret ports.ContentReturn) {
143+ l, ok := ret.(*contentLink)
144+ if !ok {
145+ return
146+ }
147+ if i := slices.Index(s.links, l); i >= 0 {
148+ s.links = slices.Delete(s.links, i, i+1)
149+ }
150+}
151+
152+// AttachOutput starts feeding the output called name with the contents of
153+// the windows, as batches on the returned channel, starting with the current
154+// content of every window. The output hands each batch back, reset, to the
155+// returned ContentReturn, which is also the handle for DetachOutput.
156+// Attaching a name that already has a feed replaces that feed. Call it from
157+// outside the display goroutine. It returns a nil channel and handle when
158+// the server is stopped.
159+func (s *Server) AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
160+ var in <-chan *ports.ContentBatch
161+ var ret ports.ContentReturn
162+ if !s.display.Do(func() { in, ret = s.attachOutput(name) }) {
163+ return nil, nil
164+ }
165+ return in, ret
166+}
167+
168+// DetachOutput stops the feed ret, returned by AttachOutput. Only that feed
169+// stops: if a later AttachOutput of the same name replaced it, the newer
170+// feed keeps running. Call it from outside the display goroutine, once the
171+// output no longer reads its channel. A nil ret does nothing.
172+func (s *Server) DetachOutput(ret ports.ContentReturn) {
173+ if ret == nil {
174+ return
175+ }
176+ s.display.Do(func() { s.detachOutput(ret) })
177+}
A internal/adapters/wayland/contentlink_test.go +355 -0
@@ -0,0 +1,355 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// linkServer is a running server with no client, to publish contents on its
14+// display goroutine.
15+func linkServer(t *testing.T) *Server {
16+ t.Helper()
17+ s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ ctx, cancel := context.WithCancel(context.Background())
22+ done := make(chan error, 1)
23+ go func() { done <- s.Run(ctx) }()
24+ t.Cleanup(func() { cancel(); <-done })
25+ return s
26+}
27+
28+func solid(id ports.WindowID) ports.SurfaceContent {
29+ return ports.SurfaceContent{ID: id, Solid: &ports.SolidColor{}}
30+}
31+
32+func (s *Server) publish(t *testing.T, contents ...ports.SurfaceContent) {
33+ t.Helper()
34+ if !s.display.Do(func() {
35+ for _, c := range contents {
36+ s.emitContent(c, damage{full: true})
37+ }
38+ }) {
39+ t.Fatal("display stopped")
40+ }
41+}
42+
43+func nextBatch(t *testing.T, in <-chan *ports.ContentBatch) *ports.ContentBatch {
44+ t.Helper()
45+ select {
46+ case b := <-in:
47+ return b
48+ case <-time.After(2 * time.Second):
49+ t.Fatal("no batch")
50+ return nil
51+ }
52+}
53+
54+// noBatch asserts that no batch is on the channel once the display goroutine
55+// went through everything posted so far (a Return's flush included): sends
56+// happen only there, so there is nothing left to wait for.
57+func noBatch(t *testing.T, s *Server, in <-chan *ports.ContentBatch) {
58+ t.Helper()
59+ s.display.Do(func() {})
60+ select {
61+ case b := <-in:
62+ t.Fatalf("unexpected batch %+v", b.Items)
63+ default:
64+ }
65+}
66+
67+// Contents published while the output has not taken the previous batch
68+// replace each other per window: the output gets the first batch, then one
69+// with only the newest content, once it returned the first. (Ordering per
70+// window holds because a batch holds one entry per window and batches are
71+// FIFO; the release accounting needs only the newest Seq.)
72+func TestContentBatchKeepsLatestPerWindow(t *testing.T) {
73+ s := linkServer(t)
74+ in, ret := s.AttachOutput("A")
75+ s.publish(t, solid(1)) // sent at once
76+ s.publish(t, solid(1), solid(2), solid(1)) // wait for the output
77+ first := nextBatch(t, in)
78+ if len(first.Items) != 1 || first.Items[0].ID != 1 || first.Items[0].Seq != 1 {
79+ t.Fatalf("first batch %+v", first.Items)
80+ }
81+ noBatch(t, s, in) // nothing moves before the output returns a batch...
82+ first.Reset()
83+ ret.Return(first) // ...which sends the waiting contents
84+ second := nextBatch(t, in)
85+ if len(second.Items) != 2 {
86+ t.Fatalf("second batch %+v", second.Items)
87+ }
88+ for _, c := range second.Items {
89+ switch c.ID {
90+ case 1:
91+ if c.Seq != 3 {
92+ t.Errorf("window 1 seq %d, want the newest (3)", c.Seq)
93+ }
94+ case 2:
95+ if c.Seq != 1 {
96+ t.Errorf("window 2 seq %d, want 1", c.Seq)
97+ }
98+ default:
99+ t.Errorf("unexpected window %d", c.ID)
100+ }
101+ }
102+}
103+
104+// An output attached later starts with the latest content of every live
105+// window and nothing for windows that were emptied.
106+func TestContentBatchAttachSeedsLiveWindows(t *testing.T) {
107+ s := linkServer(t)
108+ s.publish(t, solid(1), solid(2), solid(3), ports.SurfaceContent{ID: 2}, solid(1))
109+ in, _ := s.AttachOutput("A")
110+ b := nextBatch(t, in)
111+ got := map[ports.WindowID]uint64{}
112+ for _, c := range b.Items {
113+ got[c.ID] = c.Seq
114+ }
115+ if len(got) != 2 || len(b.Items) != 2 || got[1] != 2 || got[3] != 1 {
116+ t.Fatalf("seed batch %+v", b.Items)
117+ }
118+ // An attach with nothing to show sends nothing.
119+ s.display.Do(func() { clear(s.latest) })
120+ in2, _ := s.AttachOutput("B")
121+ noBatch(t, s, in2)
122+}
123+
124+// An empty content reaches the attached outputs (it releases the window's
125+// buffers) and removes the window from what a later output starts with.
126+func TestContentBatchDeliversEmpty(t *testing.T) {
127+ s := linkServer(t)
128+ in, ret := s.AttachOutput("A")
129+ s.publish(t, solid(1))
130+ b := nextBatch(t, in)
131+ b.Reset()
132+ ret.Return(b)
133+ s.publish(t, ports.SurfaceContent{ID: 1})
134+ b = nextBatch(t, in)
135+ if len(b.Items) != 1 || b.Items[0].ID != 1 || !b.Items[0].Empty() || b.Items[0].Seq != 2 {
136+ t.Fatalf("empty not delivered: %+v", b.Items)
137+ }
138+ var left int
139+ s.display.Do(func() { left = len(s.latest) })
140+ if left != 0 {
141+ t.Fatalf("latest keeps %d emptied windows", left)
142+ }
143+ // Superseded by an empty before the output looked: the empty wins.
144+ b.Reset()
145+ ret.Return(b)
146+ s.publish(t, solid(1)) // sent
147+ s.publish(t, solid(1), ports.SurfaceContent{ID: 1}) // pending: last one is empty
148+ first := nextBatch(t, in)
149+ first.Reset()
150+ ret.Return(first)
151+ b = nextBatch(t, in)
152+ if len(b.Items) != 1 || !b.Items[0].Empty() || b.Items[0].Seq != 5 {
153+ t.Fatalf("emptied window: %+v", b.Items)
154+ }
155+}
156+
157+// Every attached output gets its own batches; detaching one stops its feed
158+// only, and its output may still Return what it holds.
159+func TestContentBatchDetach(t *testing.T) {
160+ s := linkServer(t)
161+ inA, retA := s.AttachOutput("A")
162+ inB, _ := s.AttachOutput("B")
163+ s.publish(t, solid(1))
164+ a, b := nextBatch(t, inA), nextBatch(t, inB)
165+ if len(a.Items) != 1 || len(b.Items) != 1 || a == b {
166+ t.Fatalf("batches %+v %+v", a.Items, b.Items)
167+ }
168+ s.DetachOutput(retA)
169+ // Waiting contents of a detached link are never sent; Return is safe.
170+ a.Reset()
171+ retA.Return(a)
172+ s.publish(t, solid(1))
173+ noBatch(t, s, inA)
174+ if got := nextBatch(t, inB); len(got.Items) != 1 {
175+ t.Fatalf("B batch %+v", got.Items)
176+ }
177+ // Attaching under a known name replaces the link.
178+ inA2, _ := s.AttachOutput("A")
179+ if got := nextBatch(t, inA2); len(got.Items) != 1 {
180+ t.Fatalf("re-attached batch %+v", got.Items)
181+ }
182+ var links int
183+ s.display.Do(func() { links = len(s.links) })
184+ if links != 2 {
185+ t.Fatalf("%d links, want 2", links)
186+ }
187+}
188+
189+// Detaching a feed by its handle never removes a newer feed of the same
190+// name: A attached again replaced the first link, and detaching the old
191+// handle leaves the new one fed.
192+func TestDetachOldHandleKeepsReplacedLink(t *testing.T) {
193+ s := linkServer(t)
194+ _, oldRet := s.AttachOutput("A")
195+ in, _ := s.AttachOutput("A")
196+ s.DetachOutput(oldRet)
197+ var links int
198+ s.display.Do(func() { links = len(s.links) })
199+ if links != 1 {
200+ t.Fatalf("%d links, want the new one only", links)
201+ }
202+ s.publish(t, solid(1))
203+ if b := nextBatch(t, in); len(b.Items) != 1 {
204+ t.Fatalf("new link batch %+v", b.Items)
205+ }
206+}
207+
208+// Emit, send, consume and return a batch without allocating once the
209+// batches have their capacity. The consumer runs inline, like an output that
210+// keeps up.
211+func TestContentBatchCycleAllocations(t *testing.T) {
212+ s := &Server{
213+ contentSeq: map[ports.WindowID]uint64{},
214+ damage: map[ports.WindowID][]ports.SeqDamage{},
215+ latest: map[ports.WindowID]ports.SurfaceContent{},
216+ }
217+ in, ret := s.attachOutput("A")
218+ c := solid(1)
219+ other := solid(2)
220+ cycle := func() {
221+ for _, c := range []ports.SurfaceContent{c, other} {
222+ s.emitContent(c, damage{full: true})
223+ select {
224+ case b := <-in:
225+ for i := range b.Items {
226+ _ = b.Items[i].Seq
227+ }
228+ b.Reset()
229+ ret.Return(b)
230+ default:
231+ panic("no batch sent")
232+ }
233+ }
234+ }
235+ for range 3 * damageHistoryCap {
236+ cycle()
237+ }
238+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
239+ t.Errorf("content batch cycle: %.2f allocs, want 0", allocs)
240+ }
241+}
242+
243+// A late-attached output gets all the live windows in one batch, however
244+// many there are, and AttachOutput returns without the output reading.
245+func TestContentBatchAttachReplaysLargeSet(t *testing.T) {
246+ s := linkServer(t)
247+ const windows = 100
248+ all := make([]ports.SurfaceContent, 0, windows)
249+ for id := 1; id <= windows; id++ {
250+ all = append(all, solid(ports.WindowID(id)))
251+ }
252+ s.publish(t, all...)
253+ attached := make(chan (<-chan *ports.ContentBatch), 1)
254+ go func() { in, _ := s.AttachOutput("A"); attached <- in }()
255+ var in <-chan *ports.ContentBatch
256+ select {
257+ case in = <-attached:
258+ case <-time.After(2 * time.Second):
259+ t.Fatal("AttachOutput waits for the output to read")
260+ }
261+ b := nextBatch(t, in)
262+ ids := map[ports.WindowID]struct{}{}
263+ for _, c := range b.Items {
264+ ids[c.ID] = struct{}{}
265+ }
266+ if len(b.Items) != windows || len(ids) != windows {
267+ t.Fatalf("replay batch holds %d items, %d distinct windows, want %d", len(b.Items), len(ids), windows)
268+ }
269+}
270+
271+// The output returns batches in bursts and holds some while content keeps
272+// coming, so the channel is full and no batch is free when emitContent runs;
273+// Returns race with it from another goroutine. Whatever the interleaving, the
274+// output ends up with the newest Seq of every window (the pending/flushPosted
275+// handshake never strands content). Run with -race -count=20.
276+func TestContentBatchHandshakeNeverStrandsContent(t *testing.T) {
277+ s := linkServer(t)
278+ in, ret := s.AttachOutput("A")
279+ const windows, rounds = 4, 3000
280+ var last [windows + 1]atomic.Uint64
281+ stop, ended := make(chan struct{}), make(chan struct{})
282+ t.Cleanup(func() { close(stop); <-ended })
283+ go func() {
284+ defer close(ended)
285+ var held []*ports.ContentBatch
286+ giveBack := func(n int) {
287+ for ; n > 0 && len(held) > 0; n-- {
288+ b := held[0]
289+ held = held[1:]
290+ b.Reset()
291+ ret.Return(b)
292+ }
293+ }
294+ for n := 0; ; n++ {
295+ var b *ports.ContentBatch
296+ select {
297+ case b = <-in:
298+ default:
299+ // Nothing waiting: hand everything back, as an idle
300+ // output does, then block.
301+ giveBack(len(held))
302+ select {
303+ case b = <-in:
304+ case <-stop:
305+ return
306+ }
307+ }
308+ for _, c := range b.Items {
309+ if c.Seq > last[c.ID].Load() {
310+ last[c.ID].Store(c.Seq)
311+ }
312+ }
313+ held = append(held, b)
314+ if len(held) >= 2 {
315+ giveBack(1 + n%2) // a burst of one or two
316+ }
317+ }
318+ }()
319+ for range rounds {
320+ s.publishRound(windows)
321+ }
322+ var want [windows + 1]uint64
323+ s.display.Do(func() {
324+ for id := 1; id <= windows; id++ {
325+ want[id] = s.contentSeq[ports.WindowID(id)]
326+ }
327+ })
328+ for deadline := time.Now().Add(5 * time.Second); ; {
329+ missing := 0
330+ for id := 1; id <= windows; id++ {
331+ if last[id].Load() != want[id] {
332+ missing++
333+ }
334+ }
335+ if missing == 0 {
336+ return
337+ }
338+ if time.Now().After(deadline) {
339+ for id := 1; id <= windows; id++ {
340+ t.Errorf("window %d: output saw seq %d, newest is %d", id, last[id].Load(), want[id])
341+ }
342+ t.Fatal("content stranded")
343+ }
344+ time.Sleep(time.Millisecond)
345+ }
346+}
347+
348+// publishRound publishes one content per window in one display turn.
349+func (s *Server) publishRound(windows int) {
350+ s.display.Do(func() {
351+ for id := 1; id <= windows; id++ {
352+ s.emitContent(solid(ports.WindowID(id)), damage{full: true})
353+ }
354+ })
355+}
A internal/adapters/wayland/contentrelay_test.go +37 -0
@@ -0,0 +1,37 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// relayContents attaches an output to s before Run and returns the contents it
10+// receives, one by one. It consumes batches like an output does: apply, then
11+// Return. The server must not be running yet.
12+func relayContents(t *testing.T, s *Server) <-chan ports.SurfaceContent {
13+ t.Helper()
14+ in, ret := s.attachOutput("TEST-1")
15+ out := make(chan ports.SurfaceContent, 64)
16+ stop := make(chan struct{})
17+ t.Cleanup(func() { close(stop) })
18+ go func() {
19+ for {
20+ select {
21+ case <-stop:
22+ return
23+ case b := <-in:
24+ for _, c := range b.Items {
25+ select {
26+ case out <- c:
27+ case <-stop:
28+ return
29+ }
30+ }
31+ b.Reset()
32+ ret.Return(b)
33+ }
34+ }
35+ }()
36+ return out
37+}
A internal/adapters/wayland/damage_log_test.go +77 -0
@@ -0,0 +1,77 @@
1+package wayland
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/zerowrap"
11+)
12+
13+// --debug=wayland-damage logs one "client damage" entry per fresh commit
14+// with the damage in buffer pixels; without it nothing is logged.
15+func TestClientDamageLog(t *testing.T) {
16+ for _, on := range []bool{true, false} {
17+ t.Run(fmt.Sprintf("LogDamage=%v", on), func(t *testing.T) {
18+ var out lockedBuffer
19+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland")
20+ s, events, _, contents, dir := contentServerWith(t, Options{LogDamage: on}, log)
21+ c := protocolClient(t, s, dir)
22+ // A mapped window (its 1×1 first buffer is not under test), then
23+ // the 16×16 buffer the commits below attach.
24+ _, surf, _ := surfaceMapperAt(t, c, events, 4)()
25+ drainContents(contents)
26+ buf := shmBufferSized(t, c, 16, 16)
27+ commit := func(rects ...[4]int32) {
28+ t.Helper()
29+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
30+ for _, r := range rects {
31+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, r[0], r[1], r[2], r[3])
32+ }
33+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
34+ if err := c.Roundtrip(); err != nil {
35+ t.Fatal(err)
36+ }
37+ select {
38+ case <-contents:
39+ case <-time.After(2 * time.Second):
40+ t.Fatal("no content")
41+ }
42+ }
43+ // First buffer: a new size, full. Then a small rect, then one
44+ // covering the whole buffer.
45+ commit([4]int32{0, 0, 4, 4})
46+ commit([4]int32{2, 3, 4, 5})
47+ commit([4]int32{0, 0, 16, 16})
48+ var damage []string
49+ for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") {
50+ if strings.Contains(l, `"client damage"`) {
51+ damage = append(damage, l)
52+ }
53+ }
54+ if !on {
55+ if len(damage) != 0 {
56+ t.Fatalf("logged with LogDamage off: %q", damage)
57+ }
58+ return
59+ }
60+ // The mapping commit logged one entry of its own.
61+ if len(damage) != 4 {
62+ t.Fatalf("%d client damage entries, want 4 (mapping commit and 3 commits): %q", len(damage), damage)
63+ }
64+ damage = damage[1:]
65+ for i, want := range []string{`"full_buffer":true`, `"full_buffer":false`, `"full_buffer":true`} {
66+ if !strings.Contains(damage[i], want) {
67+ t.Errorf("entry %d lacks %s: %s", i, want, damage[i])
68+ }
69+ }
70+ for _, want := range []string{`"component":"wayland"`, `"bw":16`, `"bh":16`, `"rects":1`, `"window":`, `"first_rects":[{"x":2,"y":3,"w":4,"h":5}]`} {
71+ if !strings.Contains(damage[1], want) {
72+ t.Errorf("entry 1 lacks %s: %s", want, damage[1])
73+ }
74+ }
75+ })
76+ }
77+}
M internal/adapters/wayland/dmabuf_test.go +3 -3
@@ -15,15 +15,15 @@ import (
1515
1616 var linearARGB = ports.DMABufFormat{Format: 'A' | 'R'<<8 | '2'<<16 | '4'<<24}
1717
18-func dmabufServer(t *testing.T, sup ports.DMABufSupport) (*Server, chan ports.ClientEvent, chan ports.SurfaceContent, string) {
18+func dmabufServer(t *testing.T, sup ports.DMABufSupport) (*Server, chan ports.ClientEvent, <-chan ports.SurfaceContent, string) {
1919 t.Helper()
2020 dir := t.TempDir()
2121 events := make(chan ports.ClientEvent, 16)
22- contents := make(chan ports.SurfaceContent, 16)
23- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))
22+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup}, Channels{Events: events}, logging.For(context.Background(), "wayland"))
2423 if err != nil {
2524 t.Fatal(err)
2625 }
26+ contents := relayContents(t, s)
2727 ctx, cancel := context.WithCancel(context.Background())
2828 done := make(chan error, 1)
2929 go func() { done <- s.Run(ctx) }()
M internal/adapters/wayland/fifo.go +6 -6
@@ -757,8 +757,7 @@ func sameBuffer(a, b *wayland.Buffer) bool {
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,11 @@ 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
775774 }
775+ s.display.Post(s.paceSignalFn)
776776 }
M internal/adapters/wayland/fifo_test.go +11 -7
@@ -29,7 +29,10 @@ func nextContent(t *testing.T, contents <-chan ports.SurfaceContent, id ports.Wi
2929 }
3030
3131 // shmBuffer creates a 1x1 wl_shm buffer.
32-func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 {
32+func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 { return shmBufferSized(t, c, 1, 1) }
33+
34+// shmBufferSized is a w×h ARGB8888 wl_shm buffer.
35+func shmBufferSized(t *testing.T, c *wlturbo.Display, w, h int32) uint32 {
3336 t.Helper()
3437 shm := bindProtocol(t, c, "wl_shm")
3538 registerProtocol(t, c, shm)
@@ -38,16 +41,17 @@ func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 {
3841 t.Fatal(err)
3942 }
4043 defer unix.Close(fd)
41- if err := unix.Ftruncate(fd, 4); err != nil {
44+ size := w * h * 4
45+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
4246 t.Fatal(err)
4347 }
4448 pool, buffer := c.AllocateID(), c.AllocateID()
4549 registerProtocol(t, c, pool)
4650 registerProtocol(t, c, buffer)
47- if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
51+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
4852 t.Fatal(err)
4953 }
50- requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
54+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), w, h, w*4, uint32(0))
5155 return buffer
5256 }
5357
@@ -154,7 +158,7 @@ func TestMonotonicTimeClamps(t *testing.T) {
154158 // A barrier on an output that flips clears on its next flip.
155159 func TestTickFifoClearsBarrierOnFlip(t *testing.T) {
156160 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
157- s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
161+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
158162 surf := &surface{server: s, xdg: &xdgSurface{window: &window{last: ports.ConfigureWindow{Output: "DP-2"}}}}
159163 now := time.Unix(100, 0)
160164 s.lastFlip["DP-2"] = now.Add(-time.Millisecond)
@@ -217,7 +221,7 @@ func TestQueuedParentRetainsRetiredChild(t *testing.T) {
217221 name = "dropped"
218222 }
219223 t.Run(name, func(t *testing.T) {
220- srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
224+ srv := &Server{fifoSurfaces: make(map[*surface]struct{})}
221225 parent := &surface{server: srv}
222226 child := &surface{server: srv}
223227 child.sub.parent, child.sub.synced = parent, true
@@ -277,7 +281,7 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) {
277281
278282 func TestInvisibleFifoPeriod(t *testing.T) {
279283 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
280- s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
284+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}}
281285 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}
282286 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
283287 now := time.Unix(100, 0)
M internal/adapters/wayland/frames.go +79 -62
@@ -1,7 +1,7 @@
11 package wayland
22
33 import (
4- "context"
4+ "errors"
55 "slices"
66 "time"
77
@@ -16,8 +16,10 @@ import (
1616 // frame. They are paced per output: on its page flip when it flips, else
1717 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
1818 // second and a V-Sync game follows the screen. Cursors and unplaced
19-// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. The
20-// pacer sleeps while no callback waits.
19+// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. Pacing runs
20+// on the display loop: output reports arrive through the presentation sink
21+// (presented.go) and deadlines through a timerfd; the timer sleeps while no
22+// callback waits.
2123
2224 // defaultFramePeriod paces surfaces without an output (60 Hz).
2325 const defaultFramePeriod = time.Second / 60
@@ -25,69 +27,87 @@ const suspendedFramePeriod = time.Second
2527 const suspendedFrameQueue = "\x00suspended" // never a wl_output name
2628
2729 // maxPacePasses bounds the passes one tick repeats while a pass queues
28-// more work; the rest goes back to the pacer goroutine.
30+// more work; the rest is posted to the display loop.
2931 const maxPacePasses = 3
3032
31-// pace sleeps until a flip, a wake or the deadline armed by the last tick,
32-// then runs a tick on the display goroutine. The timer is only armed from
33-// ticks, which all run there, so a commit that ticks inline (paceNow) never
34-// races the pacer for it.
35-func (s *Server) pace(ctx context.Context) {
36- timer := s.paceTimer // created by Run before the pacer starts
37- defer timer.Stop()
38- presented := s.channels.Presented
39- if s.flipped == nil {
40- s.flipped = make(map[string]bool)
41- }
42- // One method value for the whole loop: no closure per wake.
43- tick := s.paceFromPacer
44- for {
45- clear(s.flipped)
46- flipped := s.flipped
47- s.frameReports = s.frameReports[:0]
48- reports := s.frameReports
49- select {
50- case <-ctx.Done():
51- return
52- case <-s.display.Stopped():
53- return
54- case p := <-presented:
55- flipped[p.Output] = flipped[p.Output] || paces(p)
56- reports = append(reports, p)
57- case <-s.frameReady:
58- case <-timer.C:
59- }
60- // Coalesce flips queued while the display was busy: one burst of
61- // callbacks per output, not one per stale flip.
62- drain:
63- for {
64- select {
65- case p := <-presented:
66- flipped[p.Output] = flipped[p.Output] || paces(p)
67- reports = append(reports, p)
68- default:
69- break drain
70- }
71- }
72- s.frameReports = reports
73- if !s.display.Do(tick) {
33+// paceNow runs a tick from code already on the display goroutine (a client
34+// commit).
35+func (s *Server) paceNow() { s.paceTick(nil, nil) }
36+
37+// paceTolerance is how much earlier than the armed deadline a new one may be
38+// and still reuse it: the ticks of one frame compute the same deadline from
39+// clock reads a few microseconds apart.
40+const paceTolerance = 50 * time.Microsecond
41+
42+// armPace schedules the next flip-less tick d from now on the pace timerfd
43+// (nanosecond precision, one-shot). A deadline at most paceTolerance before
44+// the armed one keeps the timer as it is: a frame's commit, flip and timer
45+// ticks mostly end on the same deadline. The timer never fires before the
46+// requested deadline, since the tick's checks are strict and an early tick
47+// would push due work back by their minimum re-arm. The tolerance is measured
48+// against the armed deadline, so it never accumulates. Display goroutine only.
49+func (s *Server) armPace(d time.Duration) { s.armPaceFrom(time.Now(), d) }
50+
51+// armPaceFrom is armPace with the clock read at now.
52+func (s *Server) armPaceFrom(now time.Time, d time.Duration) {
53+ deadline := now.Add(max(d, 0))
54+ if s.paceArmed {
55+ if diff := deadline.Sub(s.paceDeadline); diff >= -paceTolerance && diff <= 0 {
7456 return
7557 }
7658 }
59+ spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(max(d, time.Nanosecond)))}
60+ err := unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
61+ s.warnPace("arm", err)
62+ // A failed arm leaves the old state unknown: arm again next time.
63+ s.paceArmed, s.paceDeadline = err == nil, deadline
7764 }
7865
79-// paceFromPacer runs the pacer's collected flips (flipped, frameReports)
80-// on the display goroutine.
81-func (s *Server) paceFromPacer() { s.paceTick(s.flipped, s.frameReports) }
66+// stopPace disarms the pace timerfd, unless it is not armed (never armed, or
67+// its one-shot expiry was consumed by onPaceTimer). Display goroutine only.
68+func (s *Server) stopPace() {
69+ if !s.paceArmed {
70+ return
71+ }
72+ var spec unix.ItimerSpec
73+ err := unix.TimerfdSettime(s.paceFD, 0, &spec, nil)
74+ s.warnPace("stop", err)
75+ // A failed stop keeps the state: the next stop tries again.
76+ s.paceArmed = err != nil
77+}
8278
83-// paceNow runs a tick from code already on the display goroutine (a client
84-// commit): the pacer goroutine would only call Do to get back here. Without
85-// a running pacer (no timer) it falls back to waking it.
86-func (s *Server) paceNow() {
87- if s.paceTimer == nil {
88- s.signalPacer()
79+// warnPace logs the first failure of a pace timerfd operation (later ones
80+// would repeat at frame rate). A failed arm leaves frame callbacks waiting
81+// for the next commit or flip. Display goroutine only.
82+func (s *Server) warnPace(op string, err error) {
83+ if err == nil || s.paceWarned {
8984 return
9085 }
86+ s.paceWarned = true
87+ s.log.Warn().Err(err).Str("op", op).Msg("pace timer")
88+}
89+
90+// onPaceTimer is the display loop's callback for the pace timerfd: the
91+// deadline armed by the last tick passed.
92+func (s *Server) onPaceTimer() {
93+ var buf [8]byte
94+ // Level-triggered: consume the expiry. EAGAIN: the timer was disarmed
95+ // after the loop saw it readable.
96+ n, err := unix.Read(s.paceFD, buf[:])
97+ switch {
98+ case err == nil && n == len(buf):
99+ // The one-shot expired: the timer is disarmed until the next arm.
100+ s.paceArmed = false
101+ case err != nil && !errors.Is(err, unix.EAGAIN):
102+ s.warnPace("read", err)
103+ }
104+ s.paceTick(nil, nil)
105+}
106+
107+// paceSignalled is the posted tick: a wake (fence, overflow) made while the
108+// display goroutine was elsewhere.
109+func (s *Server) paceSignalled() {
110+ s.paceSignal.Store(false)
91111 s.paceTick(nil, nil)
92112 }
93113
@@ -109,7 +129,7 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
109129 }
110130 // The pass queued work behind its own checks (applied commits
111131 // queue callbacks, released buffers): look again, as a flip-less
112- // tick, instead of waking the pacer for it.
132+ // tick, instead of posting one.
113133 flipped, reports = nil, nil
114134 if pass+1 == maxPacePasses {
115135 s.paceDirty = false
@@ -117,16 +137,13 @@ func (s *Server) paceTick(flipped map[string]bool, reports []ports.OutputPresent
117137 break
118138 }
119139 }
120- if s.paceTimer == nil {
121- return
122- }
123140 if idle {
124141 // Nothing waits: sleep until a commit queues a callback
125142 // (wakePacer) or a flip.
126- s.paceTimer.Stop()
143+ s.stopPace()
127144 return
128145 }
129- s.paceTimer.Reset(wait)
146+ s.armPace(wait)
130147 }
131148
132149 // 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)
A internal/adapters/wayland/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package wayland
4+
5+const raceEnabled = false
M internal/adapters/wayland/pace_inline_test.go +108 -36
@@ -6,13 +6,24 @@ import (
66
77 "github.com/bnema/go-wayland-bindings/server/wayland"
88 "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
911 )
1012
1113 // paceFixture is a server whose surface waits on an output whose next
12-// frame is an hour away: a commit queues its callback and nothing fires.
13-func paceFixture(timer bool) (*Server, *surface) {
14+// frame is an hour away: a commit queues its callback and nothing fires. It
15+// has what New gives a server for pacing: a display (never run, so posted
16+// ticks stay queued) and the registered pace timer.
17+func paceFixture(t *testing.T) (*Server, *surface) {
18+ t.Helper()
19+ display, err := server.NewDisplay()
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ t.Cleanup(display.Close)
1424 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
1525 s := &Server{
26+ display: display,
1627 outputs: []*output{out},
1728 awaiting: map[string][]*wayland.Callback{},
1829 frameDue: map[string]time.Time{"DP-2": time.Now().Add(time.Hour)},
@@ -20,12 +31,12 @@ func paceFixture(timer bool) (*Server, *surface) {
2031 lastFlip: map[string]time.Time{},
2132 flips: map[string]ports.FlipInfo{},
2233 reports: map[string]ports.OutputPresented{},
23- frameReady: make(chan struct{}, 1),
2434 }
25- if timer {
26- s.paceTimer = time.NewTimer(time.Hour)
27- s.paceTimer.Stop()
35+ s.paceSignalFn = s.paceSignalled
36+ if err := s.openPaceTimer(); err != nil {
37+ t.Fatal(err)
2838 }
39+ t.Cleanup(func() { unix.Close(s.paceFD) })
2940 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
3041 surf := &surface{server: s, xdg: &xdgSurface{window: w}}
3142 surf.next.callbacks = []*wayland.Callback{new(wayland.Callback)}
@@ -33,16 +44,16 @@ func paceFixture(timer bool) (*Server, *surface) {
3344 }
3445
3546 // A commit on the display goroutine ticks the pacer inline: it arms the
36-// pacer's timer itself and does not wake the pacer goroutine, which would
37-// only call display.Do to get back to the display goroutine.
47+// pace timer itself and posts no tick, which would only run later on the
48+// display goroutine it is already on.
3849 func TestCommitTicksPacerInline(t *testing.T) {
39- s, surf := paceFixture(true)
50+ s, surf := paceFixture(t)
4051 surf.queueAndTick()
4152 if len(s.awaiting["DP-2"]) != 1 {
4253 t.Fatalf("callback not queued: %+v", s.awaiting)
4354 }
44- if len(s.frameReady) != 0 {
45- t.Fatal("commit woke the pacer goroutine")
55+ if s.paceSignal.Load() {
56+ t.Fatal("commit posted a tick")
4657 }
4758 waitArmed(t, s, "inline tick did not arm the pacer timer for the next deadline")
4859 if s.paceDepth != 0 || s.paceDirty {
@@ -50,54 +61,48 @@ func TestCommitTicksPacerInline(t *testing.T) {
5061 }
5162 }
5263
53-// Without a running pacer (no timer) the wake falls back to the channel.
54-func TestCommitWakesPacerWithoutTimer(t *testing.T) {
55- s, surf := paceFixture(false)
56- surf.queueAndTick()
57- if len(s.frameReady) != 1 {
58- t.Fatal("pacer not woken")
59- }
60-}
61-
62-// Wakes inside a tick coalesce into one more pass, not one channel wake each.
64+// Wakes inside a tick coalesce into one more pass, not one posted tick each.
6365 func TestWakePacerDefersInsideTick(t *testing.T) {
64- s, _ := paceFixture(true)
66+ s, _ := paceFixture(t)
6567 s.paceDepth = 1
6668 s.wakePacer()
6769 s.wakePacer()
68- if len(s.frameReady) != 0 || !s.paceDirty {
69- t.Fatalf("wake inside a tick: queued %d dirty %v", len(s.frameReady), s.paceDirty)
70+ if s.paceSignal.Load() || !s.paceDirty {
71+ t.Fatalf("wake inside a tick: signalled %v dirty %v", s.paceSignal.Load(), s.paceDirty)
7072 }
7173 s.paceDepth = 0
7274 s.wakePacer()
73- if len(s.frameReady) != 1 {
75+ if !s.paceSignal.Load() {
7476 t.Fatal("wake outside a tick lost")
7577 }
7678 }
7779
7880 // A commit that waits (a fifo barrier) is timed by the inline tick.
7981 func TestCommitWaitingOnBarrierArmsTimer(t *testing.T) {
80- s, surf := paceFixture(true)
82+ s, surf := paceFixture(t)
8183 surf.next.callbacks = nil
8284 surf.barrier, surf.barrierAt = true, time.Now()
8385 surf.next.wait = true
8486 surf.queueAndTick()
85- if len(surf.queue) != 1 || len(s.frameReady) != 0 {
86- t.Fatalf("queued %d, pacer wakes %d", len(surf.queue), len(s.frameReady))
87+ if len(surf.queue) != 1 || s.paceSignal.Load() {
88+ t.Fatalf("queued %d, tick signalled %v", len(surf.queue), s.paceSignal.Load())
8789 }
8890 waitArmed(t, s, "timer not armed for the barrier deadline")
8991 }
9092
91-// waitArmed asserts the timer was armed: it is pending, or it already
92-// fired and holds its value.
93+// waitArmed asserts the timerfd was armed: it is pending, or it already
94+// fired and holds its expiry.
9395 func waitArmed(t *testing.T, s *Server, msg string) {
9496 t.Helper()
95- if s.paceTimer.Stop() {
97+ var cur unix.ItimerSpec
98+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
99+ t.Fatal(err)
100+ }
101+ if cur.Value != (unix.Timespec{}) {
96102 return
97103 }
98- select {
99- case <-s.paceTimer.C:
100- default:
104+ var buf [8]byte
105+ if n, _ := unix.Read(s.paceFD, buf[:]); n != 8 {
101106 t.Fatal(msg)
102107 }
103108 }
@@ -105,14 +110,81 @@ func waitArmed(t *testing.T, s *Server, msg string) {
105110 // A synchronized subsurface commit waits for its parent's commit: it does
106111 // not tick the pacer, so a burst of child commits costs no ticks.
107112 func TestSyncedCommitDoesNotTick(t *testing.T) {
108- s, surf := paceFixture(true)
113+ s, surf := paceFixture(t)
109114 surf.sub.parent = &surface{server: s}
110115 surf.sub.synced = true
111116 surf.queueAndTick()
112117 if len(surf.queue) != 1 || !surf.queue[0].synced {
113118 t.Fatalf("synced commit not queued: %d", len(surf.queue))
114119 }
115- if len(s.frameReady) != 0 || s.paceTimer.Stop() {
120+ var cur unix.ItimerSpec
121+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
122+ t.Fatal(err)
123+ }
124+ if s.paceSignal.Load() || cur.Value != (unix.Timespec{}) {
116125 t.Fatal("synced commit ticked the pacer")
117126 }
118127 }
128+
129+// An arm for the deadline already armed (within paceTolerance) keeps the
130+// timer; another deadline re-arms it; an expiry consumed by onPaceTimer, or a
131+// stop, leaves it disarmed, and a stop of a disarmed timer does nothing.
132+func TestArmPaceSkipsUnchangedDeadline(t *testing.T) {
133+ s, _ := paceFixture(t)
134+ remaining := func() time.Duration {
135+ var cur unix.ItimerSpec
136+ if err := unix.TimerfdGettime(s.paceFD, &cur); err != nil {
137+ t.Fatal(err)
138+ }
139+ return time.Duration(cur.Value.Nano())
140+ }
141+ s.stopPace()
142+ if s.paceArmed {
143+ t.Fatal("stop of a never armed timer armed it")
144+ }
145+ now := time.Now()
146+ s.armPaceFrom(now, time.Hour)
147+ first := s.paceDeadline
148+ if !s.paceArmed || remaining() <= 59*time.Minute {
149+ t.Fatalf("not armed: %v", remaining())
150+ }
151+ // A deadline a little before the armed one keeps the timer.
152+ s.armPaceFrom(now, time.Hour-paceTolerance/2)
153+ if !s.paceDeadline.Equal(first) {
154+ t.Fatalf("an earlier deadline within tolerance re-armed the timer: %v -> %v", first, s.paceDeadline)
155+ }
156+ // A later deadline, however close, re-arms: the timer must never fire
157+ // before the requested deadline.
158+ s.armPaceFrom(now, time.Hour+time.Nanosecond)
159+ if !s.paceDeadline.After(first) {
160+ t.Fatalf("a later deadline kept the earlier timer: %v -> %v", first, s.paceDeadline)
161+ }
162+ first = s.paceDeadline
163+ s.armPace(time.Hour - time.Minute)
164+ if s.paceDeadline.Equal(first) || remaining() > 59*time.Minute {
165+ t.Fatalf("a new deadline kept the old timer: remaining %v", remaining())
166+ }
167+ s.stopPace()
168+ if s.paceArmed || remaining() != 0 {
169+ t.Fatalf("stop left the timer armed: %v", remaining())
170+ }
171+ // One-shot expiry: the fired timer is disarmed, so a stop is a no-op and
172+ // the next arm sets the timer again even for the same deadline.
173+ s.armPace(time.Nanosecond)
174+ fds := []unix.PollFd{{Fd: int32(s.paceFD), Events: unix.POLLIN}}
175+ if n, err := unix.Poll(fds, 5000); n != 1 {
176+ t.Fatalf("the timer did not fire: %d, %v", n, err)
177+ }
178+ s.onPaceTimer()
179+ if s.paceArmed {
180+ t.Fatal("expiry did not disarm")
181+ }
182+ s.stopPace() // no syscall to observe: it must not re-arm anything
183+ if remaining() != 0 {
184+ t.Fatalf("stop after the expiry: %v", remaining())
185+ }
186+ s.armPace(time.Hour)
187+ if !s.paceArmed || remaining() <= 59*time.Minute {
188+ t.Fatalf("not re-armed after the expiry: %v", remaining())
189+ }
190+}
M internal/adapters/wayland/presentation.go +0 -4
@@ -81,9 +81,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
8181 root := s.root()
8282 win := root.windowID()
8383 if win != 0 && fresh {
84- srv.contentMu.Lock()
8584 seq := srv.contentSeq[win]
86- srv.contentMu.Unlock()
8785 for i := range srv.feedbacks {
8886 if w := &srv.feedbacks[i]; w.surf == s && w.replaced == 0 && seq > w.seq {
8987 w.replaced = seq
@@ -102,9 +100,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
102100 }
103101 return
104102 }
105- srv.contentMu.Lock()
106103 seq := srv.contentSeq[win]
107- srv.contentMu.Unlock()
108104 for _, fb := range pending {
109105 srv.feedbacks = append(srv.feedbacks, feedbackWait{fb: fb, surf: s, win: win, seq: seq, at: time.Now()})
110106 }
M internal/adapters/wayland/presentation_test.go +11 -6
@@ -46,22 +46,27 @@ 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) {
50+ t.Helper()
51+ return presentServerOn(t, testOutputs)
52+}
53+
54+// presentServerOn is presentServer with its own output layout.
55+func presentServerOn(t *testing.T, layout ports.Layout) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, ports.PresentationSink, string) {
5056 t.Helper()
5157 dir := t.TempDir()
5258 events := make(chan ports.ClientEvent, 16)
5359 commands := make(chan ports.ClientCommand, 16)
54- contents := make(chan ports.SurfaceContent, 64)
55- presented := make(chan ports.OutputPresented, 16)
56- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents, Presented: presented}, logging.For(context.Background(), "wayland"))
60+ s, err := New(Options{RuntimeDir: dir, Outputs: layout}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
5761 if err != nil {
5862 t.Fatal(err)
5963 }
64+ contents := relayContents(t, s)
6065 ctx, cancel := context.WithCancel(context.Background())
6166 done := make(chan error, 1)
6267 go func() { done <- s.Run(ctx) }()
6368 t.Cleanup(func() { cancel(); <-done })
64- return s, events, commands, contents, presented, dir
69+ return s, events, commands, contents, s.Presentation(), dir
6570 }
6671
6772 func TestPresentationFeedback(t *testing.T) {
@@ -156,7 +161,7 @@ func TestPresentationFeedback(t *testing.T) {
156161 }
157162 }
158163 flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) {
159- presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}
164+ presented.Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}})
160165 }
161166 // With no flip, hiding must discard a waiting feedback immediately.
162167 hidden := feedback()
A internal/adapters/wayland/presented.go +155 -0
@@ -0,0 +1,155 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// maxPresentRing bounds the reports waiting for the display goroutine. Output
13+// queues are flushed every loop turn and hold no backlog, so the ring only
14+// fills when the display loop is stalled; this sink alone then merges flips.
15+const maxPresentRing = 64
16+
17+// mergeWarnEvery spaces the warnings about merged flips.
18+const mergeWarnEvery = 10 * time.Second
19+
20+// presentSink is the ports.PresentationSink wayland offers to the outputs:
21+// output owner goroutines Push reports, the display goroutine drains them and
22+// paces frames. The ring is a transport queue between those goroutines, not
23+// window state: the mutex guards only the slices, the pending flag and the
24+// merge counters.
25+type presentSink struct {
26+ s *Server
27+
28+ mu sync.Mutex
29+ ring, spare []ports.OutputPresented
30+ pending bool // a drain is posted and has not taken the ring yet
31+ // merged counts flips merged since the last warning;
32+ // lastWarn is when that warning was logged.
33+ merged int
34+ lastWarn time.Time
35+ drainFn func()
36+ scratchFlips map[string]bool
37+}
38+
39+func newPresentSink(s *Server) *presentSink {
40+ p := &presentSink{
41+ s: s,
42+ ring: make([]ports.OutputPresented, 0, maxPresentRing),
43+ spare: make([]ports.OutputPresented, 0, maxPresentRing),
44+ scratchFlips: make(map[string]bool),
45+ }
46+ p.drainFn = p.drain
47+ return p
48+}
49+
50+// Presentation returns the sink outputs report to. Push never blocks.
51+func (s *Server) Presentation() ports.PresentationSink { return s.sink }
52+
53+// Push queues a report for the display goroutine and wakes it; one wake
54+// serves every report pushed before the drain runs. When the ring is full the
55+// flip of the oldest report is carried or merged forward; merges are counted
56+// and warned about at most every mergeWarnEvery.
57+func (p *presentSink) Push(r ports.OutputPresented) {
58+ p.mu.Lock()
59+ warn, merged := p.enqueueLocked(r)
60+ post := !p.pending
61+ p.pending = true
62+ p.mu.Unlock()
63+ if warn {
64+ p.s.log.Warn().Int("merged", merged).Msg("outputs report faster than the display loop drains; flips merged")
65+ }
66+ if post {
67+ p.s.display.Post(p.drainFn)
68+ }
69+}
70+
71+// enqueueLocked appends r, making room first when the ring is full. It
72+// reports whether to warn now and how many flips were merged since the last
73+// warning.
74+func (p *presentSink) enqueueLocked(r ports.OutputPresented) (warn bool, merged int) {
75+ if len(p.ring) >= maxPresentRing {
76+ if p.makeRoomLocked(&r) {
77+ p.merged++
78+ }
79+ if now := p.s.clock.Now(); p.merged > 0 && now.Sub(p.lastWarn) >= mergeWarnEvery {
80+ warn, merged = true, p.merged
81+ p.merged, p.lastWarn = 0, now
82+ }
83+ }
84+ p.ring = append(p.ring, r)
85+ return warn, merged
86+}
87+
88+// makeRoomLocked frees a ring slot for incoming without losing a flip: the
89+// oldest report that a later report of the same output follows (in the ring,
90+// or incoming itself) is removed and its flip carried into that report. A
91+// flip-less successor takes the flip over; a successor with a flip merges it
92+// (presented.Merge: the older feedback is discarded), which is what the
93+// result counts.
94+//
95+// When no report has a successor, every ring entry and incoming belong to
96+// distinct outputs: only possible with more than maxPresentRing outputs.
97+// Nothing is dropped then; the ring grows by one, bounded by the number of
98+// outputs, because the next report of any of them finds a successor.
99+func (p *presentSink) makeRoomLocked(incoming *ports.OutputPresented) (merged bool) {
100+ for i := range p.ring {
101+ next := p.successorLocked(i, incoming)
102+ if next == nil {
103+ continue
104+ }
105+ switch old := p.ring[i].Flip; {
106+ case old == nil:
107+ case next.Flip == nil:
108+ next.Flip = old
109+ default:
110+ presented.Merge(old, next.Flip)
111+ merged = true
112+ }
113+ p.ring = slices.Delete(p.ring, i, i+1)
114+ return merged
115+ }
116+ return false
117+}
118+
119+// successorLocked returns the next report of ring[i]'s output: a later ring
120+// entry, else incoming; nil when none follows.
121+func (p *presentSink) successorLocked(i int, incoming *ports.OutputPresented) *ports.OutputPresented {
122+ for j := i + 1; j < len(p.ring); j++ {
123+ if p.ring[j].Output == p.ring[i].Output {
124+ return &p.ring[j]
125+ }
126+ }
127+ if incoming.Output == p.ring[i].Output {
128+ return incoming
129+ }
130+ return nil
131+}
132+
133+// drain runs on the display goroutine: it paces frames for every report that
134+// arrived since the last drain as one tick, so a burst of flips fires one
135+// burst of frame callbacks per output.
136+func (p *presentSink) drain() {
137+ p.mu.Lock()
138+ batch := p.ring
139+ p.ring, p.spare = p.spare[:0], nil
140+ p.pending = false
141+ p.mu.Unlock()
142+ flipped := p.scratchFlips
143+ clear(flipped)
144+ for _, r := range batch {
145+ flipped[r.Output] = flipped[r.Output] || paces(r)
146+ }
147+ if len(batch) > 0 {
148+ p.s.paceTick(flipped, batch)
149+ }
150+ // The reports carry maps: do not retain them in the spare batch.
151+ clear(batch)
152+ p.mu.Lock()
153+ p.spare = batch[:0]
154+ p.mu.Unlock()
155+}
A internal/adapters/wayland/presented_test.go +444 -0
@@ -0,0 +1,444 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/go-wayland-bindings/server/fifo"
11+ "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/neferwl/internal/adapters/logging"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// slowOutputs has two outputs refreshing once a second: their deadlines never
21+// fire inside a test, so only flips pace their frame callbacks.
22+var slowOutputs = ports.Layout{
23+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 1000}, Width: 1920, Height: 1080, Scale: 1},
24+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1920, Height: 1080, RefreshMilli: 1000}, X: 1920, Width: 1920, Height: 1080, Scale: 1},
25+}
26+
27+// frameDoneProxy records the time of a wl_callback.done.
28+type frameDoneProxy struct {
29+ wlturbo.BaseProxy
30+ done chan uint32
31+}
32+
33+func (p *frameDoneProxy) Dispatch(e *wlturbo.Event) {
34+ if e.Opcode == uint16(wayland.CallbackEventDone) {
35+ p.done <- binary.NativeEndian.Uint32(e.Data())
36+ }
37+}
38+
39+// requestFrame commits the surface with a new frame callback.
40+func requestFrame(t *testing.T, c *wlturbo.Display, surf uint32) *frameDoneProxy {
41+ t.Helper()
42+ cb := c.AllocateID()
43+ p := &frameDoneProxy{done: make(chan uint32, 2)}
44+ p.SetID(cb)
45+ registerWireProxy(c, p)
46+ requestProtocol(t, c, surf, wayland.SurfaceRequestFrame, cb)
47+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
48+ if err := c.Roundtrip(); err != nil {
49+ t.Fatal(err)
50+ }
51+ return p
52+}
53+
54+// awaitFrame waits for the callback to be answered and returns its time.
55+func awaitFrame(t *testing.T, c *wlturbo.Display, p *frameDoneProxy) uint32 {
56+ t.Helper()
57+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
58+ if err := c.Roundtrip(); err != nil {
59+ t.Fatal(err)
60+ }
61+ select {
62+ case ms := <-p.done:
63+ return ms
64+ case <-time.After(2 * time.Millisecond):
65+ }
66+ }
67+ t.Fatal("frame callback not answered")
68+ return 0
69+}
70+
71+// placeWindow puts the window on an output and waits until wayland applied it.
72+func placeWindow(t *testing.T, s *Server, commands chan<- ports.ClientCommand, w ports.WindowMapped, output string) {
73+ t.Helper()
74+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: output, Visible: true}
75+ for deadline := time.Now().Add(2 * time.Second); ; {
76+ var got string
77+ s.display.Do(func() { got = s.frameOutput(s.windows[w.ID].xdg.surface) })
78+ if got == output {
79+ return
80+ }
81+ if time.Now().After(deadline) {
82+ t.Fatalf("window not placed on %s", output)
83+ }
84+ time.Sleep(time.Millisecond)
85+ }
86+}
87+
88+// Flips pushed while the display goroutine is busy are paced by one tick per
89+// output, not one per stale flip. The frame callback of each output is
90+// answered once, with the time of that output's last flip: a tick per flip
91+// would have answered it with the first flip's time.
92+func TestPresentationPushCoalescesWhileDisplayBusy(t *testing.T) {
93+ s, events, commands, _, sink, dir := presentServerOn(t, slowOutputs)
94+ c := protocolClient(t, s, dir)
95+ mk := surfaceMapper(t, c, events)
96+ outputs := []string{"HEADLESS-1", "HEADLESS-2"}
97+ surfs := make([]uint32, len(outputs))
98+ for i, name := range outputs {
99+ w, surf, _ := mk()
100+ placeWindow(t, s, commands, w, name)
101+ surfs[i] = surf
102+ // The first callback of an idle output is answered at once and
103+ // starts its one-second period: the next one waits for a flip.
104+ awaitFrame(t, c, requestFrame(t, c, surf))
105+ }
106+ waiting := make([]*frameDoneProxy, len(outputs))
107+ for i := range outputs {
108+ waiting[i] = requestFrame(t, c, surfs[i])
109+ select {
110+ case ms := <-waiting[i].done:
111+ t.Fatalf("%s answered before any flip (at %d ms)", outputs[i], ms)
112+ default:
113+ }
114+ }
115+ var base time.Duration
116+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
117+ go func() {
118+ s.display.Do(func() { base = monotonic(s.started); close(started); <-release })
119+ close(finished)
120+ }()
121+ <-started
122+ const flips = 5
123+ const step = 100 * time.Millisecond
124+ for i := range flips {
125+ for _, name := range outputs {
126+ sink.Push(ports.OutputPresented{Output: name, Flip: &ports.FlipInfo{When: base + time.Duration(i+1)*step, Seq: uint64(i + 1), HardwareClock: true}})
127+ }
128+ }
129+ close(release)
130+ <-finished
131+ last := uint32((flips * step).Milliseconds())
132+ for i, name := range outputs {
133+ ms := awaitFrame(t, c, waiting[i])
134+ // base is read on the display goroutine a little before the server
135+ // answers: a few ms of slack, far below one step.
136+ if ms < last-5 || ms > last+5 {
137+ t.Fatalf("%s answered at %d ms, want the last flip's %d ms (the first flip is at %d)", name, ms, last, step.Milliseconds())
138+ }
139+ }
140+ var seq uint64
141+ var reported bool
142+ s.display.Do(func() {
143+ seq = s.flips["HEADLESS-1"].Seq
144+ _, reported = s.reports["HEADLESS-1"]
145+ })
146+ if !reported || seq != flips {
147+ t.Fatalf("last flip %d, reported %v: every flip must still be answered in order", seq, reported)
148+ }
149+}
150+
151+func ringOf(outputs ...string) *presentSink {
152+ p := &presentSink{ring: make([]ports.OutputPresented, 0, maxPresentRing+1)}
153+ for _, o := range outputs {
154+ p.ring = append(p.ring, ports.OutputPresented{Output: o})
155+ }
156+ return p
157+}
158+
159+// fill pads the ring with reports of distinct outputs up to the bound.
160+func (p *presentSink) fill() {
161+ for i := 0; len(p.ring) < maxPresentRing; i++ {
162+ p.ring = append(p.ring, ports.OutputPresented{Output: "pad" + string(rune('A'+i/26)) + string(rune('a'+i%26))})
163+ }
164+}
165+
166+func flipOf(seq uint64, shows ...ports.WindowID) *ports.FlipInfo {
167+ f := &ports.FlipInfo{Seq: seq}
168+ if len(shows) > 0 {
169+ f.Shows = map[ports.WindowID]uint64{}
170+ for _, id := range shows {
171+ f.Shows[id] = seq
172+ }
173+ }
174+ return f
175+}
176+
177+// makeRoomLocked on a full ring: which report it merges or moves, and what it
178+// reports.
179+func TestPresentationRingMakeRoom(t *testing.T) {
180+ tests := []struct {
181+ name string
182+ ring func() *presentSink
183+ in ports.OutputPresented
184+ wantMerged bool
185+ check func(t *testing.T, p *presentSink, in *ports.OutputPresented)
186+ }{
187+ {
188+ // A full ring merges the oldest flip of an output into its next flip.
189+ name: "merges past the bound",
190+ ring: func() *presentSink {
191+ p := ringOf()
192+ for i := range maxPresentRing {
193+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
194+ }
195+ return p
196+ },
197+ in: ports.OutputPresented{Output: "A", Flip: flipOf(maxPresentRing + 1)},
198+ wantMerged: true,
199+ check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) {
200+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 1 {
201+ t.Fatalf("ring %d, first seq %d merged %d", len(p.ring), p.ring[0].Flip.Seq, p.ring[0].Flip.Merged)
202+ }
203+ },
204+ },
205+ {
206+ // A flip followed by a flip-less report of the same output is not
207+ // lost: the later report carries it.
208+ name: "keeps a flip before a flip-less report",
209+ ring: func() *presentSink {
210+ p := ringOf("A", "A")
211+ p.ring[0].Flip = flipOf(7, 3)
212+ p.fill()
213+ p.ring = p.ring[:maxPresentRing]
214+ return p
215+ },
216+ in: ports.OutputPresented{Output: "B", Flip: flipOf(9)},
217+ check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) {
218+ if len(p.ring) != maxPresentRing-1 || p.ring[0].Output != "A" || p.ring[0].Flip == nil || p.ring[0].Flip.Seq != 7 || p.ring[0].Flip.Merged != 0 {
219+ t.Fatalf("flip lost: ring %d, first %+v", len(p.ring), p.ring[0])
220+ }
221+ },
222+ },
223+ {
224+ // With several outputs interleaved, the victim is the oldest report
225+ // that has a successor of its own output; other outputs' reports are
226+ // untouched.
227+ name: "interleaved outputs",
228+ ring: func() *presentSink {
229+ p := ringOf("A", "B", "A", "B")
230+ p.ring[0].Flip, p.ring[1].Flip = flipOf(1, 10), flipOf(2, 20)
231+ p.ring[2].Flip, p.ring[3].Flip = flipOf(3, 11), nil
232+ p.fill()
233+ return p
234+ },
235+ in: ports.OutputPresented{Output: "C"},
236+ wantMerged: true,
237+ check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) {
238+ if p.ring[0].Output != "B" || p.ring[0].Flip.Seq != 2 || p.ring[0].Flip.Merged != 0 {
239+ t.Fatalf("B's flip touched: %+v", p.ring[0])
240+ }
241+ a := p.ring[1]
242+ if a.Output != "A" || a.Flip.Seq != 3 || a.Flip.Merged != 1 || a.Flip.Shows[10] != 1 || a.Flip.Shows[11] != 3 {
243+ t.Fatalf("A's flips not merged: %+v", a.Flip)
244+ }
245+ if p.ring[2].Output != "B" || p.ring[2].Flip != nil {
246+ t.Fatalf("B's flip-less report moved: %+v", p.ring[2])
247+ }
248+ },
249+ },
250+ {
251+ // The incoming report counts as the successor of a ring report.
252+ name: "incoming takes over a flip",
253+ ring: func() *presentSink {
254+ p := ringOf("A")
255+ p.ring[0].Flip = flipOf(4)
256+ p.fill()
257+ return p
258+ },
259+ in: ports.OutputPresented{Output: "A"},
260+ check: func(t *testing.T, p *presentSink, in *ports.OutputPresented) {
261+ if in.Flip == nil || in.Flip.Seq != 4 || len(p.ring) != maxPresentRing-1 {
262+ t.Fatalf("incoming %+v, ring %d", in.Flip, len(p.ring))
263+ }
264+ },
265+ },
266+ {
267+ // With no successor for any report (more outputs than ring slots)
268+ // nothing is dropped: the ring grows by one and every flip is still
269+ // delivered.
270+ name: "without a successor keeps everything",
271+ ring: func() *presentSink {
272+ p := ringOf()
273+ for i := range maxPresentRing {
274+ p.ring = append(p.ring, ports.OutputPresented{Output: "o" + string(rune('A'+i/26)) + string(rune('a'+i%26)), Flip: flipOf(uint64(i + 1))})
275+ }
276+ return p
277+ },
278+ in: ports.OutputPresented{Output: "other", Flip: flipOf(99)},
279+ check: func(t *testing.T, p *presentSink, _ *ports.OutputPresented) {
280+ if len(p.ring) != maxPresentRing {
281+ t.Fatalf("merged or dropped: ring %d", len(p.ring))
282+ }
283+ },
284+ },
285+ }
286+ for _, tt := range tests {
287+ t.Run(tt.name, func(t *testing.T) {
288+ p := tt.ring()
289+ in := tt.in
290+ if merged := p.makeRoomLocked(&in); merged != tt.wantMerged {
291+ t.Fatalf("merged %v, want %v", merged, tt.wantMerged)
292+ }
293+ tt.check(t, p, &in)
294+ })
295+ }
296+}
297+
298+// Merges are counted, and warned about at most once per mergeWarnEvery.
299+func TestPresentationRingWarnsRateLimited(t *testing.T) {
300+ now := time.Unix(1000, 0)
301+ clk := portsmocks.NewMockClock(t)
302+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now })
303+ p := ringOf()
304+ p.s = &Server{clock: clk}
305+ for i := range maxPresentRing {
306+ p.ring = append(p.ring, ports.OutputPresented{Output: "A", Flip: flipOf(uint64(i + 1))})
307+ }
308+ push := func(seq uint64) (bool, int) {
309+ return p.enqueueLocked(ports.OutputPresented{Output: "A", Flip: flipOf(seq)})
310+ }
311+ if warn, n := push(100); !warn || n != 1 {
312+ t.Fatalf("first merge: warn %v merged %d", warn, n)
313+ }
314+ for i := range 3 {
315+ now = now.Add(time.Second)
316+ if warn, _ := push(uint64(101 + i)); warn {
317+ t.Fatalf("warned again after %v", time.Duration(i+1)*time.Second)
318+ }
319+ }
320+ now = now.Add(mergeWarnEvery)
321+ if warn, n := push(200); !warn || n != 4 {
322+ t.Fatalf("after the interval: warn %v merged %d, want the 4 merges since", warn, n)
323+ }
324+}
325+
326+// A stalled display loop makes outputs merge flips; wayland says so once.
327+func TestPresentationMergeWarnsInLog(t *testing.T) {
328+ var out lockedBuffer
329+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
330+ s, _, _, _, _ := contentServerWith(t, Options{}, log)
331+ release, started, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
332+ go func() {
333+ s.display.Do(func() { close(started); <-release })
334+ close(finished)
335+ }()
336+ <-started
337+ for i := range 3 * maxPresentRing {
338+ s.Presentation().Push(ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{Seq: uint64(i + 1)}})
339+ }
340+ close(release)
341+ <-finished
342+ s.display.Do(func() {})
343+ got := out.String()
344+ if n := strings.Count(got, `"level":"warn"`); n != 1 {
345+ t.Fatalf("%d warnings: %s", n, got)
346+ }
347+ if !strings.Contains(got, `"component":"wayland"`) || !strings.Contains(got, `"merged":`) {
348+ t.Fatalf("warning lacks fields: %s", got)
349+ }
350+}
351+
352+// Pushing a report and draining it on the display goroutine allocates nothing
353+// in steady state. The count includes the display goroutine: AllocsPerRun
354+// reads the global malloc counter.
355+func TestPresentationPushAllocations(t *testing.T) {
356+ if raceEnabled {
357+ t.Skip("the race detector makes the purego wake callback allocate")
358+ }
359+ s, _, _, _, sink, _ := presentServer(t)
360+ r := ports.OutputPresented{Output: "HEADLESS-1"}
361+ // Posts run in order: once the marker ran, the drain before it did.
362+ // The marker is bound once and the channel is reused, so waiting for it
363+ // allocates nothing.
364+ ran := make(chan struct{}, 1)
365+ marker := func() { ran <- struct{}{} }
366+ for range 4 {
367+ sink.Push(r)
368+ s.display.Post(marker)
369+ <-ran
370+ }
371+ allocs := testing.AllocsPerRun(200, func() {
372+ sink.Push(r)
373+ s.display.Post(marker)
374+ <-ran
375+ })
376+ if allocs != 0 {
377+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
378+ }
379+}
380+
381+// A fifo barrier on an output that does not flip clears at barrierAt+period,
382+// driven by the pace timerfd. On a one-second output (so nothing expires during
383+// the test) the timerfd must be armed, no later than that deadline (other waits,
384+// like held buffers, may be earlier); and its callback, run once the deadline
385+// has passed, applies the queued commit.
386+func TestPaceTimerfdArmedForBarrierDeadline(t *testing.T) {
387+ s, events, commands, _, dir := contentServerWith(t, Options{Outputs: slowOutputs}, testLog())
388+ c := protocolClient(t, s, dir)
389+ w, surf, _ := surfaceMapper(t, c, events)()
390+ placeWindow(t, s, commands, w, "HEADLESS-1")
391+ fifoQueue(t, c, surf)
392+ var remaining, want, period time.Duration
393+ var queued, left int
394+ var barrier bool
395+ var err error
396+ s.display.Do(func() {
397+ root := s.windows[w.ID].xdg.surface
398+ period, queued = s.outputPeriod(s.fifoOutput(root)), len(root.queue)
399+ var spec unix.ItimerSpec
400+ err = unix.TimerfdGettime(s.paceFD, &spec)
401+ remaining = time.Duration(spec.Value.Nano())
402+ want = time.Until(root.barrierAt.Add(period))
403+ // The deadline passes: the timerfd callback clears the barrier.
404+ root.barrierAt = time.Now().Add(-period)
405+ s.onPaceTimer()
406+ barrier, left = root.barrier, len(root.queue)
407+ })
408+ if err != nil {
409+ t.Fatal(err)
410+ }
411+ if queued != 1 || period != time.Second {
412+ t.Fatalf("queued %d commits, period %v: the second commit must wait for the barrier", queued, period)
413+ }
414+ if remaining <= 0 || remaining > want+50*time.Millisecond {
415+ t.Fatalf("pace timerfd fires in %v, want armed at or before barrierAt+period (%v)", remaining, want)
416+ }
417+ if barrier || left != 0 {
418+ t.Fatalf("timer callback after the deadline: barrier %v, %d queued", barrier, left)
419+ }
420+}
421+
422+func testLog() zerowrap.Logger {
423+ return logging.For(context.Background(), "wayland")
424+}
425+
426+// fifoQueue sets a barrier with one commit and queues a second commit behind
427+// wait_barrier.
428+func fifoQueue(t *testing.T, c *wlturbo.Display, surf uint32) {
429+ t.Helper()
430+ manager := bindProtocol(t, c, "wp_fifo_manager_v1")
431+ f := c.AllocateID()
432+ registerProtocol(t, c, f)
433+ requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
434+ buf := shmBuffer(t, c)
435+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
436+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
437+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
438+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
439+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
440+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
441+ if err := c.Roundtrip(); err != nil {
442+ t.Fatal(err)
443+ }
444+}
M internal/adapters/wayland/protocol_test.go +8 -1
@@ -627,7 +627,14 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
627627 // xdg_surface IDs.
628628 func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32, uint32) {
629629 t.Helper()
630- comp := bindProtocol(t, c, "wl_compositor")
630+ return surfaceMapperAt(t, c, events, 1)
631+}
632+
633+// surfaceMapperAt is surfaceMapper with wl_compositor bound at compositorVersion
634+// (damage_buffer needs 4).
635+func surfaceMapperAt(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent, compositorVersion uint32) func() (ports.WindowMapped, uint32, uint32) {
636+ t.Helper()
637+ comp := bindVersion(t, c, "wl_compositor", compositorVersion)
631638 wm := bindProtocol(t, c, "xdg_wm_base")
632639 shm := bindProtocol(t, c, "wl_shm")
633640 registerProtocol(t, c, shm)
A internal/adapters/wayland/race_test.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build race
2+
3+package wayland
4+
5+// raceEnabled: the race detector makes purego callbacks (the display loop wake)
6+// allocate, which allocation budgets that cross the display loop cannot count.
7+const raceEnabled = true
M internal/adapters/wayland/release.go +0 -4
@@ -47,9 +47,7 @@ func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold)
4747 if d, ok := s.buffers[b.Resource].(*dmabufBuffer); ok {
4848 h.id = d.buf.ID
4949 }
50- s.contentMu.Lock()
5150 h.after = s.contentSeq[window]
52- s.contentMu.Unlock()
5351 s.held = append(s.held, h)
5452 // Wake the pacer: it releases the buffer on the next output report.
5553 s.wakePacer()
@@ -78,9 +76,7 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) {
7876 s.releaseSync(sync)
7977 return
8078 }
81- s.contentMu.Lock()
8279 after := s.contentSeq[window]
83- s.contentMu.Unlock()
8480 s.held = append(s.held, heldBuffer{sync: sync, window: window, after: after, at: time.Now()})
8581 s.wakePacer()
8682 }
M internal/adapters/wayland/scale_test.go +14 -3
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/neferwl/internal/adapters/logging"
1414 "github.com/bnema/neferwl/internal/ports"
1515 "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
1617 "golang.org/x/sys/unix"
1718 )
1819
@@ -51,16 +52,26 @@ func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
5152 }
5253 }
5354
54-func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
55+func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, string) {
56+ t.Helper()
57+ return contentServerWith(t, Options{}, logging.For(context.Background(), "wayland"))
58+}
59+
60+// contentServerWith is contentServer with extra options and a logger.
61+func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, <-chan ports.SurfaceContent, string) {
5562 t.Helper()
5663 dir := t.TempDir()
5764 events := make(chan ports.ClientEvent, 16)
5865 commands := make(chan ports.ClientCommand, 16)
59- contents := make(chan ports.SurfaceContent, 16)
60- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
66+ opts.RuntimeDir = dir
67+ if opts.Outputs == nil {
68+ opts.Outputs = testOutputs
69+ }
70+ s, err := New(opts, Channels{Events: events, Commands: commands}, log)
6171 if err != nil {
6272 t.Fatal(err)
6373 }
74+ contents := relayContents(t, s)
6475 ctx, cancel := context.WithCancel(context.Background())
6576 done := make(chan error, 1)
6677 go func() { done <- s.Run(ctx) }()
M internal/adapters/wayland/server.go +75 -77
@@ -48,6 +48,9 @@ type Options struct {
4848 // nil lets every client capture, unless the session is protected. The
4949 // caller keeps it current (captureallow.Store.Run).
5050 CaptureAllow *captureallow.Store
51+ // LogDamage logs the damage of every fresh client commit at debug level
52+ // (--debug=wayland-damage).
53+ LogDamage bool
5154 // syncDev replaces the render node's syncobj interface (tests).
5255 syncDev syncobjDevice
5356 // peer replaces the peer executable lookup (tests).
@@ -63,14 +66,10 @@ type Channels struct {
6366 LeaseRequests chan<- ports.LeaseMessage
6467 LeaseEvents <-chan ports.LeaseMessage
6568 Events chan<- ports.ClientEvent
66- Contents chan<- ports.SurfaceContent
6769 Commands <-chan ports.ClientCommand
6870 Workspaces <-chan ports.Workspaces
6971 // Cursors receives the cursor the client under the pointer asks for.
7072 Cursors chan<- ports.CursorChange
71- // Presented paces frame callbacks on the outputs' page flips; outputs
72- // that do not flip (idle, headless) are paced at their refresh rate.
73- Presented <-chan ports.OutputPresented
7473 // Captures carries accepted capture requests, never blocking. Each
7574 // accepted request holds one of ports.MaxCaptureInflight credits until its
7675 // CaptureDone arrives on Captured, whether or not the client is still
@@ -144,24 +143,32 @@ type Server struct {
144143 name string
145144 cleanup func()
146145 log zerowrap.Logger
146+ logDamage bool
147147 channels Channels
148148 // awaiting holds frame callbacks by output name, due at its next
149149 // frame (frameDue, or its page flip).
150150 awaiting map[string][]*wayland.Callback
151151 frameOwners map[*wayland.Callback]*surface
152152 frameDue map[string]time.Time
153- frameReady chan struct{}
154- // paceTimer is the pacer's deadline, armed by ticks on the display
155- // goroutine; paceDepth/paceDirty defer wakePacer inside a commit or
156- // tick (display goroutine only).
157- paceTimer *time.Timer
158- paceDepth int
159- paceDirty bool
160- // Pacer scratch: periods is used on the display goroutine, while
161- // flipped and frameReports belong to pace and its synchronous Do call.
153+ // paceFD is the pace deadline timerfd, armed by ticks on the display
154+ // goroutine (paceReady: registered with the display loop);
155+ // paceDepth/paceDirty defer wakePacer inside a commit or tick (display
156+ // goroutine only). paceSignal coalesces posted ticks (paceSignalFn).
157+ paceFD int
158+ paceReady bool
159+ paceWarned bool // a pace timer failure was logged (once)
160+ // paceArmed: the timerfd is armed for paceDeadline (display goroutine
161+ // only); armPace and stopPace skip a syscall that would change nothing.
162+ paceArmed bool
163+ paceDeadline time.Time
164+ paceSignal atomic.Bool
165+ paceSignalFn func()
166+ paceDepth int
167+ paceDirty bool
168+ // sink receives the outputs' reports (presented.go).
169+ sink *presentSink
170+ // framePeriods is the tick's scratch (display goroutine only).
162171 framePeriods map[string]time.Duration
163- flipped map[string]bool
164- frameReports []ports.OutputPresented
165172 // held buffers wait until no output reads them (release.go); reports
166173 // are the outputs' latest ports.OutputPresented.
167174 held []heldBuffer
@@ -209,11 +216,16 @@ type Server struct {
209216 eventMu sync.Mutex
210217 events []ports.ClientEvent
211218 eventReady chan struct{}
212- contentMu sync.Mutex
213- contents map[ports.WindowID]ports.SurfaceContent
219+ // contentSeq counts each window's contents and damage is its recent
220+ // damage; latest is its current content. All three belong to the display
221+ // goroutine, as do the outputs' content links (contentlink.go).
214222 contentSeq map[ports.WindowID]uint64
215- // damage is each window's recent damage (contentMu).
216- damage map[ports.WindowID][]ports.SeqDamage
223+ damage map[ports.WindowID][]ports.SeqDamage
224+ latest map[ports.WindowID]ports.SurfaceContent
225+ links []*contentLink
226+ // flushPosted: a flushLinks is posted and has not started (Return).
227+ flushPosted atomic.Bool
228+ flushLinksFn func()
217229 // feedbacks wait for the flip that shows their content (presentation.go).
218230 feedbacks []feedbackWait
219231 // Explicit sync (syncobj.go): the render node, imported timelines by
@@ -221,7 +233,7 @@ type Server struct {
221233 syncDev syncobjDevice
222234 timelines map[*server.Resource]*timeline
223235 syncWait *syncWaiter
224- // flips is each output's latest flip (pacer, under Do).
236+ // flips is each output's latest flip (display goroutine).
225237 flips map[string]ports.FlipInfo
226238 // inhibitors are the live inhibitor objects (inhibit.go);
227239 // shortcutWindows and idleWindows what core was told.
@@ -235,7 +247,6 @@ type Server struct {
235247 clock ports.Clock
236248 powers []*outputPower
237249 outputsOff map[string]bool
238- contentReady chan struct{}
239250 outputs []*output
240251 outputManagers []*outputManager
241252 workspaceManagers []*workspaceManager
@@ -315,12 +326,12 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
315326 display: d,
316327 awaiting: map[string][]*wayland.Callback{},
317328 frameDue: map[string]time.Time{},
318- frameReady: make(chan struct{}, 1),
319329 reports: map[string]ports.OutputPresented{},
320330 env: linuxProcEnv{},
321331 name: name,
322332 cleanup: cleanup,
323333 log: log,
334+ logDamage: opts.LogDamage,
324335 channels: ch,
325336 surfaces: make(map[*server.Resource]*surface),
326337 buffers: make(map[*server.Resource]clientBuffer),
@@ -328,10 +339,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
328339 layers: make(map[ports.WindowID]*layerSurface),
329340 nextWindow: 1,
330341 eventReady: make(chan struct{}, 1),
331- contents: make(map[ports.WindowID]ports.SurfaceContent),
332342 contentSeq: make(map[ports.WindowID]uint64),
333343 damage: map[ports.WindowID][]ports.SeqDamage{},
334- contentReady: make(chan struct{}, 1),
344+ latest: make(map[ports.WindowID]ports.SurfaceContent),
335345 cursorReady: make(chan struct{}, 1),
336346 dataSources: map[*server.Resource]*clipSource{},
337347 primarySources: map[*server.Resource]*clipSource{},
@@ -343,6 +353,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
343353 positioners: map[*server.Resource]*positioner{},
344354 seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), keyStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), pointerStamps: make(map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay},
345355 }
356+ // Bound once, before anything can post them (the sync waiter calls
357+ // signalPacer from its own goroutine as soon as it exists).
358+ s.paceSignalFn = s.paceSignalled
359+ s.flushLinksFn = s.flushLinks
360+ s.sink = newPresentSink(s)
346361 s.leaseDevices = map[string]*leaseDevice{}
347362 s.pendingLeases = map[uint64]*leaseObject{}
348363 s.pendingLeaseCards = map[uint64]string{}
@@ -416,6 +431,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
416431 }
417432 s.syncWait.log = log
418433 s.cleanup = func() {
434+ if s.paceReady {
435+ unix.Close(s.paceFD)
436+ }
419437 if s.securityStop >= 0 {
420438 unix.Close(s.securityStop)
421439 }
@@ -436,6 +454,11 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
436454 d.Close()
437455 return nil, err
438456 }
457+ if err = s.openPaceTimer(); err != nil {
458+ s.cleanup()
459+ d.Close()
460+ return nil, err
461+ }
439462 if err = registerGlobals(d, opts, s); err != nil {
440463 s.cleanup()
441464 d.Close()
@@ -446,6 +469,24 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
446469 }
447470 return s, nil
448471 }
472+
473+// openPaceTimer creates the pace timerfd and registers it with the display
474+// loop (before Run, as AddFD requires).
475+func (s *Server) openPaceTimer() error {
476+ fd, err := unix.TimerfdCreate(unix.CLOCK_MONOTONIC, unix.TFD_NONBLOCK|unix.TFD_CLOEXEC)
477+ if err != nil {
478+ return err
479+ }
480+ // libwayland duplicates fd: ours stays open for TimerfdSettime and is
481+ // closed by cleanup; the source goes with the display.
482+ if _, err = s.display.AddFD(fd, s.onPaceTimer); err != nil {
483+ unix.Close(fd)
484+ return err
485+ }
486+ s.paceFD, s.paceReady = fd, true
487+ return nil
488+}
489+
449490 func (s *Server) SocketName() string { return s.name }
450491 func (s *Server) Run(ctx context.Context) error {
451492 defer s.cleanup()
@@ -459,9 +500,8 @@ func (s *Server) Run(ctx context.Context) error {
459500 s.log.Info().Str("socket", s.name).Msg("starting wayland")
460501 s.started = time.Now()
461502 s.ctx = ctx
462- s.paceTimer = time.NewTimer(defaultFramePeriod)
463503 var wg sync.WaitGroup
464- wg.Add(15)
504+ wg.Add(13)
465505 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
466506 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
467507 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -469,7 +509,6 @@ func (s *Server) Run(ctx context.Context) error {
469509 go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
470510 go func() { defer wg.Done(); s.forward(ctx) }()
471511 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
472- go func() { defer wg.Done(); s.forwardContents(ctx) }()
473512 go func() {
474513 defer wg.Done()
475514 for {
@@ -502,7 +541,6 @@ func (s *Server) Run(ctx context.Context) error {
502541 }
503542 }
504543 }()
505- go func() { defer wg.Done(); s.pace(ctx) }()
506544 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
507545 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
508546 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
@@ -636,11 +674,10 @@ func (s *Server) forward(ctx context.Context) {
636674 }
637675 }
638676
677+// emitContent publishes a window's content on the display goroutine: it
678+// becomes the window's latest content and joins the batch of every attached
679+// output (contentlink.go).
639680 func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
640- if s.channels.Contents == nil {
641- return
642- }
643- s.contentMu.Lock()
644681 s.contentSeq[c.ID]++
645682 c.Seq = s.contentSeq[c.ID]
646683 // Published histories are immutable: an output may hold one for as long
@@ -658,56 +695,17 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
658695 old = grown
659696 }
660697 h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects})
698+ c.DamageHistory = h[:len(h):len(h)]
661699 if c.Empty() {
662700 delete(s.damage, c.ID)
701+ delete(s.latest, c.ID)
663702 } else {
664703 s.damage[c.ID] = h
704+ s.latest[c.ID] = c
665705 }
666- c.DamageHistory = h[:len(h):len(h)]
667- s.contents[c.ID] = c
668- s.contentMu.Unlock()
669- select {
670- case s.contentReady <- struct{}{}:
671- default:
672- }
673-}
674-
675-func (s *Server) forwardContents(ctx context.Context) {
676- for {
677- s.contentMu.Lock()
678- var id ports.WindowID
679- var c ports.SurfaceContent
680- found := false
681- var seq uint64
682- for id, c = range s.contents {
683- found = true
684- seq = s.contentSeq[id]
685- break
686- }
687- s.contentMu.Unlock()
688- if !found {
689- select {
690- case <-ctx.Done():
691- return
692- case <-s.contentReady:
693- continue
694- }
695- }
696- // Do not hold a stale frame while the receiver is busy: wake on newer
697- // data. contentReady is signalled after every publication; a stale
698- // signal only costs one more loop.
699- select {
700- case <-ctx.Done():
701- return
702- case <-s.contentReady:
703- continue
704- case s.channels.Contents <- c:
705- s.contentMu.Lock()
706- if s.contentSeq[id] == seq {
707- delete(s.contents, id)
708- }
709- s.contentMu.Unlock()
710- }
706+ for _, l := range s.links {
707+ l.add(c)
708+ s.flushLink(l)
711709 }
712710 }
713711
M internal/adapters/wayland/server_test.go +2 -2
@@ -374,11 +374,11 @@ func TestWestonContents(t *testing.T) {
374374 }
375375 dir := t.TempDir()
376376 events := make(chan ports.ClientEvent, 16)
377- contents := make(chan ports.SurfaceContent, 16)
378- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))
377+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events}, logging.For(context.Background(), "wayland"))
379378 if err != nil {
380379 t.Fatal(err)
381380 }
381+ contents := relayContents(t, s)
382382 ctx, cancel := context.WithCancel(context.Background())
383383 done := make(chan error, 1)
384384 go func() { done <- s.Run(ctx) }()
M internal/adapters/wayland/surface.go +33 -1
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/go-wayland-bindings/server/wlrlayershell"
1414 "github.com/bnema/go-wayland-bindings/server/xdgshell"
1515 "github.com/bnema/neferwl/internal/ports"
16+ "github.com/rs/zerolog"
1617 )
1718
1819 type roleKind uint8
@@ -263,7 +264,7 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) {
263264 // for later) are not drawn, so they do not make it full.
264265 func (s *surface) redraw() {
265266 r := s.root()
266- if id := r.windowID(); id != 0 && s.server.channels.Contents != nil {
267+ if id := r.windowID(); id != 0 {
267268 c := r.tree(id)
268269 d := damage{full: true}
269270 if s == r && len(c.Children) == 0 && !r.drewChildren {
@@ -588,6 +589,37 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) {
588589 // A new size, too many rects or none at all with a new buffer (clients
589590 // that do not report damage) is a full change.
590591 func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
592+ s.mapDamage(u, fresh, resized, bw, bh)
593+ if fresh && s.server.logDamage {
594+ s.traceDamage(bw, bh)
595+ }
596+}
597+
598+// traceDamage logs the damage commitDamage just computed
599+// (--debug=wayland-damage): full_buffer is set when the commit is a full
600+// change or one rect covers the whole buffer. Rects are already clipped to
601+// the buffer, so covering means equal to it.
602+func (s *surface) traceDamage(bw, bh int) {
603+ d := s.committed
604+ full := d.full
605+ for _, r := range d.rects {
606+ if r.X <= 0 && r.Y <= 0 && r.X+r.W >= bw && r.Y+r.H >= bh {
607+ full = true
608+ }
609+ }
610+ arr := zerolog.Arr()
611+ for _, r := range d.rects[:min(len(d.rects), maxLoggedDamageRects)] {
612+ arr.Dict(zerolog.Dict().Int("x", r.X).Int("y", r.Y).Int("w", r.W).Int("h", r.H))
613+ }
614+ s.server.log.Debug().Uint64("window", uint64(s.root().windowID())).Int("bw", bw).Int("bh", bh).
615+ Int("rects", len(d.rects)).Bool("full_buffer", full).Array("first_rects", arr).Msg("client damage")
616+}
617+
618+// maxLoggedDamageRects bounds the rects one "client damage" entry lists.
619+const maxLoggedDamageRects = 4
620+
621+// mapDamage sets s.committed from the commit's requested damage.
622+func (s *surface) mapDamage(u *update, fresh, resized bool, bw, bh int) {
591623 surf, buf := u.damage, u.bufDamage
592624 if !fresh {
593625 s.committed = damage{}
M internal/adapters/wayland/surface_perf_test.go +5 -5
@@ -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()
@@ -48,7 +48,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
4848 })
4949 for _, count := range []int{16, 64} {
5050 t.Run(fmt.Sprintf("%d", count), func(t *testing.T) {
51- channels := Channels{Contents: make(chan ports.SurfaceContent, 1)}
51+ channels := Channels{}
5252 srv, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, channels, logging.For(context.Background(), "wayland"))
5353 if err != nil {
5454 t.Fatal(err)
@@ -64,7 +64,7 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
6464 publish := func() { root.redraw() }
6565 root.treeDirty = true
6666 publish()
67- first := srv.contents[1]
67+ first := srv.latest[1]
6868 publish()
6969 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 {
7070 t.Fatalf("previous publication changed: %+v", first)
@@ -99,10 +99,10 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
9999 root.sub.children[count-1].version++
100100 root.treeDirty = true
101101 publish()
102- if first.Children[count-1].Version != 0 || srv.contents[1].Children[count-1].Version != 1 {
102+ if first.Children[count-1].Version != 0 || srv.latest[1].Children[count-1].Version != 1 {
103103 t.Fatal("child version leaked into an earlier publication")
104104 }
105- if &first.Children[0] == &srv.contents[1].Children[0] {
105+ if &first.Children[0] == &srv.latest[1].Children[0] {
106106 t.Fatal("rebuild reused previously published children storage")
107107 }
108108 // Rebuilding a changed child's immutable snapshot must have a
M internal/adapters/wayland/syncobj.go +1 -1
@@ -219,7 +219,7 @@ type syncWaiter struct {
219219 // syncWait is one acquire point a queued commit waits for.
220220 type syncWait struct {
221221 efd int
222- done bool // the eventfd fired or the wait was cancelled (pacer, under Do)
222+ done bool // the eventfd fired or the wait was cancelled (display goroutine)
223223 failed bool // observation failed; done must not authorize publication
224224 cancelled atomic.Bool // run stops polling it and closes efd
225225 }
M internal/adapters/wayland/syncobj_test.go +6 -7
@@ -48,8 +48,8 @@ type syncHarness struct {
4848 s *Server
4949 c *wlturbo.Display
5050 dev *mocksyncobjDevice
51- contents chan ports.SurfaceContent
52- presented chan ports.OutputPresented
51+ contents <-chan ports.SurfaceContent
52+ presented ports.PresentationSink
5353 surf uint32
5454 syncSurf uint32
5555 timeline uint32
@@ -99,13 +99,12 @@ func newSyncHarness(t *testing.T) *syncHarness {
9999 dir := t.TempDir()
100100 events := make(chan ports.ClientEvent, 16)
101101 commands := make(chan ports.ClientCommand, 16)
102- h.contents = make(chan ports.SurfaceContent, 64)
103- h.presented = make(chan ports.OutputPresented, 16)
104102 sup := ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}}
105- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands, Contents: h.contents, Presented: h.presented}, logging.For(context.Background(), "wayland"))
103+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, DMABuf: sup, syncDev: h.dev}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
106104 if err != nil {
107105 t.Fatal(err)
108106 }
107+ h.contents = relayContents(t, s)
109108 ctx, cancel := context.WithCancel(context.Background())
110109 done := make(chan error, 1)
111110 go func() { done <- s.Run(ctx) }()
@@ -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 {
M internal/adapters/wayland/transform_test.go +2 -2
@@ -17,7 +17,7 @@ import (
1717 type transformClient struct {
1818 c *wlturbo.Display
1919 comp, surf, vp, bf uint32
20- contents chan ports.SurfaceContent
20+ contents <-chan ports.SurfaceContent
2121 }
2222
2323 func newTransformClient(t *testing.T) transformClient {
@@ -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
M internal/app/app.go +0 -25
@@ -138,31 +138,6 @@ 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- }
158- select {
159- case out <- p:
160- case <-ctx.Done():
161- return
162- }
163- }
164-}
165-
166141 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
167142 log := logging.For(ctx, "core")
168143 configLog := logging.For(ctx, "config")
M internal/app/capture_test.go +1 -1
@@ -144,7 +144,7 @@ func TestOutputStopWithFullReplies(t *testing.T) {
144144 replies := make(chan ports.CaptureDone, 1)
145145 replies <- ports.CaptureDone{}
146146 set := newOutputSet(ctx, replies)
147- set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
147+ set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
148148 <-ctx.Done()
149149 return nil
150150 })
A internal/app/contentattacher_mock_test.go +150 -0
@@ -0,0 +1,150 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockcontentAttacher creates a new instance of mockcontentAttacher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockcontentAttacher(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockcontentAttacher {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockcontentAttacher{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockcontentAttacher is an autogenerated mock type for the contentAttacher type
36+type mockcontentAttacher struct {
37+ mock.Mock
38+}
39+
40+type mockcontentAttacher_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockcontentAttacher) EXPECT() *mockcontentAttacher_Expecter {
45+ return &mockcontentAttacher_Expecter{mock: &_m.Mock}
46+}
47+
48+// AttachOutput provides a mock function for the type mockcontentAttacher
49+func (_mock *mockcontentAttacher) AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
50+ ret := _mock.Called(name)
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for AttachOutput")
54+ }
55+
56+ var r0 <-chan *ports.ContentBatch
57+ var r1 ports.ContentReturn
58+ if returnFunc, ok := ret.Get(0).(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn)); ok {
59+ return returnFunc(name)
60+ }
61+ if returnFunc, ok := ret.Get(0).(func(string) <-chan *ports.ContentBatch); ok {
62+ r0 = returnFunc(name)
63+ } else {
64+ if ret.Get(0) != nil {
65+ r0 = ret.Get(0).(<-chan *ports.ContentBatch)
66+ }
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(string) ports.ContentReturn); ok {
69+ r1 = returnFunc(name)
70+ } else {
71+ if ret.Get(1) != nil {
72+ r1 = ret.Get(1).(ports.ContentReturn)
73+ }
74+ }
75+ return r0, r1
76+}
77+
78+// mockcontentAttacher_AttachOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AttachOutput'
79+type mockcontentAttacher_AttachOutput_Call struct {
80+ *mock.Call
81+}
82+
83+// AttachOutput is a helper method to define mock.On call
84+// - name string
85+func (_e *mockcontentAttacher_Expecter) AttachOutput(name any) *mockcontentAttacher_AttachOutput_Call {
86+ return &mockcontentAttacher_AttachOutput_Call{Call: _e.mock.On("AttachOutput", name)}
87+}
88+
89+func (_c *mockcontentAttacher_AttachOutput_Call) Run(run func(name string)) *mockcontentAttacher_AttachOutput_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 string
92+ if args[0] != nil {
93+ arg0 = args[0].(string)
94+ }
95+ run(
96+ arg0,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockcontentAttacher_AttachOutput_Call) Return(contentBatchCh <-chan *ports.ContentBatch, contentReturn ports.ContentReturn) *mockcontentAttacher_AttachOutput_Call {
103+ _c.Call.Return(contentBatchCh, contentReturn)
104+ return _c
105+}
106+
107+func (_c *mockcontentAttacher_AttachOutput_Call) RunAndReturn(run func(name string) (<-chan *ports.ContentBatch, ports.ContentReturn)) *mockcontentAttacher_AttachOutput_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// DetachOutput provides a mock function for the type mockcontentAttacher
113+func (_mock *mockcontentAttacher) DetachOutput(feed ports.ContentReturn) {
114+ _mock.Called(feed)
115+ return
116+}
117+
118+// mockcontentAttacher_DetachOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetachOutput'
119+type mockcontentAttacher_DetachOutput_Call struct {
120+ *mock.Call
121+}
122+
123+// DetachOutput is a helper method to define mock.On call
124+// - feed ports.ContentReturn
125+func (_e *mockcontentAttacher_Expecter) DetachOutput(feed any) *mockcontentAttacher_DetachOutput_Call {
126+ return &mockcontentAttacher_DetachOutput_Call{Call: _e.mock.On("DetachOutput", feed)}
127+}
128+
129+func (_c *mockcontentAttacher_DetachOutput_Call) Run(run func(feed ports.ContentReturn)) *mockcontentAttacher_DetachOutput_Call {
130+ _c.Call.Run(func(args mock.Arguments) {
131+ var arg0 ports.ContentReturn
132+ if args[0] != nil {
133+ arg0 = args[0].(ports.ContentReturn)
134+ }
135+ run(
136+ arg0,
137+ )
138+ })
139+ return _c
140+}
141+
142+func (_c *mockcontentAttacher_DetachOutput_Call) Return() *mockcontentAttacher_DetachOutput_Call {
143+ _c.Call.Return()
144+ return _c
145+}
146+
147+func (_c *mockcontentAttacher_DetachOutput_Call) RunAndReturn(run func(feed ports.ContentReturn)) *mockcontentAttacher_DetachOutput_Call {
148+ _c.Run(run)
149+ return _c
150+}
A internal/app/contentattacher_test.go +102 -0
@@ -0,0 +1,102 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Wayland feeds a started output its contents: Attach comes after the
14+// output registered and got its security state, the output runs on what
15+// Attach returned, Detach of that same handle follows its end, and a
16+// restarted connector attaches again.
17+func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ set := newOutputSet(ctx, nil)
21+ events := make(chan ports.SecurityBackendEvent, 4)
22+ set.wireSecurity(outputChannels{securityEvents: events})
23+ attacher := newMockcontentAttacher(t)
24+ set.attach = attacher
25+ rets := []*portsmocks.MockContentReturn{portsmocks.NewMockContentReturn(t), portsmocks.NewMockContentReturn(t)}
26+ feed := make(chan *ports.ContentBatch, 1)
27+ var order []string
28+ type seen struct {
29+ contents <-chan *ports.ContentBatch
30+ returns ports.ContentReturn
31+ security ports.SecurityState
32+ }
33+ started := make(chan seen, 2)
34+ run := func(ctx context.Context, _ <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, _ ports.OutputInstance) error {
35+ started <- seen{contents, returns, <-security}
36+ <-ctx.Done()
37+ return nil
38+ }
39+ attaches := 0
40+ attacher.EXPECT().AttachOutput("DP-1").RunAndReturn(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
41+ order = append(order, "attach")
42+ ret := rets[attaches]
43+ attaches++
44+ // The output is registered with the security backend first.
45+ if len(events) != 1 {
46+ t.Errorf("attached with %d registrations queued, want 1", len(events))
47+ }
48+ return feed, ret
49+ }).Times(2)
50+ var detached []ports.ContentReturn
51+ attacher.EXPECT().DetachOutput(mock.Anything).Run(func(feed ports.ContentReturn) {
52+ order = append(order, "detach")
53+ detached = append(detached, feed)
54+ }).Times(2)
55+ for round := range 2 {
56+ if !set.start(ctx, "DP-1", run) {
57+ t.Fatal("start failed")
58+ }
59+ got := <-started
60+ if got.contents != (<-chan *ports.ContentBatch)(feed) || got.returns != ports.ContentReturn(rets[round]) {
61+ t.Fatalf("round %d: output got another feed", round)
62+ }
63+ <-events
64+ set.outs["DP-1"].stop()
65+ if err := set.finish("DP-1"); err != nil {
66+ t.Fatal(err)
67+ }
68+ }
69+ for round := range detached {
70+ if detached[round] != ports.ContentReturn(rets[round]) {
71+ t.Fatalf("round %d: detached another feed than the one attached", round)
72+ }
73+ }
74+ want := []string{"attach", "detach", "attach", "detach"}
75+ if len(order) != len(want) {
76+ t.Fatalf("order %v, want %v", order, want)
77+ }
78+ for i := range want {
79+ if order[i] != want[i] {
80+ t.Fatalf("order %v, want %v", order, want)
81+ }
82+ }
83+}
84+
85+// wait detaches the outputs it stops.
86+func TestOutputSetWaitDetachesContents(t *testing.T) {
87+ ctx, cancel := context.WithCancel(context.Background())
88+ defer cancel()
89+ set := newOutputSet(ctx, nil)
90+ attacher := newMockcontentAttacher(t)
91+ set.attach = attacher
92+ ret := portsmocks.NewMockContentReturn(t)
93+ attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once()
94+ attacher.EXPECT().DetachOutput(ports.ContentReturn(ret)).Return().Once()
95+ set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
96+ <-ctx.Done()
97+ return nil
98+ })
99+ if err := set.wait(); err != nil {
100+ t.Fatal(err)
101+ }
102+}
M internal/app/drm.go +4 -8
@@ -333,6 +333,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
333333 defer b.seat.Unsubscribe(active)
334334 seatActive := true // openDRM waits for the first seat enable
335335 set := newOutputSet(ctx, ch.captured)
336+ set.attach = ch.content
336337 set.wireSecurity(ch)
337338 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
338339 leases := newLeasePublisher(ctx, ch.leaseEvents, func() bool { return seatActive }, protected, log)
@@ -458,7 +459,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
458459 for _, o := range added {
459460 // Every physical lifetime gets the gate before any early exit:
460461 // Close must not restore saved desktop, even on a name collision.
461- o.Security = ch.security
462+ o.Security, o.Frames = ch.security, ch.frames
462463 name := o.Info().Name
463464 if other := cards[name]; other != nil && other != c {
464465 // Core and clients know outputs by connector name.
@@ -513,7 +514,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
513514 }
514515 active := b.seat.Subscribe()
515516 realtime := currentConfig.Performance.Realtime
516- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
517+ started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
517518 defer b.seat.Unsubscribe(active)
518519 o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
519520 return safe("output "+name, func() error {
@@ -533,7 +534,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
533534 schedLog.Warn().Err(err).Msg("output scheduling")
534535 }
535536 }
536- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
537+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, rc, cu, ch.presented, cap, ch.captured)
537538 })
538539 }, &o.SecurityEvents)
539540 if !started {
@@ -595,7 +596,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
595596 configs, configNext := apply.configOut(ch.configured), apply.config
596597 replies, replyNext := apply.replyOut(ch.replies)
597598 heads, headsNext := apply.headsOut(ch.heads)
598- contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
599599 select {
600600 case <-ctx.Done():
601601 return set.wait()
@@ -700,10 +700,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
700700 complete(progress.timeout(op))
701701 case s := <-ch.scenes:
702702 set.scenes(s)
703- case c := <-contents:
704- set.content(c)
705- case contentOut <- contentNext:
706- contentOwner.contentSent()
707703 case q := <-ch.captures:
708704 set.routeCapture(q)
709705 case c := <-ch.cursorChanges:
M internal/app/headless.go +4 -8
@@ -27,6 +27,7 @@ type headlessOptions struct {
2727 // newRenderer is configured for virtual outputs by newVulkanRenderer.
2828 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
2929 set := newOutputSet(ctx, ch.captured)
30+ set.attach = ch.content
3031 set.wireSecurity(ch)
3132 inventory := ports.OutputHeads{}
3233 for i, size := range o.sizes {
@@ -43,10 +44,10 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
4344 }
4445 cur := &headless.Cursor{}
4546 curs.set(name, cur)
46- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
47- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
47+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured}
48+ started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
4849 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
49- return headless.Run(octx, opts, sc, cc, cu, cap)
50+ return headless.Run(octx, opts, sc, cc, rc, cu, cap)
5051 }, &opts.SecurityEvents)
5152 if !started {
5253 return set.wait()
@@ -68,7 +69,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
6869 configs, configNext := apply.configOut(ch.configured), apply.config
6970 replies, replyNext := apply.replyOut(ch.replies)
7071 heads, headsNext := apply.headsOut(ch.heads)
71- contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
7272 select {
7373 case <-ctx.Done():
7474 return set.wait()
@@ -92,10 +92,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
9292 apply.headsSent()
9393 case s := <-ch.scenes:
9494 set.scenes(s)
95- case c := <-contents:
96- set.content(c)
97- case contentOut <- contentNext:
98- contentOwner.contentSent()
9995 case q := <-ch.captures:
10096 set.routeCapture(q)
10197 case c := <-ch.cursorChanges:
M internal/app/headless_test.go +1 -1
@@ -15,7 +15,7 @@ import (
1515 // and a headless output falls back to SDR. Skips without Vulkan or without a
1616 // device that exports dmabufs (lavapipe in CI).
1717 func TestVirtualRendererExportsHDRTargets(t *testing.T) {
18- r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16)
18+ r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false, false)(64, 16)
1919 if err != nil {
2020 t.Skipf("Vulkan unavailable: %v", err)
2121 }
M internal/app/outputrouting.go +5 -3
@@ -4,7 +4,8 @@ import "github.com/bnema/neferwl/internal/ports"
44
55 // outputChannels is the shared app-to-backend wiring. The backend loop remains
66 // the sole owner of outputSet and outputApply. Security producers use bounded
7-// lossless FIFO forwarders; content backpressure stays inside the backend select.
7+// lossless FIFO forwarders; contents are not routed here: each output is
8+// attached to wayland's content feed (contentAttacher).
89 // heads, configured and replies are fed from the outputApply outbox.
910 type outputChannels struct {
1011 security ports.SessionSecurity
@@ -14,9 +15,10 @@ type outputChannels struct {
1415 leaseEvents chan<- ports.LeaseMessage
1516 events chan<- ports.OutputEvent
1617 scenes <-chan []ports.Scene
17- contents <-chan ports.SurfaceContent
18+ content contentAttacher
1819 cursorChanges <-chan ports.CursorChange
19- presented chan<- ports.OutputPresented
20+ presented ports.PresentationSink
21+ frames chan<- ports.OutputFrame
2022 captures <-chan ports.CaptureRequest
2123 captured chan<- ports.CaptureDone
2224 formats chan<- ports.OutputFormats
M internal/app/outputrouting_test.go +2 -15
@@ -14,7 +14,7 @@ func TestOutputRouting(t *testing.T) {
1414 replies := make(chan ports.CaptureDone, 2)
1515 set := newOutputSet(ctx, replies)
1616 for _, name := range []string{"A", "B"} {
17- set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
17+ set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
1818 <-ctx.Done()
1919 return nil
2020 })
@@ -28,13 +28,6 @@ func TestOutputRouting(t *testing.T) {
2828 if got := <-set.outs["B"].scenes; got.Seq != 2 {
2929 t.Fatalf("B scene: %+v", got)
3030 }
31- content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
32- set.content(content)
33- for _, name := range []string{"A", "B"} {
34- if got := <-set.outs[name].contents; got.ID != content.ID {
35- t.Fatalf("%s content: %+v", name, got)
36- }
37- }
3831 set.setCursor(ports.CursorChange{Shape: "text"})
3932 set.setCursor(ports.CursorChange{Shape: "pointer"})
4033 for _, name := range []string{"A", "B"} {
@@ -86,13 +79,9 @@ func TestOutputSetRemembersOff(t *testing.T) {
8679
8780 func TestOutputRoutingAllocations(t *testing.T) {
8881 set := newOutputSet(context.Background(), nil)
89- // Populate the existing content slot before measuring, so the guard
90- // measures routing rather than first-use map growth.
91- content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
92- set.content(content)
9382 ctx, cancel := context.WithCancel(context.Background())
9483 defer cancel()
95- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
84+ set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
9685 <-ctx.Done()
9786 return nil
9887 })
@@ -101,8 +90,6 @@ func TestOutputRoutingAllocations(t *testing.T) {
10190 capture := ports.CaptureRequest{Output: "A"}
10291 if got := testing.AllocsPerRun(100, func() {
10392 set.scenes(scenes)
104- <-set.outs["A"].contents
105- set.content(content)
10693 set.setCursor(cursor)
10794 set.routeCapture(capture)
10895 <-set.outs["A"].captures
M internal/app/outputs.go +44 -64
@@ -13,16 +13,31 @@ import (
1313
1414 // outputRun drives one output until ctx ends: it renders the scenes and
1515 // surface contents it receives, with the cursor the client asks for.
16-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error
16+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error
17+
18+// contentAttacher connects an output to the surface contents: wayland feeds
19+// every attached output batches of window contents, starting with the latest
20+// content of each window, and takes the batches back through the returned
21+// ports.ContentReturn. AttachOutput may block on the display goroutine: call it
22+// from outside that goroutine. It returns a nil channel when wayland is gone.
23+// The returned ports.ContentReturn is also the handle of the feed: attaching
24+// a name that already has a feed replaces it, and DetachOutput(handle) stops
25+// that feed only, so a stale handle never stops a newer feed of the same
26+// name. Detach an output once it stopped.
27+type contentAttacher interface {
28+ AttachOutput(name string) (<-chan *ports.ContentBatch, ports.ContentReturn)
29+ DetachOutput(feed ports.ContentReturn)
30+}
1731
1832 // outputSet owns the running outputs and feeds them. Core sends one scene
19-// per output; each goes to its output, latest first. Surface contents go to
20-// every output, since windows move between outputs; the latest content of
21-// each window is kept so a new output starts with every window drawn.
33+// per output; each goes to its output, latest first. Surface contents reach
34+// every output (windows move between outputs) straight from wayland through
35+// attach, which also replays the current windows to a new output.
2236 // Only the goroutine running loop touches the set.
2337 type outputSet struct {
24- outs map[string]*runningOutput
25- latest map[ports.WindowID]ports.SurfaceContent
38+ outs map[string]*runningOutput
39+ // attach is nil when no wayland server feeds contents (tests).
40+ attach contentAttacher
2641 // off is the latest Scene.Off per output name, kept after the output
2742 // goes: it reconnects off until core's first scene (Output.StartOff).
2843 off map[string]bool
@@ -50,22 +65,23 @@ type runningOutput struct {
5065 instance ports.OutputInstance
5166 security chan ports.SecurityState
5267 securityEvents chan ports.SecurityBackendEvent
53- pending []ports.SurfaceContent
5468 scenes chan ports.Scene
55- contents chan ports.SurfaceContent
5669 cursor chan ports.CursorChange
5770 captures chan ports.CaptureRequest
5871 ctx context.Context
5972 stop context.CancelFunc
6073 err error
6174 done chan struct{}
75+ // feed is the content feed AttachOutput returned (nil: none), the handle
76+ // to detach once the output stopped.
77+ feed ports.ContentReturn
6278 }
6379
6480 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
65- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
81+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
6682 }
6783
68-// start runs an output in its own goroutine and replays the window contents.
84+// start runs an output in its own goroutine, fed the window contents.
6985 func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire ...*chan<- ports.SecurityBackendEvent) bool {
7086 // Exhaustion is permanent: never wrap to zero or reuse a lifetime.
7187 if s.nextInstance == ^ports.OutputInstance(0) {
@@ -82,22 +98,22 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire
8298 s.setSecurity(s.security.Snapshot())
8399 }
84100 octx, stop := context.WithCancel(ctx)
85- r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
86- r.security <- s.state // available before any old scene/content replay
101+ r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
102+ r.security <- s.state // available before any content replay
87103 r.securityEvents = s.forwardSecurity()
88104 for _, destination := range wire {
89105 *destination = r.securityEvents
90106 }
91- for _, c := range s.latest {
92- select {
93- case r.contents <- c:
94- default:
95- r.pending = append(r.pending, c)
96- }
107+ // After the security state above: the replay of the windows starts here.
108+ var contents <-chan *ports.ContentBatch
109+ var returns ports.ContentReturn
110+ if s.attach != nil {
111+ contents, returns = s.attach.AttachOutput(name)
97112 }
113+ r.feed = returns
98114 s.outs[name] = r
99115 go func() {
100- r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures, r.security, r.instance)
116+ r.err = run(octx, r.scenes, contents, returns, r.cursor, r.captures, r.security, r.instance)
101117 if r.securityEvents != nil {
102118 close(r.securityEvents)
103119 }
@@ -229,51 +245,6 @@ func (s *outputSet) scenes(set []ports.Scene) {
229245 }
230246 }
231247
232-func (s *outputSet) content(c ports.SurfaceContent) {
233- if c.Empty() {
234- delete(s.latest, c.ID)
235- } else {
236- s.latest[c.ID] = c
237- }
238- for _, r := range s.outs {
239- if r.ctx.Err() != nil {
240- continue
241- }
242- if len(r.pending) == 0 {
243- select {
244- case r.contents <- c:
245- continue
246- default:
247- }
248- }
249- // The backend disabled intake until the preceding broadcast drained.
250- if cap(r.pending) == 0 {
251- r.pending = make([]ports.SurfaceContent, 0, 1)
252- }
253- r.pending = append(r.pending, c)
254- }
255-}
256-
257-// At most one admitted broadcast waits behind each owner's finite replay.
258-// Disable new content intake until all copies have been delivered.
259-func (s *outputSet) contentOut(incoming <-chan ports.SurfaceContent) (<-chan ports.SurfaceContent, chan<- ports.SurfaceContent, ports.SurfaceContent, *runningOutput) {
260- for _, r := range s.outs {
261- if len(r.pending) != 0 && r.ctx.Err() == nil {
262- return nil, r.contents, r.pending[0], r
263- }
264- }
265- return incoming, nil, ports.SurfaceContent{}, nil
266-}
267-
268-func (r *runningOutput) contentSent() {
269- r.pending[0] = ports.SurfaceContent{}
270- if len(r.pending) == 1 {
271- r.pending = r.pending[:0] // retain the one-slot broadcast buffer
272- } else {
273- r.pending = r.pending[1:]
274- }
275-}
276-
277248 // finish forgets a stopped output and returns its error.
278249 func (s *outputSet) finish(name string) error {
279250 r := s.outs[name]
@@ -283,10 +254,18 @@ func (s *outputSet) finish(name string) error {
283254 <-r.done
284255 r.stop()
285256 s.failQueued(r)
257+ s.detach(r)
286258 delete(s.outs, name)
287259 return r.err
288260 }
289261
262+// detach stops the content feed of an output that has stopped.
263+func (s *outputSet) detach(r *runningOutput) {
264+ if s.attach != nil && r.feed != nil {
265+ s.attach.DetachOutput(r.feed)
266+ }
267+}
268+
290269 // wait stops every output and returns the first error.
291270 func (s *outputSet) wait() error {
292271 var first error = s.startErr
@@ -301,6 +280,7 @@ func (s *outputSet) wait() error {
301280 r.stop()
302281 <-r.done
303282 s.failQueued(r)
283+ s.detach(r)
304284 if first == nil && r.err != nil {
305285 first = r.err
306286 }
M internal/app/outputs_test.go +1 -1
@@ -17,7 +17,7 @@ func TestOutputSetCursor(t *testing.T) {
1717 run := func(name string) outputRun {
1818 got[name] = make(chan ports.CursorChange, 8)
1919 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
2121 for {
2222 select {
2323 case <-ctx.Done():
M internal/app/security_duplicate_test.go +1 -1
@@ -27,7 +27,7 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T
2727 events := make(chan ports.SecurityBackendEvent, 4)
2828 set := newOutputSet(context.Background(), nil)
2929 set.wireSecurity(outputChannels{security: gate, securityEvents: events})
30- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
30+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
3131 <-ctx.Done()
3232 return nil
3333 }
M internal/app/security_hotplug_test.go +4 -4
@@ -66,7 +66,7 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
6666 commands := make(chan []ports.SecurityBackendEvent, 1)
6767 closed := make(chan bool, 1)
6868 var ownerEvents chan<- ports.SecurityBackendEvent
69- if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
69+ if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
7070 defer func() { closed <- closeSuccess }()
7171 state := <-security
7272 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
@@ -205,7 +205,7 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
205205 startDone := make(chan bool, 1)
206206 var ownerEvents chan<- ports.SecurityBackendEvent
207207 go func() {
208- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
208+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
209209 for i := range 40 {
210210 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
211211 if i%2 == 0 {
@@ -295,7 +295,7 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
295295 startDone := make(chan bool, 1)
296296 var ownerEvents chan<- ports.SecurityBackendEvent
297297 go func() {
298- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
298+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
299299 for i := range 16 {
300300 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
301301 select {
@@ -350,7 +350,7 @@ func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
350350 startDone := make(chan bool, 1)
351351 var ownerEvents chan<- ports.SecurityBackendEvent
352352 go func() {
353- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
353+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
354354 defer close(ownerDone)
355355 for i := range 40 {
356356 select {
M internal/app/security_test.go +8 -69
@@ -21,7 +21,7 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) {
2121 started := make(chan ports.OutputInstance, 1)
2222 result := make(chan bool, 1)
2323 go func() {
24- result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
24+ result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
2525 state := <-security
2626 if state != (ports.SecurityState{Generation: 7, Protected: true}) {
2727 return errors.New("missing startup protection")
@@ -67,7 +67,7 @@ func TestSecurityReplacementAndStaleState(t *testing.T) {
6767 events := make(chan ports.SecurityBackendEvent, 4)
6868 set := newOutputSet(ctx, nil)
6969 set.wireSecurity(outputChannels{securityEvents: events})
70- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
70+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
7171 <-ctx.Done()
7272 return nil
7373 }
@@ -167,7 +167,7 @@ func TestSecurityInstanceExhaustion(t *testing.T) {
167167 events := make(chan ports.SecurityBackendEvent, 2)
168168 set.wireSecurity(outputChannels{securityEvents: events})
169169 set.nextInstance = ^ports.OutputInstance(0) - 1
170- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
170+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
171171 <-ctx.Done()
172172 return nil
173173 }
@@ -195,26 +195,22 @@ func TestSecurityInstanceExhaustion(t *testing.T) {
195195 }
196196 }
197197
198-// The shared display event channel and an output content queue can both be
199-// saturated. Routing must retain the next content without pinning the backend
200-// outside its select; a lease barrier can then finish before either is drained.
201-func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
198+// A saturated shared display event channel must not pin the backend outside
199+// its select: a lease barrier can finish before the channel is drained.
200+func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) {
202201 ctx, cancel := context.WithCancel(context.Background())
203202 defer cancel()
204203 set := newOutputSet(ctx, nil)
205204 events := make(chan ports.SecurityBackendEvent, 1)
206205 set.wireSecurity(outputChannels{securityEvents: events})
207206 var ownerEvents chan<- ports.SecurityBackendEvent
208- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
207+ set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
209208 <-ctx.Done()
210209 return nil
211210 }, &ownerEvents)
212211 <-events // ordered registration
213212 events <- ports.SecurityBackendBarrier{Generation: 99} // blocked display
214213 r := set.outs["DP-1"]
215- for i := range cap(r.contents) {
216- r.contents <- ports.SurfaceContent{ID: ports.WindowID(i + 1)}
217- }
218214 first := ports.SecurityOutputInvalidated{Generation: 1, Instance: r.instance}
219215 second := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: 1, Instance: r.instance, Kind: ports.ProtectionProtectedFrame}}
220216 ownerEvents <- first
@@ -225,7 +221,6 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
225221 card.EXPECT().Revoke(uint32(9)).Return(nil).Once()
226222 progressed := make(chan error, 1)
227223 go func() {
228- set.content(ports.SurfaceContent{ID: 70})
229224 set.setSecurity(ports.SecurityState{Generation: 1, Protected: true})
230225 err := revokeSecurityLeases(card, func(uint32) {})
231226 set.securityEvent(ports.SecurityBackendBarrier{Generation: 1, Err: err})
@@ -237,23 +232,7 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
237232 t.Fatal(err)
238233 }
239234 case <-time.After(time.Second):
240- t.Fatal("content/event backpressure blocked lease transition")
241- }
242- incoming := make(chan ports.SurfaceContent)
243- contentIn, output, next, owner := set.contentOut(incoming)
244- if contentIn != nil || output != r.contents || owner != r || next.ID != 70 || len(r.pending) != 1 {
245- t.Fatal("pending content not bounded/preserved")
246- }
247- for range cap(r.contents) {
248- <-r.contents
249- }
250- output <- next
251- owner.contentSent()
252- if got := <-r.contents; got.ID != 70 {
253- t.Fatal("lost pending content")
254- }
255- if contentIn, _, _, _ := set.contentOut(incoming); contentIn != incoming {
256- t.Fatal("content intake stayed disabled")
235+ t.Fatal("event backpressure blocked lease transition")
257236 }
258237 <-events // unblock display
259238 var ownerSequence []ports.SecurityBackendEvent
@@ -275,43 +254,3 @@ func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
275254 t.Fatal(err)
276255 }
277256 }
278-
279-func TestSecurityLargeReplayDoesNotBlockStart(t *testing.T) {
280- set := newOutputSet(context.Background(), nil)
281- for i := range 100 {
282- set.content(ports.SurfaceContent{ID: ports.WindowID(i + 1), SHM: &ports.SHMBuffer{}})
283- }
284- if !set.start(context.Background(), "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
285- <-ctx.Done()
286- return nil
287- }) {
288- t.Fatal("start failed")
289- }
290- r := set.outs["A"]
291- if len(r.contents) != cap(r.contents) || len(r.pending) != 100-cap(r.contents) {
292- t.Fatal("replay not preserved")
293- }
294- seen := map[ports.WindowID]bool{}
295- for len(seen) != 100 {
296- select {
297- case c := <-r.contents:
298- if seen[c.ID] {
299- t.Fatal("duplicate replay")
300- }
301- seen[c.ID] = true
302- default:
303- _, out, c, owner := set.contentOut(nil)
304- if out == nil {
305- t.Fatal("replay lost")
306- }
307- out <- c
308- owner.contentSent()
309- }
310- }
311- if len(r.pending) != 0 {
312- t.Fatal("replay remains")
313- }
314- if err := set.wait(); err != nil {
315- t.Fatal(err)
316- }
317-}
M internal/app/session.go +9 -7
@@ -123,6 +123,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
123123 Keymap: s.km.String(),
124124 RepeatRate: opts.Config.Keyboard.RepeatRate,
125125 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
126+ LogDamage: logging.Enabled(ctx, "wayland-damage"),
126127 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
127128 return s, err
128129 }
@@ -145,10 +146,11 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
145146 s.path = config.DefaultPath()
146147 }
147148 renderLog := logging.For(ctx, "render")
149+ traceDamage := logging.Enabled(ctx, "render-damage")
148150 if s.hw != nil {
149- s.newRenderer = newVulkanRenderer(renderLog, false, false)
151+ s.newRenderer = newVulkanRenderer(renderLog, false, false, traceDamage)
150152 } else {
151- s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw)
153+ s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw, traceDamage)
152154 }
153155 return s, nil
154156 }
@@ -182,14 +184,16 @@ func (s *session) close() {
182184 // outputs have no display to list modifiers, so a headless renderer is told
183185 // it drives one: without it a real GPU refuses the HDR targets and the
184186 // output falls back to SDR. readback makes the HDR targets readable
185-// (HDRPixels). These settings are Vulkan-only and not on ports.Renderer.
186-func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) {
187+// (HDRPixels). traceDamage logs every frame's redrawn area. These settings
188+// are Vulkan-only and not on ports.Renderer.
189+func newVulkanRenderer(log zerowrap.Logger, virtual, readback, traceDamage bool) func(w, h int) (ports.Renderer, error) {
187190 return func(w, h int) (ports.Renderer, error) {
188191 r, err := vulkan.New(w, h)
189192 if err != nil {
190193 return nil, err
191194 }
192195 r.SetLogger(log)
196+ r.SetTraceDamage(traceDamage)
193197 log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
194198 r.SetVirtualOutput(virtual)
195199 if readback {
@@ -347,12 +351,10 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
347351 defer workers.Done()
348352 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
349353 }()
350- workers.Add(1)
351- go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
352354
353355 go func() {
354356 defer workers.Done()
355- outputIO := p.outputs(security)
357+ outputIO := p.outputs(security, server.Presentation(), server)
356358 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
357359 if hw != nil {
358360 done <- safe("output", func() error {
M internal/app/wiring.go +8 -15
@@ -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+ // Outputs report to wayland through its presentation sink and, for each
45+ // flip of an animating scene (Scene.Animating), tell core (frames) to
46+ // move running slides.
5047 frames chan ports.OutputFrame
5148
5249 captures chan ports.CaptureRequest
@@ -94,10 +91,7 @@ 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+ frames: make(chan ports.OutputFrame, 8),
10195
10296 captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
10397 captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
@@ -147,9 +141,7 @@ func (p *pipes) wayland() wayland.Channels {
147141 Events: p.client,
148142 Commands: p.commands,
149143 Workspaces: p.workspaces,
150- Contents: p.contents,
151144 Cursors: p.cursorChanges,
152- Presented: p.presented,
153145 Captures: p.captures,
154146 Captured: p.captured,
155147 OutputFormats: p.outputFormats,
@@ -164,16 +156,17 @@ func (p *pipes) wayland() wayland.Channels {
164156 }
165157
166158 // outputs is the outputs' side (DRM or headless).
167-func (p *pipes) outputs(security ports.SessionSecurity) outputChannels {
159+func (p *pipes) outputs(security ports.SessionSecurity, presented ports.PresentationSink, content contentAttacher) outputChannels {
168160 return outputChannels{
169161 security: security,
170162 securityChanges: p.securityChanges,
171163 securityEvents: p.securityEvents,
172164 events: p.output,
173165 scenes: p.renderScenes,
174- contents: p.contents,
175166 cursorChanges: p.cursorChanges,
176- presented: p.flips,
167+ presented: presented,
168+ content: content,
169+ frames: p.frames,
177170 captures: p.captures,
178171 captured: p.captured,
179172 formats: p.outputFormats,
M internal/cli/run.go +1 -1
@@ -39,7 +39,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
3939 fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
4040 fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
4141 fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42- fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
42+ fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
4343 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
4444 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
4545 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
M internal/core/core.go +17 -4
@@ -43,8 +43,9 @@ type Channels struct {
4343 // Scales, when set, receives each scale a scale bind sets, to persist it.
4444 // Core does not wait: a full channel drops the change.
4545 Scales chan<- ports.ScaleChanged
46- // Frames, when set, reports outputs' page flips: a running slide moves
47- // one step per flip. Without flips it moves on a timer.
46+ // Frames, when set, carries the outputs' reports of each flip of an
47+ // animating scene (Scene.Animating): a running slide moves one step per
48+ // report. Without them it moves on a timer.
4849 Frames <-chan ports.OutputFrame
4950 }
5051
@@ -650,13 +651,25 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
650651 return err
651652 }
652653 drawable = drawable || shown
653- sc.last = scene
654654 scenes = append(scenes, scene)
655655 }
656656 c.configures.prune()
657657 // A pulse nothing shows (fullscreen, overview) asks for no frames.
658- if c.pulse.drawable = drawable; !drawable {
658+ if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 {
659659 c.pulse.id = 0
660+ // The focused scene was built as animating: publish it settled,
661+ // or its flips would keep waking core. A scene that kept its Seq
662+ // needs a fresh one, or outputs keep the animating one they show.
663+ if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() {
664+ scenes[i].Animating = false
665+ if scenes[i].Seq == c.cur().last.Seq {
666+ c.seq++
667+ scenes[i].Seq = c.seq
668+ }
669+ }
670+ }
671+ for i, sc := range c.screens {
672+ sc.last = scenes[i]
660673 }
661674 // A workspace switch can hide the window under the pointer; it must not get
662675 // clicks. The next motion re-runs hit-testing.
M internal/core/scene.go +10 -5
@@ -123,16 +123,21 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
123123 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
124124 scene.CaptureIndicators = c.captureIndicators(sc)
125125 scene.Capture = c.captureSceneFor(sc, capture)
126+ // The springs of this frame are advanced: a scene shown while one still
127+ // runs wakes core when it flips; the one a spring settles on does not.
128+ scene.Animating = c.sliding(sc.name())
126129 // A scene carrying a capture image always gets a fresh Seq, and so
127130 // does one of an animating screen (the one being stepped, or any
128131 // when none is): the outputs drop a scene they already show, and a
129132 // spring that starts at the previous scene, or rounds to it in its
130- // tail, would get no flip and wait for the fallback timer. Any other
131- // keeps its output's Seq while it draws the same, so an idle output
132- // is not recomposed.
133+ // tail, would get no flip and wait for the fallback timer. So does the
134+ // scene that flips Animating off: SameAs ignores the flag, and keeping
135+ // the Seq of the animating scene would leave outputs showing it, and
136+ // waking core on every later flip. Any other keeps its output's Seq
137+ // while it draws the same, so an idle output is not recomposed.
133138 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
134- animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)
135- if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
139+ animating := scene.Animating && (only == nil || only == sc)
140+ if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
136141 scene.Seq = sc.last.Seq
137142 } else {
138143 c.seq++
A internal/core/scene_animating_test.go +152 -0
@@ -0,0 +1,152 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// A scene published while a spring runs carries Animating, so its flip wakes
12+// core for the next frame; the scene the spring settles on does not, and
13+// neither does any scene of an idle output.
14+func TestSceneAnimatingFollowsSprings(t *testing.T) {
15+ c, ic := indicatorCore(t)
16+ c.cfg.Animations.On = true
17+ a := c.cur()
18+ a.mon.AddWindow(1)
19+ a.mon.AddWindow(2)
20+ if s := indicatorScene(t, c); s.Animating {
21+ t.Fatal("an idle output publishes an animating scene")
22+ }
23+
24+ // A slide starts.
25+ w := a.mon.Current()
26+ w.view.off = 100
27+ w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
28+ ic.now = ic.now.Add(10 * time.Millisecond)
29+ first := stepScenes(t, c, a)["A"]
30+ if !first.Animating {
31+ t.Fatal("a scene with a running spring is not animating")
32+ }
33+ ic.now = ic.now.Add(10 * time.Millisecond)
34+ if second := stepScenes(t, c, a)["A"]; !second.Animating || second.Seq == first.Seq {
35+ t.Fatalf("running slide: animating=%v Seq %d (was %d)", second.Animating, second.Seq, first.Seq)
36+ }
37+
38+ // The step that lands the spring publishes the last scene: nothing more
39+ // is due, so it must not wake core again.
40+ ic.now = ic.now.Add(10 * time.Second)
41+ last := stepScenes(t, c, a)["A"]
42+ if last.Animating {
43+ t.Fatal("the scene a spring settles on is animating")
44+ }
45+ if a.springing() {
46+ t.Fatal("bad setup: the spring still runs")
47+ }
48+ // Idle again: still false, and the Seq is held (no extra recomposition).
49+ if err := c.publish(context.Background()); err != nil {
50+ t.Fatal(err)
51+ }
52+ idle := (<-c.ch.Scenes)[0]
53+ if idle.Animating || idle.Seq != last.Seq || !idle.SameAs(last) {
54+ t.Fatalf("idle publish: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, last.Seq)
55+ }
56+}
57+
58+// A pulse cancelled because nothing can show it (fullscreen) publishes the
59+// focused scene settled, with a fresh Seq: otherwise outputs keep the
60+// animating scene and wake core on every flip.
61+func TestCancelledPulseClearsAnimating(t *testing.T) {
62+ c, ic := pulseCore(t)
63+ c.cur().mon.Current().FocusID(1)
64+ indicatorScene(t, c)
65+ ic.now = ic.now.Add(pulseSettle)
66+ if !c.pulseTick() {
67+ t.Fatal("bad setup: no pulse")
68+ }
69+ running := indicatorScene(t, c)
70+ if !running.Animating {
71+ t.Fatal("bad setup: the pulsing scene is not animating")
72+ }
73+ c.cur().mon.ToggleFullscreen()
74+ cancelled := indicatorScene(t, c)
75+ if cancelled.Animating || c.animating() {
76+ t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating())
77+ }
78+ if cancelled.Seq == running.Seq {
79+ t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq)
80+ }
81+ idle := indicatorScene(t, c)
82+ if idle.Animating || idle.Seq != cancelled.Seq {
83+ t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq)
84+ }
85+}
86+
87+// Animating is per output: a spring on one does not mark the other.
88+func TestSceneAnimatingIsPerOutput(t *testing.T) {
89+ c, ic := indicatorCore(t)
90+ c.cfg.Animations.On = true
91+ a := c.cur()
92+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
93+ b := c.screens[1]
94+ a.mon.AddWindow(1)
95+ b.mon.AddWindow(2)
96+ indicatorScene(t, c)
97+ w := b.mon.Current()
98+ w.view.off = 100
99+ w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
100+ ic.now = ic.now.Add(10 * time.Millisecond)
101+ got := stepScenes(t, c, b)
102+ if !got["B"].Animating || got["A"].Animating {
103+ t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating)
104+ }
105+}
106+
107+// A spring that settles on the very scene its last running step drew (the
108+// tail rounds to the final pixel) must still publish a scene with Animating
109+// off, with a fresh Seq: SameAs ignores the flag, and the outputs would
110+// otherwise keep the animating scene they show and wake core on every later
111+// flip.
112+func TestSettledSceneSameAsPreviousClearsAnimating(t *testing.T) {
113+ // Commands (rehit) are not read: room for every step.
114+ c, ic := indicatorCoreWith(t, make(chan ports.ClientCommand, 4096))
115+ c.cfg.Animations.On = true
116+ a := c.cur()
117+ a.mon.AddWindow(1)
118+ a.mon.AddWindow(2)
119+ indicatorScene(t, c)
120+
121+ w := a.mon.Current()
122+ w.view.off = 100
123+ w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
124+ // Step in 1 ms until the spring settles: the snap settles it once it is
125+ // within half a pixel of its target, where the last running scene can
126+ // draw the same pixels as the settled one.
127+ start := ic.now
128+ var running, settled ports.Scene
129+ for ms := 1; ; ms++ {
130+ if ms > 2000 {
131+ t.Fatal("the spring never settles")
132+ }
133+ ic.now = start.Add(time.Duration(ms) * time.Millisecond)
134+ settled = stepScenes(t, c, a)["A"]
135+ if !a.springing() {
136+ break
137+ }
138+ running = settled
139+ }
140+ if !running.Animating {
141+ t.Fatalf("bad setup: last running scene animating=%v", running.Animating)
142+ }
143+ if !settled.SameAs(running) {
144+ t.Fatal("bad setup: the settled scene differs from the last running one")
145+ }
146+ if settled.Animating {
147+ t.Fatal("the settled scene is animating")
148+ }
149+ if settled.Seq == running.Seq {
150+ t.Fatalf("settled scene kept the animating scene's Seq %d", running.Seq)
151+ }
152+}
A internal/mocks/ports/output_events_mock.go +86 -0
@@ -0,0 +1,86 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockPresentationSink creates a new instance of MockPresentationSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockPresentationSink(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockPresentationSink {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &MockPresentationSink{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// MockPresentationSink is an autogenerated mock type for the PresentationSink type
36+type MockPresentationSink struct {
37+ mock.Mock
38+}
39+
40+type MockPresentationSink_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *MockPresentationSink) EXPECT() *MockPresentationSink_Expecter {
45+ return &MockPresentationSink_Expecter{mock: &_m.Mock}
46+}
47+
48+// Push provides a mock function for the type MockPresentationSink
49+func (_mock *MockPresentationSink) Push(outputPresented ports.OutputPresented) {
50+ _mock.Called(outputPresented)
51+ return
52+}
53+
54+// MockPresentationSink_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
55+type MockPresentationSink_Push_Call struct {
56+ *mock.Call
57+}
58+
59+// Push is a helper method to define mock.On call
60+// - outputPresented ports.OutputPresented
61+func (_e *MockPresentationSink_Expecter) Push(outputPresented any) *MockPresentationSink_Push_Call {
62+ return &MockPresentationSink_Push_Call{Call: _e.mock.On("Push", outputPresented)}
63+}
64+
65+func (_c *MockPresentationSink_Push_Call) Run(run func(outputPresented ports.OutputPresented)) *MockPresentationSink_Push_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 ports.OutputPresented
68+ if args[0] != nil {
69+ arg0 = args[0].(ports.OutputPresented)
70+ }
71+ run(
72+ arg0,
73+ )
74+ })
75+ return _c
76+}
77+
78+func (_c *MockPresentationSink_Push_Call) Return() *MockPresentationSink_Push_Call {
79+ _c.Call.Return()
80+ return _c
81+}
82+
83+func (_c *MockPresentationSink_Push_Call) RunAndReturn(run func(outputPresented ports.OutputPresented)) *MockPresentationSink_Push_Call {
84+ _c.Run(run)
85+ return _c
86+}
A internal/mocks/ports/surface_content_mock.go +86 -0
@@ -0,0 +1,86 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockContentReturn creates a new instance of MockContentReturn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockContentReturn(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockContentReturn {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &MockContentReturn{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// MockContentReturn is an autogenerated mock type for the ContentReturn type
36+type MockContentReturn struct {
37+ mock.Mock
38+}
39+
40+type MockContentReturn_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *MockContentReturn) EXPECT() *MockContentReturn_Expecter {
45+ return &MockContentReturn_Expecter{mock: &_m.Mock}
46+}
47+
48+// Return provides a mock function for the type MockContentReturn
49+func (_mock *MockContentReturn) Return(contentBatch *ports.ContentBatch) {
50+ _mock.Called(contentBatch)
51+ return
52+}
53+
54+// MockContentReturn_Return_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Return'
55+type MockContentReturn_Return_Call struct {
56+ *mock.Call
57+}
58+
59+// Return is a helper method to define mock.On call
60+// - contentBatch *ports.ContentBatch
61+func (_e *MockContentReturn_Expecter) Return(contentBatch any) *MockContentReturn_Return_Call {
62+ return &MockContentReturn_Return_Call{Call: _e.mock.On("Return", contentBatch)}
63+}
64+
65+func (_c *MockContentReturn_Return_Call) Run(run func(contentBatch *ports.ContentBatch)) *MockContentReturn_Return_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 *ports.ContentBatch
68+ if args[0] != nil {
69+ arg0 = args[0].(*ports.ContentBatch)
70+ }
71+ run(
72+ arg0,
73+ )
74+ })
75+ return _c
76+}
77+
78+func (_c *MockContentReturn_Return_Call) Return() *MockContentReturn_Return_Call {
79+ _c.Call.Return()
80+ return _c
81+}
82+
83+func (_c *MockContentReturn_Return_Call) RunAndReturn(run func(contentBatch *ports.ContentBatch)) *MockContentReturn_Return_Call {
84+ _c.Run(run)
85+ return _c
86+}
M internal/ports/output_events.go +10 -2
@@ -94,6 +94,13 @@ type OutputPresented struct {
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. After Push the
99+// report belongs to the sink: its maps are read-only snapshots, and Flip must
100+// be fresh for every push, since the sink may update Merged and replace Shows
101+// when it merges flips.
102+type PresentationSink interface{ Push(OutputPresented) }
103+
97104 // OutputFormats carries output → wayland the dmabuf formats an output can
98105 // scan out directly (its primary plane's, that the renderer also samples,
99106 // so a refused buffer can still be composed). Device is the KMS device
@@ -133,8 +140,9 @@ type FlipInfo struct {
133140 Shows map[WindowID]uint64
134141 }
135142
136-// OutputFrame carries output → core a completed page flip: the next frame
137-// of a running animation is due.
143+// OutputFrame carries output → core a completed flip of an animating scene
144+// (Scene.Animating): the next frame is due. Sends never block; a full
145+// channel drops it.
138146 type OutputFrame struct{ Output string }
139147
140148 // OutputRemoved carries output → core an unplugged display.
M internal/ports/scene.go +11 -4
@@ -69,13 +69,20 @@ type Scene struct {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 CaptureIndicators []CaptureIndicator
72+ // Animating is set by core when a spring or the focus pulse runs on
73+ // this output after this frame was computed: the output reports the
74+ // flip of the scene to core (OutputFrame), which moves the animation to
75+ // the next frame. A scene of a settled output carries false, so a flip
76+ // of it wakes nobody. It describes scheduling, not drawing: SameAs and
77+ // the renderer ignore it.
78+ Animating bool
7279 }
7380
7481 // SameAs reports whether o draws exactly what s draws: every field other
75-// than Seq is equal. A scene carrying a CaptureScene is never the same
76-// (conservative: the capture image is not compared). Add every new Scene
77-// field here; TestSceneSameAsCoversEveryField fails until the field count
78-// is updated.
82+// than Seq and Animating (scheduling only, not drawn) is equal. A scene
83+// carrying a CaptureScene is never the same (conservative: the capture image
84+// is not compared). Add every new Scene field here;
85+// TestSceneSameAsCoversEveryField fails until the field count is updated.
7986 func (s Scene) SameAs(o Scene) bool {
8087 if s.Security != o.Security || s.Output != o.Output ||
8188 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
M internal/ports/scene_same_test.go +3 -1
@@ -83,7 +83,9 @@ func TestSceneSameAs(t *testing.T) {
8383 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
8484 // update Scene.SameAs, then this count.
8585 func TestSceneSameAsCoversEveryField(t *testing.T) {
86- const fields = 21
86+ // 22 with Animating, which SameAs intentionally does not compare: it
87+ // only schedules the next frame, it draws nothing.
88+ const fields = 22
8789 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
8890 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
8991 }
M internal/ports/surface_content.go +24 -0
@@ -108,6 +108,30 @@ type SurfaceContent struct {
108108 DamageHistory []SeqDamage
109109 }
110110
111+// ContentBatch is the window contents wayland hands to one output: at most
112+// one per window, the latest published. A content superseded before the
113+// output took the batch is overwritten in place; contents carry the window's
114+// Seq, so the release accounting needs only the newest one.
115+//
116+// Ownership moves with the batch: the sender must not touch it after the
117+// send, and the receiver resets it and gives it back through ContentReturn.
118+// Items is a slice, not a map: SurfaceContent is large, and a map would
119+// allocate its elements again after every clear.
120+type ContentBatch struct {
121+ Items []SurfaceContent
122+}
123+
124+// Reset empties the batch, dropping its references to buffers, and keeps its
125+// capacity for reuse.
126+func (b *ContentBatch) Reset() {
127+ clear(b.Items)
128+ b.Items = b.Items[:0]
129+}
130+
131+// ContentReturn gives a consumed batch back to wayland. Return never blocks
132+// and is safe after the output was detached.
133+type ContentReturn interface{ Return(*ContentBatch) }
134+
111135 // BufferTransform is a wl_output.transform value: bit 0 rotates 90°
112136 // counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
113137 // axis first.
A tools/bench/README.md +10 -0
@@ -0,0 +1,10 @@
1+# Headless frame-cost bench
2+
3+`bench.sh` measures NeferWL's CPU time and voluntary context switches per presented frame on a 5120x2160 headless output while chromium animates a small part of a page.
4+
5+Requires `go`, `chromium` and `python3`. Frames are counted from the renderer's `frame` debug entries, which the script turns on.
6+
7+ tools/bench/bench.sh spin60.html 15 # CSS spinner, 60 fps
8+ tools/bench/bench.sh gif.html 15 # 32x32 GIF, 20 fps
9+
10+Environment: `BENCH_X11=1` runs chromium through XWayland, `BENCH_DEBUG=<categories>` adds `--debug` categories, `BENCH_KEEP=1` leaves the processes running, `BENCH_PPROF=<addr>` sets the pprof address (default `localhost:6061`).
A tools/bench/bench.sh +90 -0
@@ -0,0 +1,90 @@
1+#!/usr/bin/env bash
2+# Measures NeferWL's CPU cost per presented frame on the headless backend
3+# while chromium animates a small region of a page.
4+#
5+# usage: tools/bench/bench.sh <page.html> <seconds>
6+# env: BENCH_X11=1 run chromium through XWayland instead of Wayland
7+# BENCH_DEBUG=cat extra --debug categories (comma separated)
8+# BENCH_KEEP=1 leave neferwl and chromium running (prints PIDs)
9+# BENCH_PPROF=addr pprof listen address (default localhost:6061)
10+set -euo pipefail
11+
12+page=${1:?usage: bench.sh <page.html> <seconds>}
13+secs=${2:?usage: bench.sh <page.html> <seconds>}
14+here=$(cd "$(dirname "$0")" && pwd)
15+root=$(cd "$here/../.." && pwd)
16+tmp=$(mktemp -d)
17+pids=()
18+
19+cleanup() {
20+ if [ -z "${BENCH_KEEP:-}" ]; then
21+ for p in "${pids[@]}"; do kill -- "-$p" 2>/dev/null || true; done
22+ sleep 1
23+ rm -rf "$tmp"
24+ fi
25+}
26+trap cleanup EXIT
27+
28+(cd "$root" && CGO_ENABLED=0 go build -o "$tmp/nwl" ./cmd/neferwl)
29+mkdir -p "$tmp/rt" "$tmp/cfg" "$tmp/state"
30+chmod 700 "$tmp/rt"
31+
32+debug=render${BENCH_DEBUG:+,$BENCH_DEBUG}
33+xflag=--no-xwayland
34+[ -n "${BENCH_X11:-}" ] && xflag=
35+env XDG_RUNTIME_DIR="$tmp/rt" XDG_CONFIG_HOME="$tmp/cfg" XDG_STATE_HOME="$tmp/state" WAYLAND_DISPLAY= \
36+ setsid "$tmp/nwl" --backend=headless --size=5120x2160 --no-terminal $xflag \
37+ --debug="$debug" --pprof="${BENCH_PPROF:-localhost:6061}" >"$tmp/nwl.out" 2>&1 </dev/null &
38+nwl=$!
39+pids+=("$nwl")
40+log=$tmp/state/neferwl/runs/headless/latest.log
41+for _ in $(seq 50); do [ -S "$tmp/rt/wayland-1" ] && break; sleep 0.1; done
42+if [ ! -S "$tmp/rt/wayland-1" ]; then
43+ echo "neferwl did not start: no wayland-1 socket; its output:" >&2
44+ cat "$tmp/nwl.out" >&2
45+ exit 1
46+fi
47+
48+if [ -n "${BENCH_X11:-}" ]; then
49+ disp=
50+ for _ in $(seq 50); do
51+ disp=$(grep -o '"DISPLAY":"[^"]*"' "$log" 2>/dev/null | head -1 | cut -d'"' -f4) || true
52+ [ -n "$disp" ] && break
53+ sleep 0.1
54+ done
55+ [ -n "$disp" ] || { echo "no X11 display in log" >&2; exit 1; }
56+ env XDG_RUNTIME_DIR="$tmp/rt" WAYLAND_DISPLAY= DISPLAY="$disp" setsid chromium --ozone-platform=x11 \
57+ --user-data-dir="$tmp/chr" --no-first-run --app="file://$here/$page" >"$tmp/chr.out" 2>&1 </dev/null &
58+else
59+ env XDG_RUNTIME_DIR="$tmp/rt" WAYLAND_DISPLAY=wayland-1 setsid chromium --ozone-platform=wayland \
60+ --user-data-dir="$tmp/chr" --no-first-run --app="file://$here/$page" >"$tmp/chr.out" 2>&1 </dev/null &
61+fi
62+pids+=("$!")
63+sleep 8
64+
65+cs() { cat /proc/"$nwl"/task/*/status | awk '/^voluntary_ctxt/{v+=$2} END{print v}'; }
66+# CPU time in ns summed over threads (schedstat is finer than stat's ticks).
67+cpu_ns() { cat /proc/"$nwl"/task/*/schedstat | awk '{v+=$1} END{printf "%d\n", v}'; }
68+frames() { grep -c '"message":"frame"' "$log" || true; }
69+
70+t0=$(cpu_ns); f0=$(frames); c0=$(cs)
71+sleep "$secs"
72+t1=$(cpu_ns); f1=$(frames); c1=$(cs)
73+
74+if [ "$((f1 - f0))" -le 0 ]; then
75+ echo "no frame presented in ${secs}s: the page did not animate or neferwl stalled" >&2
76+ tail -n 20 "$tmp/nwl.out" >&2
77+ exit 1
78+fi
79+
80+python3 - "$((t1 - t0))" "$((f1 - f0))" "$((c1 - c0))" "$secs" "$page" <<'EOF'
81+import sys
82+t, f, c, s, page = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]), float(sys.argv[4]), sys.argv[5]
83+cpu = t / 1e9 / s * 100
84+fr = max(f, 1)
85+print(f"{page} cpu%={cpu:.2f} fps={f / s:.1f} ms/frame={t / 1e6 / fr:.3f} cs/frame={c / fr:.0f}")
86+EOF
87+
88+if [ -n "${BENCH_KEEP:-}" ]; then
89+ echo "kept: dir=$tmp pids=${pids[*]} log=$log"
90+fi
A tools/bench/emoji.gif +0 -0 binary file: no text diff

binary file: no text diff

A tools/bench/gif.html +1 -0
@@ -0,0 +1,1 @@
1+<html><body style="margin:0;background:#fff;font:14px sans-serif"><p>general — hello <img src="emoji.gif" width=22 height=22> text</p></body></html>
A tools/bench/spin60.html +1 -0
@@ -0,0 +1,1 @@
1+<html><body style="margin:0;background:#fff"><p>x</p><div style="width:20px;height:20px;border:3px solid #ccc;border-top-color:#36c;border-radius:50%;animation:s 1s linear infinite"></div><style>@keyframes s{to{transform:rotate(360deg)}}</style></body></html>

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close