detached fa9d828

perf(wayland): forward capture replies through an allocation-free post ring

commit
fa9d828b599b8d31d4403ea34764bcd48918e3f4
author
brice · · 2026-10-14 18:44 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
844b5d1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +282 -9
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -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|TestWriteMappedAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -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|TestWriteMappedAllocations|TestCapturedForwardAllocations)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -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|TestWriteMappedAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -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|TestWriteMappedAllocations|TestCapturedForwardAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
A internal/adapters/wayland/postring.go +98 -0
@@ -0,0 +1,98 @@
1+package wayland
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// postRing carries values from producer goroutines to the display goroutine
10+// without allocating. It is a transport queue, not window state: the mutex
11+// guards only the slices and the pending flag. Values are never merged or
12+// dropped, in order of arrival.
13+//
14+// It binds its drain once and posts it at most once per batch: Display.Post
15+// is unbounded (a Post per value would let a fast producer grow the queue),
16+// so one pending drain serves every value pushed before it runs.
17+type postRing[T any] struct {
18+ display *server.Display
19+ apply func(T)
20+ drainFn func()
21+ // space is signalled (never blocking) after each drain and after a push
22+ // that left room, to wake blocked pushers one after the other.
23+ space chan struct{}
24+
25+ mu sync.Mutex
26+ ring, spare []T
27+ limit int
28+ pending bool // a drain is posted and has not taken the ring yet
29+}
30+
31+// newPostRing returns a ring of limit values whose values apply runs on the
32+// display goroutine, in order. limit must be at least 1.
33+func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] {
34+ r := &postRing[T]{
35+ display: d,
36+ apply: apply,
37+ space: make(chan struct{}, 1),
38+ ring: make([]T, 0, limit),
39+ spare: make([]T, 0, limit),
40+ limit: limit,
41+ }
42+ r.drainFn = r.drain
43+ return r
44+}
45+
46+// Push queues v for the display goroutine and wakes it. A full ring blocks
47+// the caller, which must not be the display goroutine, until a drain made
48+// room; it reports false, with v dropped, once the display has stopped.
49+func (r *postRing[T]) Push(v T) bool {
50+ for {
51+ r.mu.Lock()
52+ if len(r.ring) < r.limit {
53+ r.ring = append(r.ring, v)
54+ post := !r.pending
55+ r.pending = true
56+ room := len(r.ring) < r.limit
57+ r.mu.Unlock()
58+ if post {
59+ r.display.Post(r.drainFn)
60+ }
61+ if room {
62+ r.signal() // chain the wake to another blocked pusher
63+ }
64+ return true
65+ }
66+ r.mu.Unlock()
67+ select {
68+ case <-r.space:
69+ case <-r.display.Stopped():
70+ return false
71+ }
72+ }
73+}
74+
75+func (r *postRing[T]) signal() {
76+ select {
77+ case r.space <- struct{}{}:
78+ default:
79+ }
80+}
81+
82+// drain runs on the display goroutine and applies the batch in order.
83+func (r *postRing[T]) drain() {
84+ r.mu.Lock()
85+ batch := r.ring
86+ r.ring, r.spare = r.spare[:0], nil
87+ r.pending = false
88+ r.mu.Unlock()
89+ r.signal()
90+ for _, v := range batch {
91+ r.apply(v)
92+ }
93+ // Do not retain what the values reference in the spare batch.
94+ clear(batch)
95+ r.mu.Lock()
96+ r.spare = batch[:0]
97+ r.mu.Unlock()
98+}
@@ -0,0 +1,98 @@
1+package wayland
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// postRing carries values from producer goroutines to the display goroutine
10+// without allocating. It is a transport queue, not window state: the mutex
11+// guards only the slices and the pending flag. Values are never merged or
12+// dropped, in order of arrival.
13+//
14+// It binds its drain once and posts it at most once per batch: Display.Post
15+// is unbounded (a Post per value would let a fast producer grow the queue),
16+// so one pending drain serves every value pushed before it runs.
17+type postRing[T any] struct {
18+ display *server.Display
19+ apply func(T)
20+ drainFn func()
21+ // space is signalled (never blocking) after each drain and after a push
22+ // that left room, to wake blocked pushers one after the other.
23+ space chan struct{}
24+
25+ mu sync.Mutex
26+ ring, spare []T
27+ limit int
28+ pending bool // a drain is posted and has not taken the ring yet
29+}
30+
31+// newPostRing returns a ring of limit values whose values apply runs on the
32+// display goroutine, in order. limit must be at least 1.
33+func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] {
34+ r := &postRing[T]{
35+ display: d,
36+ apply: apply,
37+ space: make(chan struct{}, 1),
38+ ring: make([]T, 0, limit),
39+ spare: make([]T, 0, limit),
40+ limit: limit,
41+ }
42+ r.drainFn = r.drain
43+ return r
44+}
45+
46+// Push queues v for the display goroutine and wakes it. A full ring blocks
47+// the caller, which must not be the display goroutine, until a drain made
48+// room; it reports false, with v dropped, once the display has stopped.
49+func (r *postRing[T]) Push(v T) bool {
50+ for {
51+ r.mu.Lock()
52+ if len(r.ring) < r.limit {
53+ r.ring = append(r.ring, v)
54+ post := !r.pending
55+ r.pending = true
56+ room := len(r.ring) < r.limit
57+ r.mu.Unlock()
58+ if post {
59+ r.display.Post(r.drainFn)
60+ }
61+ if room {
62+ r.signal() // chain the wake to another blocked pusher
63+ }
64+ return true
65+ }
66+ r.mu.Unlock()
67+ select {
68+ case <-r.space:
69+ case <-r.display.Stopped():
70+ return false
71+ }
72+ }
73+}
74+
75+func (r *postRing[T]) signal() {
76+ select {
77+ case r.space <- struct{}{}:
78+ default:
79+ }
80+}
81+
82+// drain runs on the display goroutine and applies the batch in order.
83+func (r *postRing[T]) drain() {
84+ r.mu.Lock()
85+ batch := r.ring
86+ r.ring, r.spare = r.spare[:0], nil
87+ r.pending = false
88+ r.mu.Unlock()
89+ r.signal()
90+ for _, v := range batch {
91+ r.apply(v)
92+ }
93+ // Do not retain what the values reference in the spare batch.
94+ clear(batch)
95+ r.mu.Lock()
96+ r.spare = batch[:0]
97+ r.mu.Unlock()
98+}
A internal/adapters/wayland/postring_test.go +160 -0
@@ -0,0 +1,160 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Pushing a capture reply and applying it on the display goroutine allocates
14+// nothing in steady state. The count includes the display goroutine:
15+// AllocsPerRun reads the global malloc counter.
16+func TestCapturedForwardAllocations(t *testing.T) {
17+ if raceEnabled {
18+ t.Skip("the race detector makes the purego wake callback allocate")
19+ }
20+ s, _, _, _, _, _ := presentServer(t)
21+ done := ports.CaptureDone{ID: 77, Output: "HEADLESS-1", Time: time.Unix(1, 2)}
22+ // Posts run in order: once the marker ran, the drain before it did. The
23+ // marker is bound once and the channel reused, so waiting allocates nothing.
24+ ran := make(chan struct{}, 1)
25+ marker := func() { ran <- struct{}{} }
26+ for range 4 {
27+ s.capturedRing.Push(done)
28+ s.display.Post(marker)
29+ <-ran
30+ }
31+ allocs := testing.AllocsPerRun(200, func() {
32+ s.capturedRing.Push(done)
33+ s.display.Post(marker)
34+ <-ran
35+ })
36+ if allocs != 0 {
37+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
38+ }
39+}
40+
41+// A full ring blocks the push until the display goroutine drains it, and no
42+// value is lost or reordered.
43+func TestPostRingFullBlocksWithoutLoss(t *testing.T) {
44+ s, _, _, _, _, _ := presentServer(t)
45+ const limit, total = 4, 3*4 + 1
46+ var got []int // display goroutine only
47+ r := newPostRing(s.display, limit, func(v int) { got = append(got, v) })
48+
49+ // Stall the display goroutine so nothing drains.
50+ release := make(chan struct{})
51+ stalled := make(chan struct{})
52+ s.display.Post(func() { close(stalled); <-release })
53+ <-stalled
54+
55+ pushed := make(chan int, total)
56+ var wg sync.WaitGroup
57+ wg.Add(1)
58+ go func() {
59+ defer wg.Done()
60+ for i := range total {
61+ if !r.Push(i) {
62+ t.Errorf("push %d refused", i)
63+ return
64+ }
65+ pushed <- i
66+ }
67+ }()
68+ for range limit {
69+ select {
70+ case <-pushed:
71+ case <-time.After(2 * time.Second):
72+ t.Fatal("pushes below the limit blocked")
73+ }
74+ }
75+ select {
76+ case i := <-pushed:
77+ t.Fatalf("push %d returned on a full ring", i)
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+ close(release)
81+ wg.Wait()
82+ // The marker runs after every drain posted before it.
83+ var final []int
84+ ran := make(chan struct{})
85+ for {
86+ s.display.Post(func() {
87+ if len(got) == total {
88+ final = append(final, got...)
89+ }
90+ ran <- struct{}{}
91+ })
92+ <-ran
93+ if final != nil {
94+ break
95+ }
96+ time.Sleep(time.Millisecond)
97+ }
98+ for i, v := range final {
99+ if v != i {
100+ t.Fatalf("applied %v, want 0..%d in order", final, total-1)
101+ }
102+ }
103+}
104+
105+// A push blocked on a full ring resumes once the display drains it.
106+func TestPostRingBlockedPushResumes(t *testing.T) {
107+ s, _, _, _, _, _ := presentServer(t)
108+ r := newPostRing(s.display, 1, func(int) {})
109+ release := make(chan struct{})
110+ stalled := make(chan struct{})
111+ s.display.Post(func() { close(stalled); <-release })
112+ <-stalled
113+ if !r.Push(1) {
114+ t.Fatal("first push refused")
115+ }
116+ result := make(chan bool, 1)
117+ go func() { result <- r.Push(2) }()
118+ select {
119+ case ok := <-result:
120+ t.Fatalf("push on a full ring returned %v", ok)
121+ case <-time.After(50 * time.Millisecond):
122+ }
123+ close(release)
124+ select {
125+ case ok := <-result:
126+ if !ok {
127+ t.Fatal("push refused while the display runs")
128+ }
129+ case <-time.After(2 * time.Second):
130+ t.Fatal("blocked push never resumed")
131+ }
132+}
133+
134+// A push blocked on a full ring returns false once the display has stopped,
135+// instead of waiting for a drain that never comes.
136+func TestPostRingPushStopsWithDisplay(t *testing.T) {
137+ s, err := New(Options{RuntimeDir: t.TempDir()}, Channels{}, logging.For(context.Background(), "wayland"))
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ ctx, cancel := context.WithCancel(context.Background())
142+ done := make(chan error, 1)
143+ go func() { done <- s.Run(ctx) }()
144+ cancel()
145+ <-done
146+ r := newPostRing(s.display, 1, func(int) {})
147+ if !r.Push(1) { // room: queued, its drain is dropped by the stopped display
148+ t.Fatal("first push refused")
149+ }
150+ result := make(chan bool, 1)
151+ go func() { result <- r.Push(2) }()
152+ select {
153+ case ok := <-result:
154+ if ok {
155+ t.Fatal("push on a stopped display reported success")
156+ }
157+ case <-time.After(2 * time.Second):
158+ t.Fatal("push blocked after the display stopped")
159+ }
160+}
@@ -0,0 +1,160 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Pushing a capture reply and applying it on the display goroutine allocates
14+// nothing in steady state. The count includes the display goroutine:
15+// AllocsPerRun reads the global malloc counter.
16+func TestCapturedForwardAllocations(t *testing.T) {
17+ if raceEnabled {
18+ t.Skip("the race detector makes the purego wake callback allocate")
19+ }
20+ s, _, _, _, _, _ := presentServer(t)
21+ done := ports.CaptureDone{ID: 77, Output: "HEADLESS-1", Time: time.Unix(1, 2)}
22+ // Posts run in order: once the marker ran, the drain before it did. The
23+ // marker is bound once and the channel reused, so waiting allocates nothing.
24+ ran := make(chan struct{}, 1)
25+ marker := func() { ran <- struct{}{} }
26+ for range 4 {
27+ s.capturedRing.Push(done)
28+ s.display.Post(marker)
29+ <-ran
30+ }
31+ allocs := testing.AllocsPerRun(200, func() {
32+ s.capturedRing.Push(done)
33+ s.display.Post(marker)
34+ <-ran
35+ })
36+ if allocs != 0 {
37+ t.Errorf("push + drain: %.1f allocs, want 0", allocs)
38+ }
39+}
40+
41+// A full ring blocks the push until the display goroutine drains it, and no
42+// value is lost or reordered.
43+func TestPostRingFullBlocksWithoutLoss(t *testing.T) {
44+ s, _, _, _, _, _ := presentServer(t)
45+ const limit, total = 4, 3*4 + 1
46+ var got []int // display goroutine only
47+ r := newPostRing(s.display, limit, func(v int) { got = append(got, v) })
48+
49+ // Stall the display goroutine so nothing drains.
50+ release := make(chan struct{})
51+ stalled := make(chan struct{})
52+ s.display.Post(func() { close(stalled); <-release })
53+ <-stalled
54+
55+ pushed := make(chan int, total)
56+ var wg sync.WaitGroup
57+ wg.Add(1)
58+ go func() {
59+ defer wg.Done()
60+ for i := range total {
61+ if !r.Push(i) {
62+ t.Errorf("push %d refused", i)
63+ return
64+ }
65+ pushed <- i
66+ }
67+ }()
68+ for range limit {
69+ select {
70+ case <-pushed:
71+ case <-time.After(2 * time.Second):
72+ t.Fatal("pushes below the limit blocked")
73+ }
74+ }
75+ select {
76+ case i := <-pushed:
77+ t.Fatalf("push %d returned on a full ring", i)
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+ close(release)
81+ wg.Wait()
82+ // The marker runs after every drain posted before it.
83+ var final []int
84+ ran := make(chan struct{})
85+ for {
86+ s.display.Post(func() {
87+ if len(got) == total {
88+ final = append(final, got...)
89+ }
90+ ran <- struct{}{}
91+ })
92+ <-ran
93+ if final != nil {
94+ break
95+ }
96+ time.Sleep(time.Millisecond)
97+ }
98+ for i, v := range final {
99+ if v != i {
100+ t.Fatalf("applied %v, want 0..%d in order", final, total-1)
101+ }
102+ }
103+}
104+
105+// A push blocked on a full ring resumes once the display drains it.
106+func TestPostRingBlockedPushResumes(t *testing.T) {
107+ s, _, _, _, _, _ := presentServer(t)
108+ r := newPostRing(s.display, 1, func(int) {})
109+ release := make(chan struct{})
110+ stalled := make(chan struct{})
111+ s.display.Post(func() { close(stalled); <-release })
112+ <-stalled
113+ if !r.Push(1) {
114+ t.Fatal("first push refused")
115+ }
116+ result := make(chan bool, 1)
117+ go func() { result <- r.Push(2) }()
118+ select {
119+ case ok := <-result:
120+ t.Fatalf("push on a full ring returned %v", ok)
121+ case <-time.After(50 * time.Millisecond):
122+ }
123+ close(release)
124+ select {
125+ case ok := <-result:
126+ if !ok {
127+ t.Fatal("push refused while the display runs")
128+ }
129+ case <-time.After(2 * time.Second):
130+ t.Fatal("blocked push never resumed")
131+ }
132+}
133+
134+// A push blocked on a full ring returns false once the display has stopped,
135+// instead of waiting for a drain that never comes.
136+func TestPostRingPushStopsWithDisplay(t *testing.T) {
137+ s, err := New(Options{RuntimeDir: t.TempDir()}, Channels{}, logging.For(context.Background(), "wayland"))
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ ctx, cancel := context.WithCancel(context.Background())
142+ done := make(chan error, 1)
143+ go func() { done <- s.Run(ctx) }()
144+ cancel()
145+ <-done
146+ r := newPostRing(s.display, 1, func(int) {})
147+ if !r.Push(1) { // room: queued, its drain is dropped by the stopped display
148+ t.Fatal("first push refused")
149+ }
150+ result := make(chan bool, 1)
151+ go func() { result <- r.Push(2) }()
152+ select {
153+ case ok := <-result:
154+ if ok {
155+ t.Fatal("push on a stopped display reported success")
156+ }
157+ case <-time.After(2 * time.Second):
158+ t.Fatal("push blocked after the display stopped")
159+ }
160+}
M internal/adapters/wayland/screencopy.go +19 -8
@@ -239,6 +239,24 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
239239 return 0, false
240240 }
241241 }
242+
243+// capturedRingSize bounds the replies waiting for the display goroutine: one
244+// per admitted capture, and the cast frames in flight (8).
245+const capturedRingSize = ports.MaxCaptureInflight + 8
246+
247+// applyCaptured settles one capture reply on the display goroutine.
248+func (s *Server) applyCaptured(done ports.CaptureDone) {
249+ delete(s.captureInflight, done.ID)
250+ if fn := s.captureReplies[done.ID]; fn != nil {
251+ delete(s.captureReplies, done.ID)
252+ fn(done)
253+ }
254+ s.maybeLocked()
255+}
256+
257+// forwardCaptured moves backend replies to the display goroutine through
258+// capturedRing. Each reply releases an admission credit, so none is merged or
259+// dropped; a full ring blocks this goroutine, never the display.
242260 func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
243261 for {
244262 select {
@@ -250,14 +268,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
250268 if !ok {
251269 return
252270 }
253- if !s.display.Do(func() {
254- delete(s.captureInflight, done.ID)
255- if fn := s.captureReplies[done.ID]; fn != nil {
256- delete(s.captureReplies, done.ID)
257- fn(done)
258- }
259- s.maybeLocked()
260- }) {
271+ if !s.capturedRing.Push(done) {
261272 return
262273 }
263274 }
@@ -239,6 +239,24 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
239 return 0, false239 return 0, false
240 }240 }
241 }241 }
242+
243+// capturedRingSize bounds the replies waiting for the display goroutine: one
244+// per admitted capture, and the cast frames in flight (8).
245+const capturedRingSize = ports.MaxCaptureInflight + 8
246+
247+// applyCaptured settles one capture reply on the display goroutine.
248+func (s *Server) applyCaptured(done ports.CaptureDone) {
249+ delete(s.captureInflight, done.ID)
250+ if fn := s.captureReplies[done.ID]; fn != nil {
251+ delete(s.captureReplies, done.ID)
252+ fn(done)
253+ }
254+ s.maybeLocked()
255+}
256+
257+// forwardCaptured moves backend replies to the display goroutine through
258+// capturedRing. Each reply releases an admission credit, so none is merged or
259+// dropped; a full ring blocks this goroutine, never the display.
242 func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {260 func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
243 for {261 for {
244 select {262 select {
@@ -250,14 +268,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
250 if !ok {268 if !ok {
251 return269 return
252 }270 }
253- if !s.display.Do(func() {271+ if !s.capturedRing.Push(done) {
254- delete(s.captureInflight, done.ID)
255- if fn := s.captureReplies[done.ID]; fn != nil {
256- delete(s.captureReplies, done.ID)
257- fn(done)
258- }
259- s.maybeLocked()
260- }) {
261 return272 return
262 }273 }
263 }274 }
M internal/adapters/wayland/server.go +4 -0
@@ -111,6 +111,9 @@ type Server struct {
111111 nextLeaseID uint64
112112 nextCapture uint64
113113 captureReplies map[uint64]func(ports.CaptureDone)
114+ // capturedRing hands backend replies to the display goroutine
115+ // (forwardCaptured); the display goroutine applies them in order.
116+ capturedRing *postRing[ports.CaptureDone]
114117 // captureInflight counts accepted captures until the backend completes
115118 // them, independent of client resources; only the display loop touches it.
116119 captureInflight map[uint64]struct{}
@@ -358,6 +361,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
358361 s.paceSignalFn = s.paceSignalled
359362 s.flushLinksFn = s.flushLinks
360363 s.sink = newPresentSink(s)
364+ s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)
361365 s.leaseDevices = map[string]*leaseDevice{}
362366 s.pendingLeases = map[uint64]*leaseObject{}
363367 s.pendingLeaseCards = map[uint64]string{}
@@ -111,6 +111,9 @@ type Server struct {
111 nextLeaseID uint64111 nextLeaseID uint64
112 nextCapture uint64112 nextCapture uint64
113 captureReplies map[uint64]func(ports.CaptureDone)113 captureReplies map[uint64]func(ports.CaptureDone)
114+ // capturedRing hands backend replies to the display goroutine
115+ // (forwardCaptured); the display goroutine applies them in order.
116+ capturedRing *postRing[ports.CaptureDone]
114 // captureInflight counts accepted captures until the backend completes117 // captureInflight counts accepted captures until the backend completes
115 // them, independent of client resources; only the display loop touches it.118 // them, independent of client resources; only the display loop touches it.
116 captureInflight map[uint64]struct{}119 captureInflight map[uint64]struct{}
@@ -358,6 +361,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
358 s.paceSignalFn = s.paceSignalled361 s.paceSignalFn = s.paceSignalled
359 s.flushLinksFn = s.flushLinks362 s.flushLinksFn = s.flushLinks
360 s.sink = newPresentSink(s)363 s.sink = newPresentSink(s)
364+ s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)
361 s.leaseDevices = map[string]*leaseDevice{}365 s.leaseDevices = map[string]*leaseDevice{}
362 s.pendingLeases = map[uint64]*leaseObject{}366 s.pendingLeases = map[uint64]*leaseObject{}
363 s.pendingLeaseCards = map[uint64]string{}367 s.pendingLeaseCards = map[uint64]string{}

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