detached 27cdcf1

fix(drm): never put a closed window's kept buffer on a plane; forget its configures

GO-001: wayland releases a closed window's buffer once the output reports
its empty content as seen, so the client may already draw into it while
the leaving entry is composed. The first leaving scene carries Fade 0 and
Zoom 0, so the plane paths need the output's own knowledge: Output.kept
(surfaces.Table.Kept) is passed to scanoutCandidate and overlayCandidate,
which refuse a kept window with reason "leaving". shownBy and headless
flipInfo skip kept IDs (GO-005): nothing is presented to the client.

GO-002: configures.keep held the destroyed window's last configure; xdg
reuses window IDs, so a remap during the fade would withhold the new
toplevel's unchanged tile configure. unmapWindow forgets the ID's sent and
answer state up front. The remap chains the entrance over the leaving
motion (appearFrom), lists the window once and configures it.

GO-003: the kept content's Children are copied with their Acquire fences
zeroed (one allocation per unmap); the package doc states the invariant
(composed only; window capture ends before the leaving scene).

Tests: drm overlay + single leaving tile composed with reason leaving,
direct scanout of a lone leaving fullscreen window refused, shownBy skips
kept; headless flipInfo skips kept; surfaces children copy; core remap
during the fade; leaving dropped by a real lock, unplug, overview toggle
and workspace switch (GO-004).
commit
27cdcf17e40da3731051d56948c8c092c7f2f9bc
author
brice · · 2026-10-05 01:12 UTC
parent
d7e6236
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +354 -53
M internal/adapters/drm/drop_hint_test.go +3 -3
@@ -9,15 +9,15 @@ import (
99 func TestDropHintsBlockPlanes(t *testing.T) {
1010 s, c := overlayScene()
1111 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" {
12+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "drop_hint" {
1313 t.Fatalf("overlay reason %q", reason)
1414 }
1515 fs, fc := fullscreenScene()
16- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" {
16+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason == "drop_hint" {
1717 t.Fatal("no hints, still refused")
1818 }
1919 fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
20- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" {
20+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason != "drop_hint" {
2121 t.Fatalf("scanout reason %q", reason)
2222 }
2323 }
@@ -9,15 +9,15 @@ import (
9 func TestDropHintsBlockPlanes(t *testing.T) {9 func TestDropHintsBlockPlanes(t *testing.T) {
10 s, c := overlayScene()10 s, c := overlayScene()
11 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}11 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" {12+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "drop_hint" {
13 t.Fatalf("overlay reason %q", reason)13 t.Fatalf("overlay reason %q", reason)
14 }14 }
15 fs, fc := fullscreenScene()15 fs, fc := fullscreenScene()
16- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" {16+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason == "drop_hint" {
17 t.Fatal("no hints, still refused")17 t.Fatal("no hints, still refused")
18 }18 }
19 fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}19 fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
20- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" {20+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason != "drop_hint" {
21 t.Fatalf("scanout reason %q", reason)21 t.Fatalf("scanout reason %q", reason)
22 }22 }
23 }23 }
A internal/adapters/drm/leaving_test.go +84 -0
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (it closed and fades
11+// out) never goes on a plane: wayland has released its buffer, so the
12+// client may already draw into it. The first leaving scene carries Fade 0
13+// and Zoom 0 (the fade starts at opaque), so the kept flag alone refuses it.
14+func TestLeavingWindowNeverOnAPlane(t *testing.T) {
15+ t.Run("overlay plane, a single leaving tile", func(t *testing.T) {
16+ o, _, _ := overlayOutput(t)
17+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
18+ table := surfaces.New()
19+ o.kept = table.Kept
20+ s, c := overlayScene()
21+ table.Update(c[2], s)
22+ // Window 2 unmaps: its empty content keeps the last one, Acquire
23+ // dropped, while the scene lists it.
24+ table.Update(ports.SurfaceContent{ID: 2}, s)
25+ if !table.Kept(2) {
26+ t.Fatal("setup: content not kept")
27+ }
28+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "leaving" {
29+ t.Fatalf("overlay reason %q, want leaving", reason)
30+ }
31+ ov, rest := o.overlayFrame(s, table.Map())
32+ if ov.fb != 0 || len(rest.Windows) != 2 || o.overlayReason != "leaving" {
33+ t.Fatalf("leaving window put on the overlay: %+v rest %d reason %q", ov, len(rest.Windows), o.overlayReason)
34+ }
35+ // Without the kept flag the same scene goes on the plane: the
36+ // refusal is the flag's.
37+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
38+ t.Fatalf("control: reason %q", reason)
39+ }
40+ // A new content of a remapped window frees it again.
41+ table.Update(c[2], s)
42+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "" {
43+ t.Fatalf("remapped window refused: %q", reason)
44+ }
45+ })
46+ t.Run("direct scanout of a lone leaving fullscreen window", func(t *testing.T) {
47+ s, c := fullscreenScene()
48+ table := surfaces.New()
49+ for _, cc := range c {
50+ table.Update(cc, s)
51+ }
52+ id := s.Windows[0].ID
53+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "" {
54+ t.Fatalf("control: reason %q", reason)
55+ }
56+ table.Update(ports.SurfaceContent{ID: id}, s)
57+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "leaving" {
58+ t.Fatalf("scanout reason %q, want leaving", reason)
59+ }
60+ // Through the output: composed, reason logged.
61+ o, _, _ := testOutput(t)
62+ o.kept = table.Kept
63+ o.scanout = true
64+ if fb, _, _ := o.scanoutFrame(s, table.Map()); fb != 0 || o.reason != "leaving" {
65+ t.Fatalf("direct scanout of a leaving window: fb %d reason %q", fb, o.reason)
66+ }
67+ })
68+ t.Run("not reported shown", func(t *testing.T) {
69+ o, _, _ := testOutput(t)
70+ table := surfaces.New()
71+ o.kept = table.Kept
72+ s, c := overlayScene()
73+ table.Update(c[2], s)
74+ seen := map[ports.WindowID]uint64{2: 3}
75+ if shows := o.shownBy(s, seen); shows[2] != 3 {
76+ t.Fatalf("control: shows %v", shows)
77+ }
78+ table.Update(ports.SurfaceContent{ID: 2, Seq: 4}, s)
79+ seen[2] = 4
80+ if shows := o.shownBy(s, seen); len(shows) != 0 {
81+ t.Fatalf("a leaving window is reported shown: %v", shows)
82+ }
83+ })
84+}
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (it closed and fades
11+// out) never goes on a plane: wayland has released its buffer, so the
12+// client may already draw into it. The first leaving scene carries Fade 0
13+// and Zoom 0 (the fade starts at opaque), so the kept flag alone refuses it.
14+func TestLeavingWindowNeverOnAPlane(t *testing.T) {
15+ t.Run("overlay plane, a single leaving tile", func(t *testing.T) {
16+ o, _, _ := overlayOutput(t)
17+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
18+ table := surfaces.New()
19+ o.kept = table.Kept
20+ s, c := overlayScene()
21+ table.Update(c[2], s)
22+ // Window 2 unmaps: its empty content keeps the last one, Acquire
23+ // dropped, while the scene lists it.
24+ table.Update(ports.SurfaceContent{ID: 2}, s)
25+ if !table.Kept(2) {
26+ t.Fatal("setup: content not kept")
27+ }
28+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "leaving" {
29+ t.Fatalf("overlay reason %q, want leaving", reason)
30+ }
31+ ov, rest := o.overlayFrame(s, table.Map())
32+ if ov.fb != 0 || len(rest.Windows) != 2 || o.overlayReason != "leaving" {
33+ t.Fatalf("leaving window put on the overlay: %+v rest %d reason %q", ov, len(rest.Windows), o.overlayReason)
34+ }
35+ // Without the kept flag the same scene goes on the plane: the
36+ // refusal is the flag's.
37+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
38+ t.Fatalf("control: reason %q", reason)
39+ }
40+ // A new content of a remapped window frees it again.
41+ table.Update(c[2], s)
42+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "" {
43+ t.Fatalf("remapped window refused: %q", reason)
44+ }
45+ })
46+ t.Run("direct scanout of a lone leaving fullscreen window", func(t *testing.T) {
47+ s, c := fullscreenScene()
48+ table := surfaces.New()
49+ for _, cc := range c {
50+ table.Update(cc, s)
51+ }
52+ id := s.Windows[0].ID
53+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "" {
54+ t.Fatalf("control: reason %q", reason)
55+ }
56+ table.Update(ports.SurfaceContent{ID: id}, s)
57+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "leaving" {
58+ t.Fatalf("scanout reason %q, want leaving", reason)
59+ }
60+ // Through the output: composed, reason logged.
61+ o, _, _ := testOutput(t)
62+ o.kept = table.Kept
63+ o.scanout = true
64+ if fb, _, _ := o.scanoutFrame(s, table.Map()); fb != 0 || o.reason != "leaving" {
65+ t.Fatalf("direct scanout of a leaving window: fb %d reason %q", fb, o.reason)
66+ }
67+ })
68+ t.Run("not reported shown", func(t *testing.T) {
69+ o, _, _ := testOutput(t)
70+ table := surfaces.New()
71+ o.kept = table.Kept
72+ s, c := overlayScene()
73+ table.Update(c[2], s)
74+ seen := map[ports.WindowID]uint64{2: 3}
75+ if shows := o.shownBy(s, seen); shows[2] != 3 {
76+ t.Fatalf("control: shows %v", shows)
77+ }
78+ table.Update(ports.SurfaceContent{ID: 2, Seq: 4}, s)
79+ seen[2] = 4
80+ if shows := o.shownBy(s, seen); len(shows) != 0 {
81+ t.Fatalf("a leaving window is reported shown: %v", shows)
82+ }
83+ })
84+}
M internal/adapters/drm/output.go +5 -0
@@ -36,6 +36,10 @@ type Output struct {
3636 securityInvalid bool
3737 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
3838 runContext context.Context // bounds compositor clear waits during Run startup
39+ // kept reports a window drawn from a content its client withdrew (it
40+ // closed and fades out; surfaces.Table.Kept): composed only, never on
41+ // a plane, never reported shown. Set by Run; nil before.
42+ kept func(ports.WindowID) bool
3943
4044 k kms
4145 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -1031,6 +1035,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10311035 }
10321036 table := surfaces.New()
10331037 drawn := table.Map()
1038+ o.kept = table.Kept
10341039 var scene ports.Scene
10351040 haveScene, dirty := false, false
10361041 var want ports.CursorChange
@@ -36,6 +36,10 @@ type Output struct {
36 securityInvalid bool36 securityInvalid bool
37 inactiveOnClose bool // affirmative terminal KMS result, owned by Close37 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
38 runContext context.Context // bounds compositor clear waits during Run startup38 runContext context.Context // bounds compositor clear waits during Run startup
39+ // kept reports a window drawn from a content its client withdrew (it
40+ // closed and fades out; surfaces.Table.Kept): composed only, never on
41+ // a plane, never reported shown. Set by Run; nil before.
42+ kept func(ports.WindowID) bool
39 43
40 k kms44 k kms
41 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents45 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -1031,6 +1035,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1031 }1035 }
1032 table := surfaces.New()1036 table := surfaces.New()
1033 drawn := table.Map()1037 drawn := table.Map()
1038+ o.kept = table.Kept
1034 var scene ports.Scene1039 var scene ports.Scene
1035 haveScene, dirty := false, false1040 haveScene, dirty := false, false
1036 var want ports.CursorChange1041 var want ports.CursorChange
M internal/adapters/drm/overlay.go +14 -2
@@ -59,7 +59,10 @@ type overlayWin struct {
5959 // overlayCandidate finds the one window the overlay can show: an opaque
6060 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
6161 // or layer above it, and the plane colour mode it needs. reason is why none.
62-func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
62+//
63+// kept is scanoutCandidate's: a window drawn from a withdrawn content is
64+// composed, never put on the plane.
65+func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
6366 if s.Transform != 0 {
6467 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
6568 }
@@ -85,7 +88,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8588 // window (a peeking stashed one) needs the veil drawn over it, a
8689 // fading one its translucency, and one with the focus effect its
8790 // lift; an overview preview is drawn smaller than its buffer.
88- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
91+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
8992 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
9093 pick, mode = w, m
9194 continue
@@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
97100 }
98101 }
99102 if pick == nil {
103+ if kept != nil && len(s.Windows) > 0 {
104+ // The one window that could have gone on the plane fades out
105+ // after its close: composed from its kept content.
106+ for i := range s.Windows {
107+ if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) {
108+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving"
109+ }
110+ }
111+ }
100112 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
101113 }
102114 if s.Dim > 0 {
@@ -59,7 +59,10 @@ type overlayWin struct {
59 // overlayCandidate finds the one window the overlay can show: an opaque59 // overlayCandidate finds the one window the overlay can show: an opaque
60 // dmabuf at integer physical coordinates, drawn 1:1, with no other window60 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
61 // or layer above it, and the plane colour mode it needs. reason is why none.61 // or layer above it, and the plane colour mode it needs. reason is why none.
62-func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {62+//
63+// kept is scanoutCandidate's: a window drawn from a withdrawn content is
64+// composed, never put on the plane.
65+func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
63 if s.Transform != 0 {66 if s.Transform != 0 {
64 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"67 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
65 }68 }
@@ -85,7 +88,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
85 // window (a peeking stashed one) needs the veil drawn over it, a88 // window (a peeking stashed one) needs the veil drawn over it, a
86 // fading one its translucency, and one with the focus effect its89 // fading one its translucency, and one with the focus effect its
87 // lift; an overview preview is drawn smaller than its buffer.90 // lift; an overview preview is drawn smaller than its buffer.
88- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {91+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {92 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
90 pick, mode = w, m93 pick, mode = w, m
91 continue94 continue
@@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
97 }100 }
98 }101 }
99 if pick == nil {102 if pick == nil {
103+ if kept != nil && len(s.Windows) > 0 {
104+ // The one window that could have gone on the plane fades out
105+ // after its close: composed from its kept content.
106+ for i := range s.Windows {
107+ if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) {
108+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving"
109+ }
110+ }
111+ }
100 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"112 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
101 }113 }
102 if s.Dim > 0 {114 if s.Dim > 0 {
M internal/adapters/drm/overlay_test.go +20 -20
@@ -43,7 +43,7 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
4343 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
4444 s, contents := fullscreenScene()
4545 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
46- if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" {
46+ if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "workspace_clip" {
4747 t.Fatalf("fullscreen bypassed clip: %q", reason)
4848 }
4949 if fullscreenShown(&s) {
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
5454 func TestOverlayWorkspaceClip(t *testing.T) {
5555 s, contents := overlayScene()
5656 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57- if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" {
57+ if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "workspace_clip" {
5858 t.Fatalf("overflowing workspace overlay: %q", reason)
5959 }
6060 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61- if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 {
61+ if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
6262 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
6363 }
6464 }
6565
6666 func TestOverlayCandidate(t *testing.T) {
6767 s, c := overlayScene()
68- if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 {
68+ if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {
6969 t.Fatalf("candidate %v %q", w.ID, reason)
7070 }
7171 // A rotated output composes: planes are not rotated.
7272 rs, rc := overlayScene()
7373 rs.Transform = 1
74- if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" {
74+ if _, _, _, reason := overlayCandidate(rs, rc, false, nil, nil); reason != "output_transform" {
7575 t.Fatalf("rotated output reason %q", reason)
7676 }
7777 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
78- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
78+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
7979 t.Fatalf("YUV overlay reason %q", reason)
8080 }
8181 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,58 +83,58 @@ func TestOverlayCandidate(t *testing.T) {
8383 rotated := c[2]
8484 rotated.Transform = 1
8585 c[2] = rotated
86- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
86+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
8787 t.Fatalf("transformed overlay reason %q", reason)
8888 }
8989 _, c = overlayScene()
9090 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
91- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
91+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
9292 t.Fatalf("reason %q", reason)
9393 }
9494 s, _ = overlayScene()
9595 s.Scale = 1.5
96- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" {
96+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "scaled" {
9797 t.Fatalf("reason %q", reason)
9898 }
9999 s, _ = overlayScene()
100100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}
101- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" {
101+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "layer_above" {
102102 t.Fatalf("reason %q", reason)
103103 }
104104 // The dim veil is composed under the float: nothing leaves for the plane.
105105 s, _ = overlayScene()
106106 s.Dim = 0.3
107- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" {
107+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "dim" {
108108 t.Fatalf("dim reason %q", reason)
109109 }
110110 // A dimmed window (a peeking stashed one) needs its veil composed.
111111 s, _ = overlayScene()
112112 s.Windows[len(s.Windows)-1].Dim = 0.5
113- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
113+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
114114 t.Fatalf("dimmed window reason %q", reason)
115115 }
116116 // A fading window needs its translucency composed.
117117 s, _ = overlayScene()
118118 s.Windows[len(s.Windows)-1].Fade = 0.5
119- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
119+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
120120 t.Fatalf("fading window reason %q", reason)
121121 }
122122 // A zoomed (animating) window is drawn smaller than its buffer.
123123 s, _ = overlayScene()
124124 s.Windows[len(s.Windows)-1].Zoom = 0.9
125- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
125+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
126126 t.Fatalf("zoomed window reason %q", reason)
127127 }
128128 // A window with the focus effect needs it composed.
129129 s, _ = overlayScene()
130130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
131- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
131+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
132132 t.Fatalf("pulsing window reason %q", reason)
133133 }
134134 // An overview preview is drawn smaller than its buffer: composed.
135135 s, _ = overlayScene()
136136 s.Windows[len(s.Windows)-1].Preview = 0.5
137- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
137+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
138138 t.Fatalf("preview reason %q", reason)
139139 }
140140 // A viewport crop is composed: the plane would show the whole buffer.
@@ -142,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) {
142142 cropped := c[2]
143143 cropped.Source = [4]float32{10, 10, 50, 50}
144144 c[2] = cropped
145- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
145+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
146146 t.Fatalf("cropped overlay reason %q", reason)
147147 }
148148 // A full-buffer source is no crop.
149149 cropped.Source = [4]float32{0, 0, 100, 100}
150150 c[2] = cropped
151- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {
151+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
152152 t.Fatalf("full source reason %q", reason)
153153 }
154154 }
@@ -224,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
224224 func TestOverlayPopupAndBorder(t *testing.T) {
225225 s, c := overlayScene()
226226 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
227- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
227+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
228228 t.Fatalf("popup: %q", reason)
229229 }
230230 s, _ = overlayScene()
231231 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
232- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" {
232+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "border" {
233233 t.Fatalf("border: %q", reason)
234234 }
235235 }
@@ -43,7 +43,7 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
43 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {43 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
44 s, contents := fullscreenScene()44 s, contents := fullscreenScene()
45 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}45 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
46- if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" {46+ if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "workspace_clip" {
47 t.Fatalf("fullscreen bypassed clip: %q", reason)47 t.Fatalf("fullscreen bypassed clip: %q", reason)
48 }48 }
49 if fullscreenShown(&s) {49 if fullscreenShown(&s) {
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
54 func TestOverlayWorkspaceClip(t *testing.T) {54 func TestOverlayWorkspaceClip(t *testing.T) {
55 s, contents := overlayScene()55 s, contents := overlayScene()
56 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}56 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57- if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" {57+ if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "workspace_clip" {
58 t.Fatalf("overflowing workspace overlay: %q", reason)58 t.Fatalf("overflowing workspace overlay: %q", reason)
59 }59 }
60 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}60 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61- if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 {61+ if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
63 }63 }
64 }64 }
65 65
66 func TestOverlayCandidate(t *testing.T) {66 func TestOverlayCandidate(t *testing.T) {
67 s, c := overlayScene()67 s, c := overlayScene()
68- if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 {68+ if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {
69 t.Fatalf("candidate %v %q", w.ID, reason)69 t.Fatalf("candidate %v %q", w.ID, reason)
70 }70 }
71 // A rotated output composes: planes are not rotated.71 // A rotated output composes: planes are not rotated.
72 rs, rc := overlayScene()72 rs, rc := overlayScene()
73 rs.Transform = 173 rs.Transform = 1
74- if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" {74+ if _, _, _, reason := overlayCandidate(rs, rc, false, nil, nil); reason != "output_transform" {
75 t.Fatalf("rotated output reason %q", reason)75 t.Fatalf("rotated output reason %q", reason)
76 }76 }
77 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}77 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
78- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {78+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
79 t.Fatalf("YUV overlay reason %q", reason)79 t.Fatalf("YUV overlay reason %q", reason)
80 }80 }
81 // A transformed buffer is composed: the plane would show it unrotated.81 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,58 +83,58 @@ func TestOverlayCandidate(t *testing.T) {
83 rotated := c[2]83 rotated := c[2]
84 rotated.Transform = 184 rotated.Transform = 1
85 c[2] = rotated85 c[2] = rotated
86- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {86+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
87 t.Fatalf("transformed overlay reason %q", reason)87 t.Fatalf("transformed overlay reason %q", reason)
88 }88 }
89 _, c = overlayScene()89 _, c = overlayScene()
90 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})90 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
91- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {91+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
92 t.Fatalf("reason %q", reason)92 t.Fatalf("reason %q", reason)
93 }93 }
94 s, _ = overlayScene()94 s, _ = overlayScene()
95 s.Scale = 1.595 s.Scale = 1.5
96- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" {96+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "scaled" {
97 t.Fatalf("reason %q", reason)97 t.Fatalf("reason %q", reason)
98 }98 }
99 s, _ = overlayScene()99 s, _ = overlayScene()
100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}
101- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" {101+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "layer_above" {
102 t.Fatalf("reason %q", reason)102 t.Fatalf("reason %q", reason)
103 }103 }
104 // The dim veil is composed under the float: nothing leaves for the plane.104 // The dim veil is composed under the float: nothing leaves for the plane.
105 s, _ = overlayScene()105 s, _ = overlayScene()
106 s.Dim = 0.3106 s.Dim = 0.3
107- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" {107+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "dim" {
108 t.Fatalf("dim reason %q", reason)108 t.Fatalf("dim reason %q", reason)
109 }109 }
110 // A dimmed window (a peeking stashed one) needs its veil composed.110 // A dimmed window (a peeking stashed one) needs its veil composed.
111 s, _ = overlayScene()111 s, _ = overlayScene()
112 s.Windows[len(s.Windows)-1].Dim = 0.5112 s.Windows[len(s.Windows)-1].Dim = 0.5
113- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {113+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
114 t.Fatalf("dimmed window reason %q", reason)114 t.Fatalf("dimmed window reason %q", reason)
115 }115 }
116 // A fading window needs its translucency composed.116 // A fading window needs its translucency composed.
117 s, _ = overlayScene()117 s, _ = overlayScene()
118 s.Windows[len(s.Windows)-1].Fade = 0.5118 s.Windows[len(s.Windows)-1].Fade = 0.5
119- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {119+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
120 t.Fatalf("fading window reason %q", reason)120 t.Fatalf("fading window reason %q", reason)
121 }121 }
122 // A zoomed (animating) window is drawn smaller than its buffer.122 // A zoomed (animating) window is drawn smaller than its buffer.
123 s, _ = overlayScene()123 s, _ = overlayScene()
124 s.Windows[len(s.Windows)-1].Zoom = 0.9124 s.Windows[len(s.Windows)-1].Zoom = 0.9
125- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {125+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
126 t.Fatalf("zoomed window reason %q", reason)126 t.Fatalf("zoomed window reason %q", reason)
127 }127 }
128 // A window with the focus effect needs it composed.128 // A window with the focus effect needs it composed.
129 s, _ = overlayScene()129 s, _ = overlayScene()
130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
131- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {131+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
132 t.Fatalf("pulsing window reason %q", reason)132 t.Fatalf("pulsing window reason %q", reason)
133 }133 }
134 // An overview preview is drawn smaller than its buffer: composed.134 // An overview preview is drawn smaller than its buffer: composed.
135 s, _ = overlayScene()135 s, _ = overlayScene()
136 s.Windows[len(s.Windows)-1].Preview = 0.5136 s.Windows[len(s.Windows)-1].Preview = 0.5
137- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {137+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
138 t.Fatalf("preview reason %q", reason)138 t.Fatalf("preview reason %q", reason)
139 }139 }
140 // A viewport crop is composed: the plane would show the whole buffer.140 // A viewport crop is composed: the plane would show the whole buffer.
@@ -142,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) {
142 cropped := c[2]142 cropped := c[2]
143 cropped.Source = [4]float32{10, 10, 50, 50}143 cropped.Source = [4]float32{10, 10, 50, 50}
144 c[2] = cropped144 c[2] = cropped
145- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {145+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
146 t.Fatalf("cropped overlay reason %q", reason)146 t.Fatalf("cropped overlay reason %q", reason)
147 }147 }
148 // A full-buffer source is no crop.148 // A full-buffer source is no crop.
149 cropped.Source = [4]float32{0, 0, 100, 100}149 cropped.Source = [4]float32{0, 0, 100, 100}
150 c[2] = cropped150 c[2] = cropped
151- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {151+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
152 t.Fatalf("full source reason %q", reason)152 t.Fatalf("full source reason %q", reason)
153 }153 }
154 }154 }
@@ -224,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
224 func TestOverlayPopupAndBorder(t *testing.T) {224 func TestOverlayPopupAndBorder(t *testing.T) {
225 s, c := overlayScene()225 s, c := overlayScene()
226 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)226 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
227- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {227+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
228 t.Fatalf("popup: %q", reason)228 t.Fatalf("popup: %q", reason)
229 }229 }
230 s, _ = overlayScene()230 s, _ = overlayScene()
231 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}231 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
232- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" {232+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "border" {
233 t.Fatalf("border: %q", reason)233 t.Fatalf("border: %q", reason)
234 }234 }
235 }235 }
M internal/adapters/drm/report.go +3 -1
@@ -30,7 +30,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
3030 }
3131 clear(o.showsScratch)
3232 for id, seq := range seen {
33- if s.Shows(id) {
33+ // A closed window drawn from its kept content is not shown to its
34+ // client: the empty content has no frame to present.
35+ if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
3436 o.showsScratch[id] = seq
3537 }
3638 }
@@ -30,7 +30,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
30 }30 }
31 clear(o.showsScratch)31 clear(o.showsScratch)
32 for id, seq := range seen {32 for id, seq := range seen {
33- if s.Shows(id) {33+ // A closed window drawn from its kept content is not shown to its
34+ // client: the empty content has no frame to present.
35+ if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
34 o.showsScratch[id] = seq36 o.showsScratch[id] = seq
35 }37 }
36 }38 }
M internal/adapters/drm/scanout.go +10 -3
@@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool {
8585 // scanoutCandidate returns the window content the output can scan out
8686 // directly, or a reason why it cannot. w, h are the output's physical size.
8787 // The plane's formats are checked by scanoutFB.
88-func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
88+//
89+// kept reports a window drawn from a content its client withdrew (it
90+// closed and fades out): the client may already have its buffer back, so
91+// it is never put on a plane (nil: none is).
92+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
8993 if s.Transform != 0 {
9094 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
9195 return ports.SurfaceContent{}, "output_transform"
@@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107111 if full == nil {
108112 return ports.SurfaceContent{}, "no_fullscreen"
109113 }
114+ if kept != nil && kept(full.ID) {
115+ return ports.SurfaceContent{}, "leaving"
116+ }
110117 if len(s.DropHints) > 0 {
111118 // Drag hints are drawn over the window.
112119 return ports.SurfaceContent{}, "drop_hint"
@@ -205,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
205212 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
206213 reason := "disabled"
207214 if o.scanout {
208- c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
215+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height(), o.kept)
209216 if reason == "" {
210217 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
211218 }
@@ -260,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
260267 var w ports.SceneWindow
261268 var c ports.SurfaceContent
262269 var mode colorMode
263- w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline)
270+ w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline, o.kept)
264271 if reason == "" && mode != colorBypass {
265272 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
266273 reason = "color_refused"
@@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool {
85 // scanoutCandidate returns the window content the output can scan out85 // scanoutCandidate returns the window content the output can scan out
86 // directly, or a reason why it cannot. w, h are the output's physical size.86 // directly, or a reason why it cannot. w, h are the output's physical size.
87 // The plane's formats are checked by scanoutFB.87 // The plane's formats are checked by scanoutFB.
88-func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {88+//
89+// kept reports a window drawn from a content its client withdrew (it
90+// closed and fades out): the client may already have its buffer back, so
91+// it is never put on a plane (nil: none is).
92+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
89 if s.Transform != 0 {93 if s.Transform != 0 {
90 // The scanout buffer would have to be pre-rotated: rotated outputs compose.94 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
91 return ports.SurfaceContent{}, "output_transform"95 return ports.SurfaceContent{}, "output_transform"
@@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107 if full == nil {111 if full == nil {
108 return ports.SurfaceContent{}, "no_fullscreen"112 return ports.SurfaceContent{}, "no_fullscreen"
109 }113 }
114+ if kept != nil && kept(full.ID) {
115+ return ports.SurfaceContent{}, "leaving"
116+ }
110 if len(s.DropHints) > 0 {117 if len(s.DropHints) > 0 {
111 // Drag hints are drawn over the window.118 // Drag hints are drawn over the window.
112 return ports.SurfaceContent{}, "drop_hint"119 return ports.SurfaceContent{}, "drop_hint"
@@ -205,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
205 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {212 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
206 reason := "disabled"213 reason := "disabled"
207 if o.scanout {214 if o.scanout {
208- c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())215+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height(), o.kept)
209 if reason == "" {216 if reason == "" {
210 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)217 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
211 }218 }
@@ -260,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
260 var w ports.SceneWindow267 var w ports.SceneWindow
261 var c ports.SurfaceContent268 var c ports.SurfaceContent
262 var mode colorMode269 var mode colorMode
263- w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline)270+ w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline, o.kept)
264 if reason == "" && mode != colorBypass {271 if reason == "" && mode != colorBypass {
265 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {272 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
266 reason = "color_refused"273 reason = "color_refused"
M internal/adapters/drm/scanout_test.go +4 -4
@@ -76,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) {
7676 if tc.cont != nil {
7777 tc.cont(&c)
7878 }
79- if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
79+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100, nil); reason != tc.reason {
8080 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
8181 }
8282 }
@@ -105,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
105105 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
106106 Geometry: ports.Rect{W: 100, H: 50},
107107 Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}
108- c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100)
108+ c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100, nil)
109109 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
110110 t.Fatalf("candidate %+v, reason %q", c, reason)
111111 }
@@ -114,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
114114 // one; alone it is not a dmabuf.
115115 solidRoot := root
116116 solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}
117- c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100)
117+ c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil)
118118 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
119119 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
120120 }
121121 solidRoot.Children = nil
122- if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" {
122+ if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil); reason != "not_dmabuf" {
123123 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
124124 }
125125 }
@@ -76,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) {
76 if tc.cont != nil {76 if tc.cont != nil {
77 tc.cont(&c)77 tc.cont(&c)
78 }78 }
79- if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {79+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100, nil); reason != tc.reason {
80 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)80 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
81 }81 }
82 }82 }
@@ -105,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
105 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},105 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
106 Geometry: ports.Rect{W: 100, H: 50},106 Geometry: ports.Rect{W: 100, H: 50},
107 Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}107 Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}
108- c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100)108+ c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100, nil)
109 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {109 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
110 t.Fatalf("candidate %+v, reason %q", c, reason)110 t.Fatalf("candidate %+v, reason %q", c, reason)
111 }111 }
@@ -114,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
114 // one; alone it is not a dmabuf.114 // one; alone it is not a dmabuf.
115 solidRoot := root115 solidRoot := root
116 solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}116 solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}
117- c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100)117+ c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil)
118 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {118 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
119 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)119 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
120 }120 }
121 solidRoot.Children = nil121 solidRoot.Children = nil
122- if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" {122+ if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil); reason != "not_dmabuf" {
123 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)123 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
124 }124 }
125 }125 }
A internal/adapters/headless/leaving_test.go +25 -0
@@ -0,0 +1,25 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (closed, fading out) is
11+// not reported shown: the empty content has no frame to present.
12+func TestFlipInfoSkipsKeptContent(t *testing.T) {
13+ table := surfaces.New()
14+ s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}}
15+ table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s)
16+ 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+ t.Fatalf("control: %v", f.Shows)
19+ }
20+ table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s)
21+ f := flipInfo(s, table.Map(), table.Kept)
22+ if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 {
23+ t.Fatalf("a leaving window is reported shown: %v", f.Shows)
24+ }
25+}
@@ -0,0 +1,25 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (closed, fading out) is
11+// not reported shown: the empty content has no frame to present.
12+func TestFlipInfoSkipsKeptContent(t *testing.T) {
13+ table := surfaces.New()
14+ s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}}
15+ table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s)
16+ 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+ t.Fatalf("control: %v", f.Shows)
19+ }
20+ table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s)
21+ f := flipInfo(s, table.Map(), table.Kept)
22+ if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 {
23+ t.Fatalf("a leaving window is reported shown: %v", f.Shows)
24+ }
25+}
M internal/adapters/headless/output.go +5 -3
@@ -579,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
579579 pipeline.EndGate(nil)
580580 requests = capture.Waiting(requests, scene)
581581 frame++
582- opts.report(&reports, flipInfo(scene, drawn), seen)
582+ opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583583 if opts.ScreenshotDir != "" && !opts.protected() {
584584 shot := r.Pixels()
585585 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -671,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) {
671671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672672 // time (software clock, refresh unknown), so presentation feedback and
673673 // frame callbacks follow headless frames.
674-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo {
674+func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675675 var ts unix.Timespec
676676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677677 shows := map[ports.WindowID]uint64{}
678678 for id, c := range surfaces {
679- if scene.Shows(id) {
679+ // A closed window drawn from its kept content is not shown to
680+ // its client.
681+ if scene.Shows(id) && !kept(id) {
680682 shows[id] = c.Seq
681683 }
682684 }
@@ -579,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
579 pipeline.EndGate(nil)579 pipeline.EndGate(nil)
580 requests = capture.Waiting(requests, scene)580 requests = capture.Waiting(requests, scene)
581 frame++581 frame++
582- opts.report(&reports, flipInfo(scene, drawn), seen)582+ opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583 if opts.ScreenshotDir != "" && !opts.protected() {583 if opts.ScreenshotDir != "" && !opts.protected() {
584 shot := r.Pixels()584 shot := r.Pixels()
585 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {585 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -671,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) {
671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672 // time (software clock, refresh unknown), so presentation feedback and672 // time (software clock, refresh unknown), so presentation feedback and
673 // frame callbacks follow headless frames.673 // frame callbacks follow headless frames.
674-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo {674+func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675 var ts unix.Timespec675 var ts unix.Timespec
676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677 shows := map[ports.WindowID]uint64{}677 shows := map[ports.WindowID]uint64{}
678 for id, c := range surfaces {678 for id, c := range surfaces {
679- if scene.Shows(id) {679+ // A closed window drawn from its kept content is not shown to
680+ // its client.
681+ if scene.Shows(id) && !kept(id) {
680 shows[id] = c.Seq682 shows[id] = c.Seq
681 }683 }
682 }684 }
M internal/adapters/surfaces/surfaces.go +28 -7
@@ -20,12 +20,21 @@ import (
2020 // content waited on it, and wayland closes it once the buffer is released.
2121 //
2222 // Buffer lifetime: wayland releases the buffer to the client once the
23-// output reports the empty content as seen, so the client may draw into or
24-// destroy it during the fade. A dmabuf stays importable through the
25-// renderer's own duplicated descriptors (a destroyed one shows as stale
26-// pixels at worst); a wl_shm pool stays mapped by the renderer while it is
27-// drawn, so a destroyed pool is still readable (a client that truncates the
28-// file under a mapping has always been able to fault a reader).
23+// output reports the empty content as seen, so the client may draw into,
24+// destroy or reuse it during the fade. A kept content is therefore only
25+// ever composed by the renderer, from what it already holds: the dmabuf
26+// import (its own duplicated descriptors keep the memory alive; a reused
27+// buffer shows stale pixels at worst) or the GPU copy of the wl_shm buffer
28+// (the GPU never reads the pool mapping, and a pool's fd close does not
29+// unmap it). A kept content is never scanned out on a plane and never
30+// reported as shown: outputs ask Kept. Window capture never reaches it:
31+// core resolves a window session through its workspace, which the closed
32+// window has left, so the session ends (CaptureReasonWindowGone) before
33+// the leaving scene; an output or workspace capture composes the fading
34+// window like the screen does.
35+// Every Acquire fence of the kept content (root and children) is dropped:
36+// the frame that first drew it waited on them, and wayland closes them
37+// when the buffers are released.
2938 type Table struct {
3039 m map[ports.WindowID]ports.SurfaceContent
3140 // kept are the windows drawn from a content their client withdrew.
@@ -57,6 +66,16 @@ func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) {
5766 return
5867 }
5968 prev.Acquire = nil
69+ if len(prev.Children) > 0 {
70+ // The slice is shared with wayland's publication: copy before
71+ // zeroing (one allocation per unmap).
72+ children := make([]ports.Subsurface, len(prev.Children))
73+ copy(children, prev.Children)
74+ for i := range children {
75+ children[i].Acquire = nil
76+ }
77+ prev.Children = children
78+ }
6079 t.m[c.ID] = prev
6180 t.kept[c.ID] = struct{}{}
6281 }
@@ -72,7 +91,9 @@ func (t *Table) Prune(scene ports.Scene) {
7291 }
7392 }
7493
75-// Kept reports whether id is drawn from a content its client withdrew.
94+// Kept reports whether id is drawn from a content its client withdrew: a
95+// buffer the client may have got back. Outputs compose it only: no plane
96+// scans it out, no capture or presentation report counts it as shown.
7697 func (t *Table) Kept(id ports.WindowID) bool {
7798 _, ok := t.kept[id]
7899 return ok
@@ -20,12 +20,21 @@ import (
20 // content waited on it, and wayland closes it once the buffer is released.20 // content waited on it, and wayland closes it once the buffer is released.
21 //21 //
22 // Buffer lifetime: wayland releases the buffer to the client once the22 // Buffer lifetime: wayland releases the buffer to the client once the
23-// output reports the empty content as seen, so the client may draw into or23+// output reports the empty content as seen, so the client may draw into,
24-// destroy it during the fade. A dmabuf stays importable through the24+// destroy or reuse it during the fade. A kept content is therefore only
25-// renderer's own duplicated descriptors (a destroyed one shows as stale25+// ever composed by the renderer, from what it already holds: the dmabuf
26-// pixels at worst); a wl_shm pool stays mapped by the renderer while it is26+// import (its own duplicated descriptors keep the memory alive; a reused
27-// drawn, so a destroyed pool is still readable (a client that truncates the27+// buffer shows stale pixels at worst) or the GPU copy of the wl_shm buffer
28-// file under a mapping has always been able to fault a reader).28+// (the GPU never reads the pool mapping, and a pool's fd close does not
29+// unmap it). A kept content is never scanned out on a plane and never
30+// reported as shown: outputs ask Kept. Window capture never reaches it:
31+// core resolves a window session through its workspace, which the closed
32+// window has left, so the session ends (CaptureReasonWindowGone) before
33+// the leaving scene; an output or workspace capture composes the fading
34+// window like the screen does.
35+// Every Acquire fence of the kept content (root and children) is dropped:
36+// the frame that first drew it waited on them, and wayland closes them
37+// when the buffers are released.
29 type Table struct {38 type Table struct {
30 m map[ports.WindowID]ports.SurfaceContent39 m map[ports.WindowID]ports.SurfaceContent
31 // kept are the windows drawn from a content their client withdrew.40 // kept are the windows drawn from a content their client withdrew.
@@ -57,6 +66,16 @@ func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) {
57 return66 return
58 }67 }
59 prev.Acquire = nil68 prev.Acquire = nil
69+ if len(prev.Children) > 0 {
70+ // The slice is shared with wayland's publication: copy before
71+ // zeroing (one allocation per unmap).
72+ children := make([]ports.Subsurface, len(prev.Children))
73+ copy(children, prev.Children)
74+ for i := range children {
75+ children[i].Acquire = nil
76+ }
77+ prev.Children = children
78+ }
60 t.m[c.ID] = prev79 t.m[c.ID] = prev
61 t.kept[c.ID] = struct{}{}80 t.kept[c.ID] = struct{}{}
62 }81 }
@@ -72,7 +91,9 @@ func (t *Table) Prune(scene ports.Scene) {
72 }91 }
73 }92 }
74 93
75-// Kept reports whether id is drawn from a content its client withdrew.94+// Kept reports whether id is drawn from a content its client withdrew: a
95+// buffer the client may have got back. Outputs compose it only: no plane
96+// scans it out, no capture or presentation report counts it as shown.
76 func (t *Table) Kept(id ports.WindowID) bool {97 func (t *Table) Kept(id ports.WindowID) bool {
77 _, ok := t.kept[id]98 _, ok := t.kept[id]
78 return ok99 return ok
M internal/adapters/surfaces/surfaces_test.go +23 -0
@@ -99,3 +99,26 @@ func TestUpdateAllocations(t *testing.T) {
9999 t.Fatalf("%v allocations per update", n)
100100 }
101101 }
102+
103+// The kept content's children lose their Acquire fences too, on a copy:
104+// wayland's own slice (shared with its publication) is left alone.
105+func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
106+ tb := New()
107+ r, w, err := os.Pipe()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer r.Close()
112+ defer w.Close()
113+ children := []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 2}, Acquire: r}}}
114+ full := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}, Children: children}
115+ tb.Update(full, shows(1))
116+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1))
117+ kept := tb.Map()[1]
118+ if len(kept.Children) != 1 || kept.Children[0].Acquire != nil || kept.Children[0].DMABuf != children[0].DMABuf {
119+ t.Fatalf("kept children %+v", kept.Children)
120+ }
121+ if children[0].Acquire != r {
122+ t.Fatal("wayland's slice was modified")
123+ }
124+}
@@ -99,3 +99,26 @@ func TestUpdateAllocations(t *testing.T) {
99 t.Fatalf("%v allocations per update", n)99 t.Fatalf("%v allocations per update", n)
100 }100 }
101 }101 }
102+
103+// The kept content's children lose their Acquire fences too, on a copy:
104+// wayland's own slice (shared with its publication) is left alone.
105+func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
106+ tb := New()
107+ r, w, err := os.Pipe()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer r.Close()
112+ defer w.Close()
113+ children := []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 2}, Acquire: r}}}
114+ full := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}, Children: children}
115+ tb.Update(full, shows(1))
116+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1))
117+ kept := tb.Map()[1]
118+ if len(kept.Children) != 1 || kept.Children[0].Acquire != nil || kept.Children[0].DMABuf != children[0].DMABuf {
119+ t.Fatalf("kept children %+v", kept.Children)
120+ }
121+ if children[0].Acquire != r {
122+ t.Fatal("wayland's slice was modified")
123+ }
124+}
M internal/core/configures.go +11 -1
@@ -131,9 +131,19 @@ func (s *configures) mark(v ports.ConfigureWindow) {
131131 }
132132
133133 // keep marks id as seen without a configure: a window drawn while it leaves
134-// keeps what was sent to it.
134+// keeps what was sent to it. Only for a window the layout still holds (a
135+// hidden stash window): a destroyed one is forgotten (forget).
135136 func (s *configures) keep(id WindowID) { s.seen[id] = true }
136137
138+// forget drops what was sent to id: the window is destroyed. xdg reuses
139+// window IDs, so a remapped one must start from nothing, or an unchanged
140+// first configure would be withheld.
141+func (s *configures) forget(id WindowID) {
142+ delete(s.sent, id)
143+ delete(s.answer, id)
144+ delete(s.seen, id)
145+}
146+
137147 // prune forgets the windows no next call saw since the last prune: they
138148 // left the layout.
139149 func (s *configures) prune() {
@@ -131,9 +131,19 @@ func (s *configures) mark(v ports.ConfigureWindow) {
131 }131 }
132 132
133 // keep marks id as seen without a configure: a window drawn while it leaves133 // keep marks id as seen without a configure: a window drawn while it leaves
134-// keeps what was sent to it.134+// keeps what was sent to it. Only for a window the layout still holds (a
135+// hidden stash window): a destroyed one is forgotten (forget).
135 func (s *configures) keep(id WindowID) { s.seen[id] = true }136 func (s *configures) keep(id WindowID) { s.seen[id] = true }
136 137
138+// forget drops what was sent to id: the window is destroyed. xdg reuses
139+// window IDs, so a remapped one must start from nothing, or an unchanged
140+// first configure would be withheld.
141+func (s *configures) forget(id WindowID) {
142+ delete(s.sent, id)
143+ delete(s.answer, id)
144+ delete(s.seen, id)
145+}
146+
137 // prune forgets the windows no next call saw since the last prune: they147 // prune forgets the windows no next call saw since the last prune: they
138 // left the layout.148 // left the layout.
139 func (s *configures) prune() {149 func (s *configures) prune() {
M internal/core/transition.go +3 -0
@@ -434,6 +434,9 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
434434 // is the same with animations off. A fullscreen window, one under a
435435 // session lock or with the overview open just goes.
436436 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
437+ // The window is destroyed: its ID may come back for another one, which
438+ // must be configured from nothing (a leaving entry keeps no configure).
439+ c.configures.forget(v.ID)
437440 sc, w := c.screenOf(v.ID)
438441 if sc == nil {
439442 return
@@ -434,6 +434,9 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
434 // is the same with animations off. A fullscreen window, one under a434 // is the same with animations off. A fullscreen window, one under a
435 // session lock or with the overview open just goes.435 // session lock or with the overview open just goes.
436 func (c *Core) unmapWindow(v ports.WindowUnmapped) {436 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
437+ // The window is destroyed: its ID may come back for another one, which
438+ // must be configured from nothing (a leaving entry keeps no configure).
439+ c.configures.forget(v.ID)
437 sc, w := c.screenOf(v.ID)440 sc, w := c.screenOf(v.ID)
438 if sc == nil {441 if sc == nil {
439 return442 return
M internal/core/transition_unmap_internal_test.go +116 -9
@@ -1,11 +1,13 @@
11 package core
22
33 import (
4+ "context"
45 "math"
56 "slices"
67 "testing"
78 "time"
89
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
911 "github.com/bnema/neferwl/internal/ports"
1012 )
1113
@@ -282,17 +284,122 @@ func TestUnmapExceptions(t *testing.T) {
282284 })
283285 }
284286
285-// A session lock during the fade drops the leaving entry with the other
286-// motions (stopRects), as do animations turned off.
287-func TestUnmapLeavingDroppedByLock(t *testing.T) {
288- c, _, _, sc := twoSettled(t)
287+// An interruption during the fade drops the leaving entry with the other
288+// motions (stopRects): a real session lock, an output unplug, an overview
289+// toggle and a workspace switch. Nothing of the closed window is left.
290+func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
291+ leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) {
292+ t.Helper()
293+ c, ic, _, sc := twoSettled(t)
294+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
295+ if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
296+ t.Fatal("no leaving entry")
297+ }
298+ return c, ic, sc
299+ }
300+ gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) {
301+ t.Helper()
302+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
303+ t.Fatalf("the leaving entry survived: %+v", s.Windows)
304+ }
305+ // Other motions may run (the overview's cards): none for 2.
306+ if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
307+ t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown)
308+ }
309+ }
310+ t.Run("session lock", func(t *testing.T) {
311+ c, _, sc := leaving(t)
312+ state := ports.SecurityState{Generation: 1, Protected: true}
313+ gate := portsmocks.NewMockSessionSecurity(t)
314+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315+ c.ch.Security = gate
316+ if err := c.publish(context.Background()); err != nil {
317+ t.Fatal(err)
318+ }
319+ locked := (<-c.ch.Scenes)[0]
320+ if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() {
321+ t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating())
322+ }
323+ state = ports.SecurityState{Generation: 2}
324+ gone(t, c, sc, indicatorScene(t, c))
325+ })
326+ t.Run("unplug", func(t *testing.T) {
327+ c, _, sc := leaving(t)
328+ c.removeScreen("A")
329+ if c.animating() || len(sc.rects) != 0 {
330+ t.Fatalf("motions survived the unplug: %d", len(sc.rects))
331+ }
332+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
333+ gone(t, c, c.cur(), indicatorScene(t, c))
334+ })
335+ t.Run("overview toggle", func(t *testing.T) {
336+ c, ic, sc := leaving(t)
337+ act(c, ic, ActionToggleOverview)
338+ gone(t, c, sc, indicatorScene(t, c))
339+ })
340+ t.Run("workspace switch", func(t *testing.T) {
341+ c, ic, sc := leaving(t)
342+ act(c, ic, ActionFocusWorkspaceDown)
343+ gone(t, c, sc, indicatorScene(t, c))
344+ })
345+}
346+
347+// A window ID reused by a new toplevel while the old one fades out (xdg
348+// reuses IDs): the leaving entry goes, the new window appears from where the
349+// old one was drawn (appearFrom), is listed once, and gets its tile
350+// configure (the destroyed window's last configure is forgotten).
351+func TestUnmapThenRemapDuringFade(t *testing.T) {
352+ c, ic, cmds, sc := twoSettled(t)
289353 c.unmapWindow(ports.WindowUnmapped{ID: 2})
290- if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
291- t.Fatal("no leaving entry")
354+ indicatorScene(t, c)
355+ ic.now = ic.now.Add(40 * time.Millisecond)
356+ c.animate(ic.now, nil)
357+ mid := sceneWindow(t, indicatorScene(t, c), 2)
358+ if !(mid.Fade > 0 && mid.Fade < 1) {
359+ t.Fatalf("not fading: %+v", mid)
360+ }
361+ drainConfigures(cmds)
362+ if _, ok := c.configures.sent[2]; ok {
363+ t.Fatal("the destroyed window's configure is remembered")
292364 }
293- c.stopAnimations()
365+
366+ c.mapWindow(ports.WindowMapped{ID: 2})
294367 s := indicatorScene(t, c)
295- if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 {
296- t.Fatalf("after the stop: %+v", s.Windows)
368+ n := 0
369+ for _, w := range s.Windows {
370+ if w.ID == 2 {
371+ n++
372+ }
373+ }
374+ if n != 1 {
375+ t.Fatalf("window 2 listed %d times: %+v", n, s.Windows)
376+ }
377+ if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) {
378+ t.Fatalf("leaving entry left: %+v", sc.shown)
379+ }
380+ rm, ok := sc.rects[2]
381+ if !ok || rm.leaving {
382+ t.Fatalf("no entrance for the remapped window: %+v", rm)
383+ }
384+ w := sceneWindow(t, s, 2)
385+ // The entrance chains over the exit: drawn as the fade left it, not
386+ // invisible at 90 %.
387+ if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect {
388+ t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid)
389+ }
390+ settled := settledOf(t, sc, 2)
391+ var got []ports.ConfigureWindow
392+ for len(cmds) > 0 {
393+ if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 {
394+ got = append(got, v)
395+ }
396+ }
397+ if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible {
398+ t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect)
399+ }
400+ ic.now = ic.now.Add(5 * time.Second)
401+ c.animate(ic.now, nil)
402+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
403+ t.Fatalf("remapped window settled: %+v", w)
297404 }
298405 }
@@ -1,11 +1,13 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "math"5 "math"
5 "slices"6 "slices"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
10 )12 )
11 13
@@ -282,17 +284,122 @@ func TestUnmapExceptions(t *testing.T) {
282 })284 })
283 }285 }
284 286
285-// A session lock during the fade drops the leaving entry with the other287+// An interruption during the fade drops the leaving entry with the other
286-// motions (stopRects), as do animations turned off.288+// motions (stopRects): a real session lock, an output unplug, an overview
287-func TestUnmapLeavingDroppedByLock(t *testing.T) {289+// toggle and a workspace switch. Nothing of the closed window is left.
288- c, _, _, sc := twoSettled(t)290+func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
291+ leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) {
292+ t.Helper()
293+ c, ic, _, sc := twoSettled(t)
294+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
295+ if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
296+ t.Fatal("no leaving entry")
297+ }
298+ return c, ic, sc
299+ }
300+ gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) {
301+ t.Helper()
302+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
303+ t.Fatalf("the leaving entry survived: %+v", s.Windows)
304+ }
305+ // Other motions may run (the overview's cards): none for 2.
306+ if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
307+ t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown)
308+ }
309+ }
310+ t.Run("session lock", func(t *testing.T) {
311+ c, _, sc := leaving(t)
312+ state := ports.SecurityState{Generation: 1, Protected: true}
313+ gate := portsmocks.NewMockSessionSecurity(t)
314+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315+ c.ch.Security = gate
316+ if err := c.publish(context.Background()); err != nil {
317+ t.Fatal(err)
318+ }
319+ locked := (<-c.ch.Scenes)[0]
320+ if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() {
321+ t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating())
322+ }
323+ state = ports.SecurityState{Generation: 2}
324+ gone(t, c, sc, indicatorScene(t, c))
325+ })
326+ t.Run("unplug", func(t *testing.T) {
327+ c, _, sc := leaving(t)
328+ c.removeScreen("A")
329+ if c.animating() || len(sc.rects) != 0 {
330+ t.Fatalf("motions survived the unplug: %d", len(sc.rects))
331+ }
332+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
333+ gone(t, c, c.cur(), indicatorScene(t, c))
334+ })
335+ t.Run("overview toggle", func(t *testing.T) {
336+ c, ic, sc := leaving(t)
337+ act(c, ic, ActionToggleOverview)
338+ gone(t, c, sc, indicatorScene(t, c))
339+ })
340+ t.Run("workspace switch", func(t *testing.T) {
341+ c, ic, sc := leaving(t)
342+ act(c, ic, ActionFocusWorkspaceDown)
343+ gone(t, c, sc, indicatorScene(t, c))
344+ })
345+}
346+
347+// A window ID reused by a new toplevel while the old one fades out (xdg
348+// reuses IDs): the leaving entry goes, the new window appears from where the
349+// old one was drawn (appearFrom), is listed once, and gets its tile
350+// configure (the destroyed window's last configure is forgotten).
351+func TestUnmapThenRemapDuringFade(t *testing.T) {
352+ c, ic, cmds, sc := twoSettled(t)
289 c.unmapWindow(ports.WindowUnmapped{ID: 2})353 c.unmapWindow(ports.WindowUnmapped{ID: 2})
290- if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {354+ indicatorScene(t, c)
291- t.Fatal("no leaving entry")355+ ic.now = ic.now.Add(40 * time.Millisecond)
356+ c.animate(ic.now, nil)
357+ mid := sceneWindow(t, indicatorScene(t, c), 2)
358+ if !(mid.Fade > 0 && mid.Fade < 1) {
359+ t.Fatalf("not fading: %+v", mid)
360+ }
361+ drainConfigures(cmds)
362+ if _, ok := c.configures.sent[2]; ok {
363+ t.Fatal("the destroyed window's configure is remembered")
292 }364 }
293- c.stopAnimations()365+
366+ c.mapWindow(ports.WindowMapped{ID: 2})
294 s := indicatorScene(t, c)367 s := indicatorScene(t, c)
295- if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 {368+ n := 0
296- t.Fatalf("after the stop: %+v", s.Windows)369+ for _, w := range s.Windows {
370+ if w.ID == 2 {
371+ n++
372+ }
373+ }
374+ if n != 1 {
375+ t.Fatalf("window 2 listed %d times: %+v", n, s.Windows)
376+ }
377+ if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) {
378+ t.Fatalf("leaving entry left: %+v", sc.shown)
379+ }
380+ rm, ok := sc.rects[2]
381+ if !ok || rm.leaving {
382+ t.Fatalf("no entrance for the remapped window: %+v", rm)
383+ }
384+ w := sceneWindow(t, s, 2)
385+ // The entrance chains over the exit: drawn as the fade left it, not
386+ // invisible at 90 %.
387+ if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect {
388+ t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid)
389+ }
390+ settled := settledOf(t, sc, 2)
391+ var got []ports.ConfigureWindow
392+ for len(cmds) > 0 {
393+ if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 {
394+ got = append(got, v)
395+ }
396+ }
397+ if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible {
398+ t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect)
399+ }
400+ ic.now = ic.now.Add(5 * time.Second)
401+ c.animate(ic.now, nil)
402+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
403+ t.Fatalf("remapped window settled: %+v", w)
297 }404 }
298 }405 }

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 26

Tags 10

↑↓ moveenter openesc close