detached 587fa9e

test(wayland): cover the content handshake and a large replay

Stress the pending/flushPosted handshake with Returns racing emitContent,
check a 100-window replay lands in one batch without the output reading,
and replace the timed negative waits with a display round trip.
commit
587fa9e3d77482023ee02073593c585d81c8af98
author
brice · · 2026-10-13 17:58 UTC
parent
6eb70df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +124 -5
M internal/adapters/wayland/contentlink_test.go +124 -5
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "context"
5+ "sync/atomic"
56 "testing"
67 "time"
78
@@ -50,12 +51,16 @@ func nextBatch(t *testing.T, in <-chan *ports.ContentBatch) *ports.ContentBatch
5051 }
5152 }
5253
53-func noBatch(t *testing.T, in <-chan *ports.ContentBatch) {
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) {
5458 t.Helper()
59+ s.display.Do(func() {})
5560 select {
5661 case b := <-in:
5762 t.Fatalf("unexpected batch %+v", b.Items)
58- case <-time.After(30 * time.Millisecond):
63+ default:
5964 }
6065 }
6166
@@ -73,7 +78,7 @@ func TestContentBatchKeepsLatestPerWindow(t *testing.T) {
7378 if len(first.Items) != 1 || first.Items[0].ID != 1 || first.Items[0].Seq != 1 {
7479 t.Fatalf("first batch %+v", first.Items)
7580 }
76- noBatch(t, in) // nothing moves before the output returns a batch...
81+ noBatch(t, s, in) // nothing moves before the output returns a batch...
7782 first.Reset()
7883 ret.Return(first) // ...which sends the waiting contents
7984 second := nextBatch(t, in)
@@ -113,7 +118,7 @@ func TestContentBatchAttachSeedsLiveWindows(t *testing.T) {
113118 // An attach with nothing to show sends nothing.
114119 s.display.Do(func() { clear(s.latest) })
115120 in2, _ := s.AttachOutput("B")
116- noBatch(t, in2)
121+ noBatch(t, s, in2)
117122 }
118123
119124 // An empty content reaches the attached outputs (it releases the window's
@@ -165,7 +170,7 @@ func TestContentBatchDetach(t *testing.T) {
165170 a.Reset()
166171 retA.Return(a)
167172 s.publish(t, solid(1))
168- noBatch(t, inA)
173+ noBatch(t, s, inA)
169174 if got := nextBatch(t, inB); len(got.Items) != 1 {
170175 t.Fatalf("B batch %+v", got.Items)
171176 }
@@ -215,3 +220,117 @@ func TestContentBatchCycleAllocations(t *testing.T) {
215220 t.Errorf("content batch cycle: %.2f allocs, want 0", allocs)
216221 }
217222 }
223+
224+// A late-attached output gets all the live windows in one batch, however
225+// many there are, and AttachOutput returns without the output reading.
226+func TestContentBatchAttachReplaysLargeSet(t *testing.T) {
227+ s := linkServer(t)
228+ const windows = 100
229+ all := make([]ports.SurfaceContent, 0, windows)
230+ for id := 1; id <= windows; id++ {
231+ all = append(all, solid(ports.WindowID(id)))
232+ }
233+ s.publish(t, all...)
234+ attached := make(chan (<-chan *ports.ContentBatch), 1)
235+ go func() { in, _ := s.AttachOutput("A"); attached <- in }()
236+ var in <-chan *ports.ContentBatch
237+ select {
238+ case in = <-attached:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("AttachOutput waits for the output to read")
241+ }
242+ b := nextBatch(t, in)
243+ ids := map[ports.WindowID]struct{}{}
244+ for _, c := range b.Items {
245+ ids[c.ID] = struct{}{}
246+ }
247+ if len(b.Items) != windows || len(ids) != windows {
248+ t.Fatalf("replay batch holds %d items, %d distinct windows, want %d", len(b.Items), len(ids), windows)
249+ }
250+}
251+
252+// The output returns batches in bursts and holds some while content keeps
253+// coming, so the channel is full and no batch is free when emitContent runs;
254+// Returns race with it from another goroutine. Whatever the interleaving, the
255+// output ends up with the newest Seq of every window (the pending/flushPosted
256+// handshake never strands content). Run with -race -count=20.
257+func TestContentBatchHandshakeNeverStrandsContent(t *testing.T) {
258+ s := linkServer(t)
259+ in, ret := s.AttachOutput("A")
260+ const windows, rounds = 4, 3000
261+ var last [windows + 1]atomic.Uint64
262+ stop, ended := make(chan struct{}), make(chan struct{})
263+ t.Cleanup(func() { close(stop); <-ended })
264+ go func() {
265+ defer close(ended)
266+ var held []*ports.ContentBatch
267+ giveBack := func(n int) {
268+ for ; n > 0 && len(held) > 0; n-- {
269+ b := held[0]
270+ held = held[1:]
271+ b.Reset()
272+ ret.Return(b)
273+ }
274+ }
275+ for n := 0; ; n++ {
276+ var b *ports.ContentBatch
277+ select {
278+ case b = <-in:
279+ default:
280+ // Nothing waiting: hand everything back, as an idle
281+ // output does, then block.
282+ giveBack(len(held))
283+ select {
284+ case b = <-in:
285+ case <-stop:
286+ return
287+ }
288+ }
289+ for _, c := range b.Items {
290+ if c.Seq > last[c.ID].Load() {
291+ last[c.ID].Store(c.Seq)
292+ }
293+ }
294+ held = append(held, b)
295+ if len(held) >= 2 {
296+ giveBack(1 + n%2) // a burst of one or two
297+ }
298+ }
299+ }()
300+ for range rounds {
301+ s.publishRound(windows)
302+ }
303+ var want [windows + 1]uint64
304+ s.display.Do(func() {
305+ for id := 1; id <= windows; id++ {
306+ want[id] = s.contentSeq[ports.WindowID(id)]
307+ }
308+ })
309+ for deadline := time.Now().Add(5 * time.Second); ; {
310+ missing := 0
311+ for id := 1; id <= windows; id++ {
312+ if last[id].Load() != want[id] {
313+ missing++
314+ }
315+ }
316+ if missing == 0 {
317+ return
318+ }
319+ if time.Now().After(deadline) {
320+ for id := 1; id <= windows; id++ {
321+ t.Errorf("window %d: output saw seq %d, newest is %d", id, last[id].Load(), want[id])
322+ }
323+ t.Fatal("content stranded")
324+ }
325+ time.Sleep(time.Millisecond)
326+ }
327+}
328+
329+// publishRound publishes one content per window in one display turn.
330+func (s *Server) publishRound(windows int) {
331+ s.display.Do(func() {
332+ for id := 1; id <= windows; id++ {
333+ s.emitContent(solid(ports.WindowID(id)), damage{full: true})
334+ }
335+ })
336+}
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync/atomic"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -50,12 +51,16 @@ func nextBatch(t *testing.T, in <-chan *ports.ContentBatch) *ports.ContentBatch
50 }51 }
51 }52 }
52 53
53-func noBatch(t *testing.T, in <-chan *ports.ContentBatch) {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) {
54 t.Helper()58 t.Helper()
59+ s.display.Do(func() {})
55 select {60 select {
56 case b := <-in:61 case b := <-in:
57 t.Fatalf("unexpected batch %+v", b.Items)62 t.Fatalf("unexpected batch %+v", b.Items)
58- case <-time.After(30 * time.Millisecond):63+ default:
59 }64 }
60 }65 }
61 66
@@ -73,7 +78,7 @@ func TestContentBatchKeepsLatestPerWindow(t *testing.T) {
73 if len(first.Items) != 1 || first.Items[0].ID != 1 || first.Items[0].Seq != 1 {78 if len(first.Items) != 1 || first.Items[0].ID != 1 || first.Items[0].Seq != 1 {
74 t.Fatalf("first batch %+v", first.Items)79 t.Fatalf("first batch %+v", first.Items)
75 }80 }
76- noBatch(t, in) // nothing moves before the output returns a batch...81+ noBatch(t, s, in) // nothing moves before the output returns a batch...
77 first.Reset()82 first.Reset()
78 ret.Return(first) // ...which sends the waiting contents83 ret.Return(first) // ...which sends the waiting contents
79 second := nextBatch(t, in)84 second := nextBatch(t, in)
@@ -113,7 +118,7 @@ func TestContentBatchAttachSeedsLiveWindows(t *testing.T) {
113 // An attach with nothing to show sends nothing.118 // An attach with nothing to show sends nothing.
114 s.display.Do(func() { clear(s.latest) })119 s.display.Do(func() { clear(s.latest) })
115 in2, _ := s.AttachOutput("B")120 in2, _ := s.AttachOutput("B")
116- noBatch(t, in2)121+ noBatch(t, s, in2)
117 }122 }
118 123
119 // An empty content reaches the attached outputs (it releases the window's124 // An empty content reaches the attached outputs (it releases the window's
@@ -165,7 +170,7 @@ func TestContentBatchDetach(t *testing.T) {
165 a.Reset()170 a.Reset()
166 retA.Return(a)171 retA.Return(a)
167 s.publish(t, solid(1))172 s.publish(t, solid(1))
168- noBatch(t, inA)173+ noBatch(t, s, inA)
169 if got := nextBatch(t, inB); len(got.Items) != 1 {174 if got := nextBatch(t, inB); len(got.Items) != 1 {
170 t.Fatalf("B batch %+v", got.Items)175 t.Fatalf("B batch %+v", got.Items)
171 }176 }
@@ -215,3 +220,117 @@ func TestContentBatchCycleAllocations(t *testing.T) {
215 t.Errorf("content batch cycle: %.2f allocs, want 0", allocs)220 t.Errorf("content batch cycle: %.2f allocs, want 0", allocs)
216 }221 }
217 }222 }
223+
224+// A late-attached output gets all the live windows in one batch, however
225+// many there are, and AttachOutput returns without the output reading.
226+func TestContentBatchAttachReplaysLargeSet(t *testing.T) {
227+ s := linkServer(t)
228+ const windows = 100
229+ all := make([]ports.SurfaceContent, 0, windows)
230+ for id := 1; id <= windows; id++ {
231+ all = append(all, solid(ports.WindowID(id)))
232+ }
233+ s.publish(t, all...)
234+ attached := make(chan (<-chan *ports.ContentBatch), 1)
235+ go func() { in, _ := s.AttachOutput("A"); attached <- in }()
236+ var in <-chan *ports.ContentBatch
237+ select {
238+ case in = <-attached:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("AttachOutput waits for the output to read")
241+ }
242+ b := nextBatch(t, in)
243+ ids := map[ports.WindowID]struct{}{}
244+ for _, c := range b.Items {
245+ ids[c.ID] = struct{}{}
246+ }
247+ if len(b.Items) != windows || len(ids) != windows {
248+ t.Fatalf("replay batch holds %d items, %d distinct windows, want %d", len(b.Items), len(ids), windows)
249+ }
250+}
251+
252+// The output returns batches in bursts and holds some while content keeps
253+// coming, so the channel is full and no batch is free when emitContent runs;
254+// Returns race with it from another goroutine. Whatever the interleaving, the
255+// output ends up with the newest Seq of every window (the pending/flushPosted
256+// handshake never strands content). Run with -race -count=20.
257+func TestContentBatchHandshakeNeverStrandsContent(t *testing.T) {
258+ s := linkServer(t)
259+ in, ret := s.AttachOutput("A")
260+ const windows, rounds = 4, 3000
261+ var last [windows + 1]atomic.Uint64
262+ stop, ended := make(chan struct{}), make(chan struct{})
263+ t.Cleanup(func() { close(stop); <-ended })
264+ go func() {
265+ defer close(ended)
266+ var held []*ports.ContentBatch
267+ giveBack := func(n int) {
268+ for ; n > 0 && len(held) > 0; n-- {
269+ b := held[0]
270+ held = held[1:]
271+ b.Reset()
272+ ret.Return(b)
273+ }
274+ }
275+ for n := 0; ; n++ {
276+ var b *ports.ContentBatch
277+ select {
278+ case b = <-in:
279+ default:
280+ // Nothing waiting: hand everything back, as an idle
281+ // output does, then block.
282+ giveBack(len(held))
283+ select {
284+ case b = <-in:
285+ case <-stop:
286+ return
287+ }
288+ }
289+ for _, c := range b.Items {
290+ if c.Seq > last[c.ID].Load() {
291+ last[c.ID].Store(c.Seq)
292+ }
293+ }
294+ held = append(held, b)
295+ if len(held) >= 2 {
296+ giveBack(1 + n%2) // a burst of one or two
297+ }
298+ }
299+ }()
300+ for range rounds {
301+ s.publishRound(windows)
302+ }
303+ var want [windows + 1]uint64
304+ s.display.Do(func() {
305+ for id := 1; id <= windows; id++ {
306+ want[id] = s.contentSeq[ports.WindowID(id)]
307+ }
308+ })
309+ for deadline := time.Now().Add(5 * time.Second); ; {
310+ missing := 0
311+ for id := 1; id <= windows; id++ {
312+ if last[id].Load() != want[id] {
313+ missing++
314+ }
315+ }
316+ if missing == 0 {
317+ return
318+ }
319+ if time.Now().After(deadline) {
320+ for id := 1; id <= windows; id++ {
321+ t.Errorf("window %d: output saw seq %d, newest is %d", id, last[id].Load(), want[id])
322+ }
323+ t.Fatal("content stranded")
324+ }
325+ time.Sleep(time.Millisecond)
326+ }
327+}
328+
329+// publishRound publishes one content per window in one display turn.
330+func (s *Server) publishRound(windows int) {
331+ s.display.Do(func() {
332+ for id := 1; id <= windows; id++ {
333+ s.emitContent(solid(ports.WindowID(id)), damage{full: true})
334+ }
335+ })
336+}

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