detached dd089ad

refactor: deepen surface, output, frame and workspace modules (#37)

- wayland: queued surface updates own their captured commit state;
  applyCommit reads it directly (no take/put/restore of pending state).
- drm: frameLifecycle owns flip/fence/deadline transitions; scanout and
  overlay frame decisions with fallback reasons live in one module.
- app: drm and headless backends share outputChannels wiring and route
  output messages directly through outputSet; protocol-only output
  settings are rejected in the wayland output-management adapter.
- core: workspace visibility is derived in one place (fixes protocol
  workspace activation skipping slot refill); spawn tokens for slots and
  auto-terminals are minted and claimed by one placement module.
- perf-check: allocation guards for frame lifecycle, frame decision,
  captured commit/viewport apply and output routing.
commit
dd089ad52533d21dca36327db9d5ecc2b6fedf87
author
bnema · · 2026-09-28 06:50 UTC
committer
GitHub · · 2026-09-28 06:50 UTC
parent
5576f26
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +1825 -594
M Makefile +1 -1
@@ -50,7 +50,7 @@ arch:
5050 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
5151 # Fast, mandatory guards for tiled content publications and SHM copies.
5252 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
53+ CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
5454 check: vet test arch fakes-check perf-check
5555
5656 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
@@ -50,7 +50,7 @@ arch:
50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
51 # Fast, mandatory guards for tiled content publications and SHM copies.51 # Fast, mandatory guards for tiled content publications and SHM copies.
52 perf-check:52 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=153+ CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
54 check: vet test arch fakes-check perf-check54 check: vet test arch fakes-check perf-check
55 55
56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
M docs/performance.md +1 -1
@@ -10,7 +10,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
1010
1111 ## Guards
1212
13-`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`.
13+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
1414
1515 ## Measuring
1616
@@ -10,7 +10,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
10 10
11 ## Guards11 ## Guards
12 12
13-`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`.13+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
14 14
15 ## Measuring15 ## Measuring
16 16
M internal/adapters/drm/commit_test.go +5 -5
@@ -20,14 +20,14 @@ func TestStaleEventDoesNotCompleteNewCommit(t *testing.T) {
2020 t.Fatal(err)
2121 }
2222 old := eventOf((*commits)[0])
23- o.pending = false // a modeset dropped the commit; its event is queued
23+ o.frame.endPending() // a modeset dropped the commit; its event is queued
2424 if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil {
2525 t.Fatal(err)
2626 }
27- if o.completed(old, nil) || !o.pending {
27+ if o.completed(old, nil) || !o.frame.pendingCommit() {
2828 t.Fatal("stale event completed the new commit")
2929 }
30- if !o.completed(eventOf((*commits)[1]), nil) || o.pending {
30+ if !o.completed(eventOf((*commits)[1]), nil) || o.frame.pendingCommit() {
3131 t.Fatal("own event did not complete the commit")
3232 }
3333 }
@@ -40,10 +40,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
4040 if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) {
4141 t.Fatalf("EBUSY not handled: %v", err)
4242 }
43- if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.pending {
43+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.frame.pendingCommit() {
4444 t.Fatal("EBUSY wait not ended by the CRTC's event")
4545 }
46- if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.pending {
46+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.frame.pendingCommit() {
4747 t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits))
4848 }
4949 }
@@ -20,14 +20,14 @@ func TestStaleEventDoesNotCompleteNewCommit(t *testing.T) {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
22 old := eventOf((*commits)[0])22 old := eventOf((*commits)[0])
23- o.pending = false // a modeset dropped the commit; its event is queued23+ o.frame.endPending() // a modeset dropped the commit; its event is queued
24 if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil {24 if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil {
25 t.Fatal(err)25 t.Fatal(err)
26 }26 }
27- if o.completed(old, nil) || !o.pending {27+ if o.completed(old, nil) || !o.frame.pendingCommit() {
28 t.Fatal("stale event completed the new commit")28 t.Fatal("stale event completed the new commit")
29 }29 }
30- if !o.completed(eventOf((*commits)[1]), nil) || o.pending {30+ if !o.completed(eventOf((*commits)[1]), nil) || o.frame.pendingCommit() {
31 t.Fatal("own event did not complete the commit")31 t.Fatal("own event did not complete the commit")
32 }32 }
33 }33 }
@@ -40,10 +40,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
40 if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) {40 if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) {
41 t.Fatalf("EBUSY not handled: %v", err)41 t.Fatalf("EBUSY not handled: %v", err)
42 }42 }
43- if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.pending {43+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.frame.pendingCommit() {
44 t.Fatal("EBUSY wait not ended by the CRTC's event")44 t.Fatal("EBUSY wait not ended by the CRTC's event")
45 }45 }
46- if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.pending {46+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.frame.pendingCommit() {
47 t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits))47 t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits))
48 }48 }
49 }49 }
A internal/adapters/drm/frame_lifecycle.go +190 -0
@@ -0,0 +1,190 @@
1+package drm
2+
3+import (
4+ "os"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// frameLifecycle is owned by Output.Run. KMS and the event reader do not
12+// schedule frames: Run feeds this state with commit results, flip serials and
13+// deadline ticks. Only Run uses the timer; the transitions take plain values.
14+type frameLifecycle struct {
15+ pending bool
16+ pendingFrame pendingFrame
17+ serials *atomic.Uint64 // shared across outputs on the card
18+ pendingSerial, nextSerial uint64
19+ flipStart time.Time
20+ stuckAfter time.Duration
21+ stuckAt time.Time
22+ fenceWarned bool
23+ busySince, lastBusy time.Time
24+ stuckTimer *time.Timer
25+}
26+
27+// pendingFrame is what the pending frame commit shows.
28+type pendingFrame struct {
29+ frame bool
30+ queued uint64
31+ zeroCopy ports.WindowID // window shown without composition
32+ async bool
33+ shows map[ports.WindowID]uint64
34+ // fences are duplicates of the fences the commit waits on, owned until
35+ // the commit ends. composed is set when the frame was rendered.
36+ fences []*os.File
37+ composed bool
38+}
39+
40+func (f *pendingFrame) closeFences() {
41+ for _, fd := range f.fences {
42+ fd.Close()
43+ }
44+ f.fences = nil
45+}
46+
47+// Commit event userData: the commit serial above userKindBits, the kind below.
48+const (
49+ userFrame = 1
50+ userState = 2
51+ userKindBits = 2
52+)
53+
54+const (
55+ stuckTimeout = time.Second
56+ busyRetry = 50 * time.Millisecond
57+)
58+
59+// stuckLimit returns the configured recovery deadline or its default.
60+func (l *frameLifecycle) stuckLimit() time.Duration {
61+ if l.stuckAfter > 0 {
62+ return l.stuckAfter
63+ }
64+ return stuckTimeout
65+}
66+
67+// userData numbers a commit across all outputs of the card.
68+func (l *frameLifecycle) userData(kind uint64) uint64 {
69+ l.nextSerial = l.serials.Add(1)
70+ return l.nextSerial<<userKindBits | kind
71+}
72+
73+// endPending releases the in-flight commit and its duplicated fences.
74+func (l *frameLifecycle) endPending() {
75+ l.pendingFrame.closeFences()
76+ l.pending, l.pendingFrame = false, pendingFrame{}
77+}
78+
79+// begin records a successful nonblocking commit awaiting its event.
80+func (l *frameLifecycle) begin(f pendingFrame, now time.Time) {
81+ l.pendingSerial = l.nextSerial
82+ l.pendingFrame.closeFences()
83+ l.pending, l.pendingFrame, l.flipStart = true, f, now
84+ l.stuckAt, l.fenceWarned = now.Add(l.stuckLimit()), false
85+ l.busySince = time.Time{}
86+}
87+
88+// pendingCommit reports whether the output must wait before another commit.
89+func (l *frameLifecycle) pendingCommit() bool { return l.pending }
90+
91+// resetAfterModeset ends a replaced commit and clears the old busy run.
92+func (l *frameLifecycle) resetAfterModeset() {
93+ l.endPending()
94+ l.busySince = time.Time{}
95+}
96+
97+// deadlineReached reports whether a pending commit is ready for recovery.
98+func (l *frameLifecycle) deadlineReached(now time.Time) bool {
99+ return l.pending && !now.Before(l.stuckAt)
100+}
101+
102+// deadlineInfo is the pending commit's log context and fences at a tick.
103+// The caller polls the fences; the transition itself consumes a plain bool.
104+func (l *frameLifecycle) deadlineInfo(now time.Time) (serial uint64, frame bool, age, busy time.Duration, fences []*os.File) {
105+ return l.pendingSerial, l.pendingFrame.frame, now.Sub(l.flipStart), now.Sub(l.busySince), l.pendingFrame.fences
106+}
107+
108+// busy records an EBUSY refusal. No commit of ours is known to be in
109+// flight: any event of this CRTC may unblock the retry.
110+// It returns true when this is a new run, for the caller's log.
111+func (l *frameLifecycle) busy(now time.Time) bool {
112+ l.endPending()
113+ l.pending, l.flipStart, l.stuckAt = true, now, now.Add(busyRetry)
114+ l.pendingSerial = 0
115+ newRun := l.busySince.IsZero() || now.Sub(l.lastBusy) > 4*busyRetry
116+ if newRun {
117+ l.busySince = now
118+ }
119+ l.lastBusy = now
120+ return newRun
121+}
122+
123+// flip consumes only the event for the pending commit; while waiting after
124+// EBUSY there is no known serial, so any CRTC event ends that wait. The
125+// returned start lets Output measure flip age after pending fences close.
126+func (l *frameLifecycle) flip(user uint64) (pendingFrame, time.Time, bool) {
127+ if !l.pending || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial {
128+ return pendingFrame{}, time.Time{}, false
129+ }
130+ f, start := l.pendingFrame, l.flipStart
131+ l.endPending()
132+ l.busySince = time.Time{}
133+ return f, start, true
134+}
135+
136+// timeoutResult describes the action after a deadline tick.
137+type timeoutResult uint8
138+
139+const (
140+ timeoutEarly timeoutResult = iota // deadline not reached (or a warned fence still waits)
141+ timeoutFence // first wait for an unsignalled fence
142+ timeoutRetry // EBUSY without an event: retry the commit
143+ timeoutBusy // EBUSY past its limit: modeset
144+ timeoutMissing // committed event missing: modeset
145+)
146+
147+// timeout takes the current fence status as a value. The caller polls only
148+// when a known commit reaches its deadline; no clock, fence or event source
149+// is mocked in transition tests.
150+func (l *frameLifecycle) timeout(now time.Time, fencesReady bool) timeoutResult {
151+ if !l.pending || now.Before(l.stuckAt) {
152+ return timeoutEarly
153+ }
154+ if l.pendingSerial != 0 && !fencesReady {
155+ l.stuckAt = now.Add(l.stuckLimit())
156+ if !l.fenceWarned {
157+ l.fenceWarned = true
158+ return timeoutFence
159+ }
160+ return timeoutEarly
161+ }
162+ l.endPending()
163+ if l.pendingSerial != 0 {
164+ return timeoutMissing
165+ }
166+ if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() {
167+ return timeoutRetry
168+ }
169+ return timeoutBusy
170+}
171+
172+// startTimer creates the per-output deadline timer for Run, not kms.
173+func (l *frameLifecycle) startTimer() {
174+ l.stuckTimer = time.NewTimer(time.Hour)
175+ l.stuckTimer.Stop()
176+}
177+
178+// wait arms the pending deadline, or disables it while idle or away.
179+// Go 1.27 guarantees a stopped/reset timer cannot deliver an old value.
180+func (l *frameLifecycle) wait(enabled bool) <-chan time.Time {
181+ if !enabled || !l.pending {
182+ l.stuckTimer.Stop()
183+ return nil
184+ }
185+ l.stuckTimer.Reset(max(0, time.Until(l.stuckAt)))
186+ return l.stuckTimer.C
187+}
188+
189+// stopTimer releases the timer when Run exits.
190+func (l *frameLifecycle) stopTimer() { l.stuckTimer.Stop() }
@@ -0,0 +1,190 @@
1+package drm
2+
3+import (
4+ "os"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// frameLifecycle is owned by Output.Run. KMS and the event reader do not
12+// schedule frames: Run feeds this state with commit results, flip serials and
13+// deadline ticks. Only Run uses the timer; the transitions take plain values.
14+type frameLifecycle struct {
15+ pending bool
16+ pendingFrame pendingFrame
17+ serials *atomic.Uint64 // shared across outputs on the card
18+ pendingSerial, nextSerial uint64
19+ flipStart time.Time
20+ stuckAfter time.Duration
21+ stuckAt time.Time
22+ fenceWarned bool
23+ busySince, lastBusy time.Time
24+ stuckTimer *time.Timer
25+}
26+
27+// pendingFrame is what the pending frame commit shows.
28+type pendingFrame struct {
29+ frame bool
30+ queued uint64
31+ zeroCopy ports.WindowID // window shown without composition
32+ async bool
33+ shows map[ports.WindowID]uint64
34+ // fences are duplicates of the fences the commit waits on, owned until
35+ // the commit ends. composed is set when the frame was rendered.
36+ fences []*os.File
37+ composed bool
38+}
39+
40+func (f *pendingFrame) closeFences() {
41+ for _, fd := range f.fences {
42+ fd.Close()
43+ }
44+ f.fences = nil
45+}
46+
47+// Commit event userData: the commit serial above userKindBits, the kind below.
48+const (
49+ userFrame = 1
50+ userState = 2
51+ userKindBits = 2
52+)
53+
54+const (
55+ stuckTimeout = time.Second
56+ busyRetry = 50 * time.Millisecond
57+)
58+
59+// stuckLimit returns the configured recovery deadline or its default.
60+func (l *frameLifecycle) stuckLimit() time.Duration {
61+ if l.stuckAfter > 0 {
62+ return l.stuckAfter
63+ }
64+ return stuckTimeout
65+}
66+
67+// userData numbers a commit across all outputs of the card.
68+func (l *frameLifecycle) userData(kind uint64) uint64 {
69+ l.nextSerial = l.serials.Add(1)
70+ return l.nextSerial<<userKindBits | kind
71+}
72+
73+// endPending releases the in-flight commit and its duplicated fences.
74+func (l *frameLifecycle) endPending() {
75+ l.pendingFrame.closeFences()
76+ l.pending, l.pendingFrame = false, pendingFrame{}
77+}
78+
79+// begin records a successful nonblocking commit awaiting its event.
80+func (l *frameLifecycle) begin(f pendingFrame, now time.Time) {
81+ l.pendingSerial = l.nextSerial
82+ l.pendingFrame.closeFences()
83+ l.pending, l.pendingFrame, l.flipStart = true, f, now
84+ l.stuckAt, l.fenceWarned = now.Add(l.stuckLimit()), false
85+ l.busySince = time.Time{}
86+}
87+
88+// pendingCommit reports whether the output must wait before another commit.
89+func (l *frameLifecycle) pendingCommit() bool { return l.pending }
90+
91+// resetAfterModeset ends a replaced commit and clears the old busy run.
92+func (l *frameLifecycle) resetAfterModeset() {
93+ l.endPending()
94+ l.busySince = time.Time{}
95+}
96+
97+// deadlineReached reports whether a pending commit is ready for recovery.
98+func (l *frameLifecycle) deadlineReached(now time.Time) bool {
99+ return l.pending && !now.Before(l.stuckAt)
100+}
101+
102+// deadlineInfo is the pending commit's log context and fences at a tick.
103+// The caller polls the fences; the transition itself consumes a plain bool.
104+func (l *frameLifecycle) deadlineInfo(now time.Time) (serial uint64, frame bool, age, busy time.Duration, fences []*os.File) {
105+ return l.pendingSerial, l.pendingFrame.frame, now.Sub(l.flipStart), now.Sub(l.busySince), l.pendingFrame.fences
106+}
107+
108+// busy records an EBUSY refusal. No commit of ours is known to be in
109+// flight: any event of this CRTC may unblock the retry.
110+// It returns true when this is a new run, for the caller's log.
111+func (l *frameLifecycle) busy(now time.Time) bool {
112+ l.endPending()
113+ l.pending, l.flipStart, l.stuckAt = true, now, now.Add(busyRetry)
114+ l.pendingSerial = 0
115+ newRun := l.busySince.IsZero() || now.Sub(l.lastBusy) > 4*busyRetry
116+ if newRun {
117+ l.busySince = now
118+ }
119+ l.lastBusy = now
120+ return newRun
121+}
122+
123+// flip consumes only the event for the pending commit; while waiting after
124+// EBUSY there is no known serial, so any CRTC event ends that wait. The
125+// returned start lets Output measure flip age after pending fences close.
126+func (l *frameLifecycle) flip(user uint64) (pendingFrame, time.Time, bool) {
127+ if !l.pending || l.pendingSerial != 0 && user>>userKindBits != l.pendingSerial {
128+ return pendingFrame{}, time.Time{}, false
129+ }
130+ f, start := l.pendingFrame, l.flipStart
131+ l.endPending()
132+ l.busySince = time.Time{}
133+ return f, start, true
134+}
135+
136+// timeoutResult describes the action after a deadline tick.
137+type timeoutResult uint8
138+
139+const (
140+ timeoutEarly timeoutResult = iota // deadline not reached (or a warned fence still waits)
141+ timeoutFence // first wait for an unsignalled fence
142+ timeoutRetry // EBUSY without an event: retry the commit
143+ timeoutBusy // EBUSY past its limit: modeset
144+ timeoutMissing // committed event missing: modeset
145+)
146+
147+// timeout takes the current fence status as a value. The caller polls only
148+// when a known commit reaches its deadline; no clock, fence or event source
149+// is mocked in transition tests.
150+func (l *frameLifecycle) timeout(now time.Time, fencesReady bool) timeoutResult {
151+ if !l.pending || now.Before(l.stuckAt) {
152+ return timeoutEarly
153+ }
154+ if l.pendingSerial != 0 && !fencesReady {
155+ l.stuckAt = now.Add(l.stuckLimit())
156+ if !l.fenceWarned {
157+ l.fenceWarned = true
158+ return timeoutFence
159+ }
160+ return timeoutEarly
161+ }
162+ l.endPending()
163+ if l.pendingSerial != 0 {
164+ return timeoutMissing
165+ }
166+ if l.busySince.IsZero() || now.Sub(l.busySince) < l.stuckLimit() {
167+ return timeoutRetry
168+ }
169+ return timeoutBusy
170+}
171+
172+// startTimer creates the per-output deadline timer for Run, not kms.
173+func (l *frameLifecycle) startTimer() {
174+ l.stuckTimer = time.NewTimer(time.Hour)
175+ l.stuckTimer.Stop()
176+}
177+
178+// wait arms the pending deadline, or disables it while idle or away.
179+// Go 1.27 guarantees a stopped/reset timer cannot deliver an old value.
180+func (l *frameLifecycle) wait(enabled bool) <-chan time.Time {
181+ if !enabled || !l.pending {
182+ l.stuckTimer.Stop()
183+ return nil
184+ }
185+ l.stuckTimer.Reset(max(0, time.Until(l.stuckAt)))
186+ return l.stuckTimer.C
187+}
188+
189+// stopTimer releases the timer when Run exits.
190+func (l *frameLifecycle) stopTimer() { l.stuckTimer.Stop() }
A internal/adapters/drm/frame_lifecycle_test.go +97 -0
@@ -0,0 +1,97 @@
1+package drm
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+)
8+
9+// All transitions below use values only: no event reader, timer, clock or
10+// KMS mock is needed to prove the serial and deadline rules.
11+func TestFrameLifecycleStaleFlipAndRecovery(t *testing.T) {
12+ var serials atomic.Uint64
13+ l := frameLifecycle{serials: &serials}
14+ start := time.Unix(100, 0)
15+ old := l.userData(userFrame)
16+ l.begin(pendingFrame{frame: true, queued: 7}, start)
17+ l.endPending() // successful recovery modeset invalidates the old event
18+ current := l.userData(userFrame)
19+ l.begin(pendingFrame{frame: true, queued: 8}, start.Add(time.Millisecond))
20+ if _, _, ok := l.flip(old); ok || !l.pending {
21+ t.Fatal("old event completed new commit")
22+ }
23+ f, _, ok := l.flip(current)
24+ if !ok || l.pending || !f.frame || f.queued != 8 {
25+ t.Fatalf("new event: ok=%v pending=%v frame=%+v", ok, l.pending, f)
26+ }
27+}
28+
29+func TestFrameLifecycleBusyRetryAndLimit(t *testing.T) {
30+ var serials atomic.Uint64
31+ l := frameLifecycle{serials: &serials, stuckAfter: 200 * time.Millisecond}
32+ start := time.Unix(100, 0)
33+ if !l.busy(start) || l.stuckAt != start.Add(busyRetry) {
34+ t.Fatal("initial EBUSY did not arm retry")
35+ }
36+ if got := l.timeout(start.Add(busyRetry-time.Nanosecond), true); got != timeoutEarly {
37+ t.Fatalf("premature deadline: %d", got)
38+ }
39+ if got := l.timeout(start.Add(busyRetry), true); got != timeoutRetry || l.pending {
40+ t.Fatalf("first retry: %d pending %v", got, l.pending)
41+ }
42+ l.busy(start.Add(busyRetry))
43+ if _, _, ok := l.flip(999 << userKindBits); !ok || l.pending {
44+ t.Fatal("CRTC event did not unblock EBUSY")
45+ }
46+ for i := 3; i < 6; i++ {
47+ l.busy(start.Add(time.Duration(i) * busyRetry))
48+ if got := l.timeout(start.Add(time.Duration(i+1)*busyRetry), true); got != timeoutRetry {
49+ t.Fatalf("busy retry %d: %d", i, got)
50+ }
51+ }
52+ l.busy(start.Add(6 * busyRetry))
53+ if got := l.timeout(start.Add(7*busyRetry), true); got != timeoutBusy {
54+ t.Fatalf("long EBUSY did not request modeset: %d", got)
55+ }
56+ if !l.busy(start.Add(time.Second)) {
57+ t.Fatal("later refusal did not start a new busy run")
58+ }
59+ if got := l.timeout(start.Add(time.Second+busyRetry), true); got != timeoutRetry {
60+ t.Fatalf("new busy run: %d", got)
61+ }
62+}
63+
64+func TestFrameLifecycleFenceAndMissingEvent(t *testing.T) {
65+ var serials atomic.Uint64
66+ l := frameLifecycle{serials: &serials, stuckAfter: 100 * time.Millisecond}
67+ start := time.Unix(100, 0)
68+ user := l.userData(userFrame)
69+ l.begin(pendingFrame{frame: true}, start)
70+ if got := l.timeout(start.Add(100*time.Millisecond), false); got != timeoutFence || !l.pending || l.stuckAt != start.Add(200*time.Millisecond) {
71+ t.Fatalf("unsignalled fence: %d pending %v deadline %v", got, l.pending, l.stuckAt)
72+ }
73+ if got := l.timeout(start.Add(200*time.Millisecond), false); got != timeoutEarly || !l.pending {
74+ t.Fatalf("repeated fence wait: %d", got)
75+ }
76+ if got := l.timeout(start.Add(300*time.Millisecond), true); got != timeoutMissing || l.pending {
77+ t.Fatalf("missing event after fence: %d pending %v", got, l.pending)
78+ }
79+ if _, _, ok := l.flip(user); ok {
80+ t.Fatal("late event completed abandoned commit")
81+ }
82+}
83+
84+func TestFrameLifecycleTransitionsAllocations(t *testing.T) {
85+ var serials atomic.Uint64
86+ l := frameLifecycle{serials: &serials}
87+ start := time.Unix(100, 0)
88+ if allocs := testing.AllocsPerRun(100, func() {
89+ user := l.userData(userFrame)
90+ l.begin(pendingFrame{frame: true}, start)
91+ l.flip(user)
92+ l.busy(start)
93+ l.timeout(start.Add(busyRetry), true)
94+ }); allocs != 0 {
95+ t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs)
96+ }
97+}
@@ -0,0 +1,97 @@
1+package drm
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+)
8+
9+// All transitions below use values only: no event reader, timer, clock or
10+// KMS mock is needed to prove the serial and deadline rules.
11+func TestFrameLifecycleStaleFlipAndRecovery(t *testing.T) {
12+ var serials atomic.Uint64
13+ l := frameLifecycle{serials: &serials}
14+ start := time.Unix(100, 0)
15+ old := l.userData(userFrame)
16+ l.begin(pendingFrame{frame: true, queued: 7}, start)
17+ l.endPending() // successful recovery modeset invalidates the old event
18+ current := l.userData(userFrame)
19+ l.begin(pendingFrame{frame: true, queued: 8}, start.Add(time.Millisecond))
20+ if _, _, ok := l.flip(old); ok || !l.pending {
21+ t.Fatal("old event completed new commit")
22+ }
23+ f, _, ok := l.flip(current)
24+ if !ok || l.pending || !f.frame || f.queued != 8 {
25+ t.Fatalf("new event: ok=%v pending=%v frame=%+v", ok, l.pending, f)
26+ }
27+}
28+
29+func TestFrameLifecycleBusyRetryAndLimit(t *testing.T) {
30+ var serials atomic.Uint64
31+ l := frameLifecycle{serials: &serials, stuckAfter: 200 * time.Millisecond}
32+ start := time.Unix(100, 0)
33+ if !l.busy(start) || l.stuckAt != start.Add(busyRetry) {
34+ t.Fatal("initial EBUSY did not arm retry")
35+ }
36+ if got := l.timeout(start.Add(busyRetry-time.Nanosecond), true); got != timeoutEarly {
37+ t.Fatalf("premature deadline: %d", got)
38+ }
39+ if got := l.timeout(start.Add(busyRetry), true); got != timeoutRetry || l.pending {
40+ t.Fatalf("first retry: %d pending %v", got, l.pending)
41+ }
42+ l.busy(start.Add(busyRetry))
43+ if _, _, ok := l.flip(999 << userKindBits); !ok || l.pending {
44+ t.Fatal("CRTC event did not unblock EBUSY")
45+ }
46+ for i := 3; i < 6; i++ {
47+ l.busy(start.Add(time.Duration(i) * busyRetry))
48+ if got := l.timeout(start.Add(time.Duration(i+1)*busyRetry), true); got != timeoutRetry {
49+ t.Fatalf("busy retry %d: %d", i, got)
50+ }
51+ }
52+ l.busy(start.Add(6 * busyRetry))
53+ if got := l.timeout(start.Add(7*busyRetry), true); got != timeoutBusy {
54+ t.Fatalf("long EBUSY did not request modeset: %d", got)
55+ }
56+ if !l.busy(start.Add(time.Second)) {
57+ t.Fatal("later refusal did not start a new busy run")
58+ }
59+ if got := l.timeout(start.Add(time.Second+busyRetry), true); got != timeoutRetry {
60+ t.Fatalf("new busy run: %d", got)
61+ }
62+}
63+
64+func TestFrameLifecycleFenceAndMissingEvent(t *testing.T) {
65+ var serials atomic.Uint64
66+ l := frameLifecycle{serials: &serials, stuckAfter: 100 * time.Millisecond}
67+ start := time.Unix(100, 0)
68+ user := l.userData(userFrame)
69+ l.begin(pendingFrame{frame: true}, start)
70+ if got := l.timeout(start.Add(100*time.Millisecond), false); got != timeoutFence || !l.pending || l.stuckAt != start.Add(200*time.Millisecond) {
71+ t.Fatalf("unsignalled fence: %d pending %v deadline %v", got, l.pending, l.stuckAt)
72+ }
73+ if got := l.timeout(start.Add(200*time.Millisecond), false); got != timeoutEarly || !l.pending {
74+ t.Fatalf("repeated fence wait: %d", got)
75+ }
76+ if got := l.timeout(start.Add(300*time.Millisecond), true); got != timeoutMissing || l.pending {
77+ t.Fatalf("missing event after fence: %d pending %v", got, l.pending)
78+ }
79+ if _, _, ok := l.flip(user); ok {
80+ t.Fatal("late event completed abandoned commit")
81+ }
82+}
83+
84+func TestFrameLifecycleTransitionsAllocations(t *testing.T) {
85+ var serials atomic.Uint64
86+ l := frameLifecycle{serials: &serials}
87+ start := time.Unix(100, 0)
88+ if allocs := testing.AllocsPerRun(100, func() {
89+ user := l.userData(userFrame)
90+ l.begin(pendingFrame{frame: true}, start)
91+ l.flip(user)
92+ l.busy(start)
93+ l.timeout(start.Add(busyRetry), true)
94+ }); allocs != 0 {
95+ t.Fatalf("frame lifecycle: %.1f allocs, want 0", allocs)
96+ }
97+}
M internal/adapters/drm/output.go +53 -205
@@ -7,7 +7,6 @@ import (
77 "maps"
88 "os"
99 "slices"
10- "sync/atomic"
1110 "time"
1211 "unsafe"
1312
@@ -60,28 +59,10 @@ type Output struct {
6059 overlayReason string
6160 // fbs are the two renderer images frames alternate between, back the
6261 // one the next frame draws into (ADR 014: zero copy).
63- fbs [2]uint32
64- back int
65- // pending: a commit waits for its event; pendingFrame when it flips a
66- // new frame (else it only changed the cursor or VRR).
67- pending bool
68- pendingFrame pendingFrame
69- // serials numbers commits per card, so an event queued before a
70- // modeset, or for an earlier output on this CRTC, never completes a
71- // newer commit; pendingSerial is the pending commit's.
72- serials *atomic.Uint64
73- pendingSerial, nextSerial uint64
74- flipStart time.Time
75- flips int
76- // A commit whose event does not come by stuckAt is abandoned with a
77- // modeset once its fences signalled (stuckAfter, default
78- // stuckTimeout; fenceWarned once it waits for a fence). A commit
79- // refused with EBUSY is retried after busyRetry; busySince is when
80- // the EBUSY run began, lastBusy the latest EBUSY.
81- stuckAfter time.Duration
82- stuckAt time.Time
83- fenceWarned bool
84- busySince, lastBusy time.Time
62+ fbs [2]uint32
63+ back int
64+ frame frameLifecycle // pending commit, serials, deadlines and timer
65+ flips int
8566 // readFences are fences of frames rendered but not committed: the
8667 // GPU may still read client buffers until they signal, so what was
8768 // seen is reported only then (or after a later frame flipped).
@@ -131,28 +112,6 @@ type Output struct {
131112 // Ready reports the result of the first modeset exactly once.
132113 func (o *Output) Ready() <-chan error { return o.ready }
133114
134-// pendingFrame is what the pending frame commit shows.
135-type pendingFrame struct {
136- frame bool
137- queued uint64
138- zeroCopy ports.WindowID // window shown without composition
139- async bool
140- shows map[ports.WindowID]uint64
141- // fences are duplicates of the fences the commit waits on, owned by
142- // the output until the commit ends. composed is set when the frame
143- // was rendered (its fence is the renderer's).
144- fences []*os.File
145- composed bool
146-}
147-
148-// closeFences closes the fences of f.
149-func (f *pendingFrame) closeFences() {
150- for _, fd := range f.fences {
151- fd.Close()
152- }
153- f.fences = nil
154-}
155-
156115 // dupFences duplicates the non-nil fences.
157116 func dupFences(fs ...*os.File) []*os.File {
158117 var out []*os.File
@@ -214,81 +173,28 @@ func (o *Output) dropRead() {
214173 o.readFences = nil
215174 }
216175
217-// endPending ends the pending commit, closing its fences.
218-func (o *Output) endPending() {
219- o.pendingFrame.closeFences()
220- o.pending, o.pendingFrame = false, pendingFrame{}
221-}
222-
223-// Commit event userData: the commit serial above userKindBits, the kind
224-// of commit below.
225-const (
226- userFrame = 1
227- userState = 2
228- userKindBits = 2
229-)
230-
231-// userData takes a new serial for the next commit of kind and returns
232-// what the commit carries.
233-func (o *Output) userData(kind uint64) uint64 {
234- o.nextSerial = o.serials.Add(1)
235- return o.nextSerial<<userKindBits | kind
236-}
237-
238-// begin marks the commit made with the last userData pending.
239-func (o *Output) begin(f pendingFrame) {
240- o.pendingSerial = o.nextSerial
241- o.pendingFrame.closeFences()
242- o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
243- o.stuckAt, o.fenceWarned = o.flipStart.Add(o.stuckLimit()), false
244- o.busySince = time.Time{}
245-}
246-
247-// Bounds on waiting for a commit event: no path may leave an output
248-// pending forever.
249-const (
250- stuckTimeout = time.Second
251- busyRetry = 50 * time.Millisecond
252-)
253-
254-func (o *Output) stuckLimit() time.Duration {
255- if o.stuckAfter > 0 {
256- return o.stuckAfter
257- }
258- return stuckTimeout
259-}
260-
261-// expire handles a pending commit whose event did not come by stuckAt.
262-// A commit still waiting for a fence (a long GPU job) keeps waiting.
263-// Else the commit is abandoned so the next can go; it reports whether
264-// the output needs a modeset: an event is missing (the kernel state is
265-// unknown) or EBUSY lasted past the limit.
176+// expire applies a lifecycle deadline and reports whether KMS needs a modeset.
177+// Fence polling and logging stay at the output boundary, not in the value
178+// transition; the lifecycle owns the decision and closes pending fences.
266179 func (o *Output) expire() bool {
267180 now := time.Now()
268- age := now.Sub(o.flipStart)
269- if o.pendingSerial != 0 && !signalled(o.pendingFrame.fences) {
270- if !o.fenceWarned {
271- o.fenceWarned = true
272- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Dur("age", age).Msg("frame waits for GPU fence")
273- }
274- o.stuckAt = now.Add(o.stuckLimit())
275- return false
276- }
277- kind := "state"
278- if o.pendingFrame.frame {
279- kind = "frame"
280- }
281- o.endPending()
282- if o.pendingSerial == 0 {
283- busy := now.Sub(o.busySince)
284- if o.busySince.IsZero() || busy < o.stuckLimit() {
285- return false
286- }
181+ serial, frame, age, busy, fences := o.frame.deadlineInfo(now)
182+ ready := serial == 0 || !o.frame.deadlineReached(now) || signalled(fences)
183+ switch o.frame.timeout(now, ready) {
184+ case timeoutFence:
185+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Dur("age", age).Msg("frame waits for GPU fence")
186+ case timeoutBusy:
287187 o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
288188 return true
189+ case timeoutMissing:
190+ kind := "state"
191+ if frame {
192+ kind = "frame"
193+ }
194+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
195+ return true
289196 }
290- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
291- return true
197+ return false
292198 }
293199
294200 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -299,7 +205,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
299205 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
300206 log := card.log
301207 pipe := slices.Index(card.crtcs, crtc)
302- o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
208+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
303209 var err error
304210 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
305211 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -451,8 +357,7 @@ func (o *Output) modeset() error {
451357 }
452358 // Only now is nothing of ours pending or on screen: on failure the
453359 // previous buffers may still show.
454- o.endPending()
455- o.busySince = time.Time{}
360+ o.frame.resetAfterModeset()
456361 o.shown, o.queued = 0, 0
457362 if o.modeBlob != 0 {
458363 _ = o.k.destroyBlob(o.modeBlob)
@@ -670,7 +575,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
670575 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
671576 }
672577 // The kernel takes its own reference on the fence.
673- if err := o.k.commit(req, flags, o.userData(userFrame)); err != nil {
578+ if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
674579 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
675580 return o.commitWith(fb, fence, false, vrr, f, ov)
676581 }
@@ -702,7 +607,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
702607 f.frame, f.async = true, async
703608 f.fences = dupFences(fence, ov.acquire)
704609 o.setAsync(async)
705- o.begin(f)
610+ o.frame.begin(f, time.Now())
706611 return nil
707612 }
708613
@@ -730,7 +635,7 @@ func (o *Output) commitState(vrr bool) error {
730635 if len(req.objs) == 0 {
731636 return nil
732637 }
733- if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.userData(userState)); err != nil {
638+ if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
734639 if o.overlayConflict(err, o.overlayOn) {
735640 // The next frame is composed without the overlay.
736641 return errOverlayDropped
@@ -747,7 +652,7 @@ func (o *Output) commitState(vrr bool) error {
747652 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
748653 }
749654 o.vrrOn = vrr
750- o.begin(pendingFrame{})
655+ o.frame.begin(pendingFrame{}, time.Now())
751656 return nil
752657 }
753658
@@ -758,41 +663,6 @@ func boolValue(b bool) uint64 {
758663 return 0
759664 }
760665
761-// scanoutFrame picks a fullscreen client buffer to flip directly. It
762-// returns fb 0, with the reason logged on change, when the frame must be
763-// composed.
764-func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
765- reason := "disabled"
766- if o.scanout {
767- c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
768- if reason == "" && isYUVFormat(c.DMABuf.Format) {
769- reason = "yuv"
770- }
771- if o.hdrOn && reason == "" {
772- switch {
773- case !c.Color.IsPQ2020():
774- reason = "hdr_sdr_content"
775- case !isTenBit(c.DMABuf.Format):
776- reason = "hdr_format"
777- }
778- } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
779- // An SDR connector must never show raw PQ values.
780- reason = "sdr_pq_content"
781- }
782- }
783- if reason == "" {
784- fb, reason = o.scanoutFB(c.DMABuf, time.Now())
785- }
786- if reason != o.reason {
787- o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
788- o.reason = reason
789- }
790- if reason != "" {
791- return 0, c
792- }
793- return fb, c
794-}
795-
796666 // commitScanout flips a client buffer. It reports false when KMS refused
797667 // the buffer: the frame must be composed.
798668 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
@@ -819,9 +689,9 @@ func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame
819689 }
820690 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
821691 // KMS refuses this buffer on the plane: compose it instead.
822- o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
823- cfb.failed, o.reason = "flip_refused", "flip_refused"
824- o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
692+ o.log.Info().Str("component", "render").Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
693+ cfb.failed = "flip_refused"
694+ o.setScanoutReason("flip_refused")
825695 return false, nil
826696 }
827697 if err == nil {
@@ -911,7 +781,7 @@ func (o *Output) Close() {
911781 o.cursor.free(o.k)
912782 }
913783 o.freeImages()
914- o.endPending()
784+ o.frame.endPending()
915785 o.dropRead()
916786 o.shown, o.queued = 0, 0
917787 o.dropClientFBs(time.Now(), true)
@@ -987,13 +857,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
987857 // guarantees a stopped/reset timer cannot deliver its old value.
988858 retryTimer := time.NewTimer(time.Hour)
989859 vrrTimer := time.NewTimer(time.Hour)
990- stuckTimer := time.NewTimer(time.Hour)
860+ o.frame.startTimer()
991861 retryTimer.Stop()
992862 vrrTimer.Stop()
993- stuckTimer.Stop()
994863 defer retryTimer.Stop()
995864 defer vrrTimer.Stop()
996- defer stuckTimer.Stop()
865+ defer o.frame.stopTimer()
997866 // seen is the latest content Seq per window. It is reported only while
998867 // no frame is in flight: then the GPU has finished every frame that
999868 // read older buffers (the kernel waited for their fence to flip).
@@ -1005,7 +874,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1005874 }
1006875 for {
1007876 readWait := false
1008- if reportDirty && !o.pending {
877+ if reportDirty && !o.frame.pendingCommit() {
1009878 if readWait = !o.readDone(); !readWait {
1010879 o.report(nil, seen)
1011880 reportDirty = false
@@ -1023,26 +892,20 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1023892 }
1024893 // A composed screen that stopped changing still drops VRR on time.
1025894 var vrrOff <-chan time.Time
1026- if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
895+ if enabled && o.vrrOn && !o.frame.pendingCommit() && !o.composedSince.IsZero() {
1027896 vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince)))
1028897 vrrOff = vrrTimer.C
1029898 } else {
1030899 vrrTimer.Stop()
1031900 }
1032901 // A commit whose event never comes must not stop the output.
1033- var stuck <-chan time.Time
1034- if enabled && o.pending {
1035- stuckTimer.Reset(max(0, time.Until(o.stuckAt)))
1036- stuck = stuckTimer.C
1037- } else {
1038- stuckTimer.Stop()
1039- }
902+ stuck := o.frame.wait(enabled)
1040903 stateDirty := false
1041904 select {
1042905 case <-retry:
1043906 continue
1044907 case <-stuck:
1045- if o.pending && o.stuckAt.After(time.Now()) {
908+ if !o.frame.deadlineReached(time.Now()) {
1046909 continue // waits for a fence
1047910 }
1048911 if o.expire() {
@@ -1132,7 +995,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1132995 dirty = dirty || scene.Shows(c.ID)
1133996 case <-stats.C:
1134997 o.dropClientFBs(time.Now(), false)
1135- ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.pending)
998+ ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit())
1136999 if o.cursor != nil {
11371000 cs := o.cursor.TakeStats()
11381001 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)
@@ -1145,7 +1008,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11451008 }
11461009 requests = nil
11471010 }
1148- if !enabled || o.pending {
1011+ if !enabled || o.frame.pendingCommit() {
11491012 continue
11501013 }
11511014 // Output power: off waits for no commit in flight; on is a modeset
@@ -1181,21 +1044,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11811044 // shows is what the frame puts on screen: presentation feedback
11821045 // of windows it does not draw is discarded, not presented.
11831046 f := pendingFrame{shows: o.shownBy(scene, seen)}
1184- var fb uint32
1185- var c ports.SurfaceContent
1186- forceCompose := len(requests) > 0
1187- if !forceCompose {
1188- fb, c = o.scanoutFrame(scene, surfaces)
1189- }
1047+ decision := o.decideFrame(scene, surfaces, len(requests) > 0)
11901048 direct := false
1191- if fb != 0 {
1192- direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})
1049+ if decision.fb != 0 {
1050+ c := decision.content
1051+ direct, err = o.commitScanout(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})
11931052 }
11941053 if !direct {
1195- var ov overlayWin
1196- composed := scene
1197- if !forceCompose {
1198- ov, composed = o.overlayFrame(scene, surfaces)
1054+ ov, composed := decision.overlay, decision.composed
1055+ if decision.fb != 0 {
1056+ decision = o.composeFrame(scene, surfaces)
1057+ ov, composed = decision.overlay, decision.composed
11991058 }
12001059 r.UseTarget(o.back)
12011060 done, rerr := r.Render(composed, surfaces)
@@ -1267,19 +1126,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12671126 func (o *Output) commitFailed(err error, enabled *bool) bool {
12681127 switch {
12691128 case errors.Is(err, unix.EBUSY):
1270- // The kernel still works on an earlier commit (e.g. across a VT
1271- // switch, or one whose event already came): wait for any event
1272- // of this CRTC, or retry after busyRetry since none may come.
1273- o.endPending()
1274- now := time.Now()
1275- o.pending, o.flipStart, o.stuckAt = true, now, now.Add(busyRetry)
1276- o.pendingSerial = 0
1277- // A run of EBUSY: each refusal follows the previous retry.
1278- if o.busySince.IsZero() || now.Sub(o.lastBusy) > 4*busyRetry {
1279- o.busySince = now
1129+ // Any CRTC event can unblock a refusal; absent one, retry on time.
1130+ if o.frame.busy(time.Now()) {
12801131 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
12811132 }
1282- o.lastBusy = now
12831133 return true
12841134 case lostMaster(err):
12851135 o.log.Warn().Err(err).Msg("commit refused")
@@ -1294,14 +1144,12 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
12941144 // reported with its kernel timestamp. It reports false for an event of
12951145 // another commit, which is dropped.
12961146 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1297- if !o.pending || o.pendingSerial != 0 && ev.user>>userKindBits != o.pendingSerial {
1147+ f, start, ok := o.frame.flip(ev.user)
1148+ if !ok {
12981149 return false
12991150 }
1300- f := o.pendingFrame
1301- o.endPending()
1302- o.busySince = time.Time{}
1303- if d := time.Since(o.flipStart); d > 20*time.Millisecond {
1304- o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
1151+ if age := time.Since(start); age > 20*time.Millisecond {
1152+ o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")
13051153 }
13061154 if o.cursor != nil {
13071155 o.cursor.landed()
@@ -7,7 +7,6 @@ import (
7 "maps"7 "maps"
8 "os"8 "os"
9 "slices"9 "slices"
10- "sync/atomic"
11 "time"10 "time"
12 "unsafe"11 "unsafe"
13 12
@@ -60,28 +59,10 @@ type Output struct {
60 overlayReason string59 overlayReason string
61 // fbs are the two renderer images frames alternate between, back the60 // fbs are the two renderer images frames alternate between, back the
62 // one the next frame draws into (ADR 014: zero copy).61 // one the next frame draws into (ADR 014: zero copy).
63- fbs [2]uint3262+ fbs [2]uint32
64- back int63+ back int
65- // pending: a commit waits for its event; pendingFrame when it flips a64+ frame frameLifecycle // pending commit, serials, deadlines and timer
66- // new frame (else it only changed the cursor or VRR).65+ flips int
67- pending bool
68- pendingFrame pendingFrame
69- // serials numbers commits per card, so an event queued before a
70- // modeset, or for an earlier output on this CRTC, never completes a
71- // newer commit; pendingSerial is the pending commit's.
72- serials *atomic.Uint64
73- pendingSerial, nextSerial uint64
74- flipStart time.Time
75- flips int
76- // A commit whose event does not come by stuckAt is abandoned with a
77- // modeset once its fences signalled (stuckAfter, default
78- // stuckTimeout; fenceWarned once it waits for a fence). A commit
79- // refused with EBUSY is retried after busyRetry; busySince is when
80- // the EBUSY run began, lastBusy the latest EBUSY.
81- stuckAfter time.Duration
82- stuckAt time.Time
83- fenceWarned bool
84- busySince, lastBusy time.Time
85 // readFences are fences of frames rendered but not committed: the66 // readFences are fences of frames rendered but not committed: the
86 // GPU may still read client buffers until they signal, so what was67 // GPU may still read client buffers until they signal, so what was
87 // seen is reported only then (or after a later frame flipped).68 // seen is reported only then (or after a later frame flipped).
@@ -131,28 +112,6 @@ type Output struct {
131 // Ready reports the result of the first modeset exactly once.112 // Ready reports the result of the first modeset exactly once.
132 func (o *Output) Ready() <-chan error { return o.ready }113 func (o *Output) Ready() <-chan error { return o.ready }
133 114
134-// pendingFrame is what the pending frame commit shows.
135-type pendingFrame struct {
136- frame bool
137- queued uint64
138- zeroCopy ports.WindowID // window shown without composition
139- async bool
140- shows map[ports.WindowID]uint64
141- // fences are duplicates of the fences the commit waits on, owned by
142- // the output until the commit ends. composed is set when the frame
143- // was rendered (its fence is the renderer's).
144- fences []*os.File
145- composed bool
146-}
147-
148-// closeFences closes the fences of f.
149-func (f *pendingFrame) closeFences() {
150- for _, fd := range f.fences {
151- fd.Close()
152- }
153- f.fences = nil
154-}
155-
156 // dupFences duplicates the non-nil fences.115 // dupFences duplicates the non-nil fences.
157 func dupFences(fs ...*os.File) []*os.File {116 func dupFences(fs ...*os.File) []*os.File {
158 var out []*os.File117 var out []*os.File
@@ -214,81 +173,28 @@ func (o *Output) dropRead() {
214 o.readFences = nil173 o.readFences = nil
215 }174 }
216 175
217-// endPending ends the pending commit, closing its fences.176+// expire applies a lifecycle deadline and reports whether KMS needs a modeset.
218-func (o *Output) endPending() {177+// Fence polling and logging stay at the output boundary, not in the value
219- o.pendingFrame.closeFences()178+// transition; the lifecycle owns the decision and closes pending fences.
220- o.pending, o.pendingFrame = false, pendingFrame{}
221-}
222-
223-// Commit event userData: the commit serial above userKindBits, the kind
224-// of commit below.
225-const (
226- userFrame = 1
227- userState = 2
228- userKindBits = 2
229-)
230-
231-// userData takes a new serial for the next commit of kind and returns
232-// what the commit carries.
233-func (o *Output) userData(kind uint64) uint64 {
234- o.nextSerial = o.serials.Add(1)
235- return o.nextSerial<<userKindBits | kind
236-}
237-
238-// begin marks the commit made with the last userData pending.
239-func (o *Output) begin(f pendingFrame) {
240- o.pendingSerial = o.nextSerial
241- o.pendingFrame.closeFences()
242- o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
243- o.stuckAt, o.fenceWarned = o.flipStart.Add(o.stuckLimit()), false
244- o.busySince = time.Time{}
245-}
246-
247-// Bounds on waiting for a commit event: no path may leave an output
248-// pending forever.
249-const (
250- stuckTimeout = time.Second
251- busyRetry = 50 * time.Millisecond
252-)
253-
254-func (o *Output) stuckLimit() time.Duration {
255- if o.stuckAfter > 0 {
256- return o.stuckAfter
257- }
258- return stuckTimeout
259-}
260-
261-// expire handles a pending commit whose event did not come by stuckAt.
262-// A commit still waiting for a fence (a long GPU job) keeps waiting.
263-// Else the commit is abandoned so the next can go; it reports whether
264-// the output needs a modeset: an event is missing (the kernel state is
265-// unknown) or EBUSY lasted past the limit.
266 func (o *Output) expire() bool {179 func (o *Output) expire() bool {
267 now := time.Now()180 now := time.Now()
268- age := now.Sub(o.flipStart)181+ serial, frame, age, busy, fences := o.frame.deadlineInfo(now)
269- if o.pendingSerial != 0 && !signalled(o.pendingFrame.fences) {182+ ready := serial == 0 || !o.frame.deadlineReached(now) || signalled(fences)
270- if !o.fenceWarned {183+ switch o.frame.timeout(now, ready) {
271- o.fenceWarned = true184+ case timeoutFence:
272- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Dur("age", age).Msg("frame waits for GPU fence")185+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Dur("age", age).Msg("frame waits for GPU fence")
273- }186+ case timeoutBusy:
274- o.stuckAt = now.Add(o.stuckLimit())
275- return false
276- }
277- kind := "state"
278- if o.pendingFrame.frame {
279- kind = "frame"
280- }
281- o.endPending()
282- if o.pendingSerial == 0 {
283- busy := now.Sub(o.busySince)
284- if o.busySince.IsZero() || busy < o.stuckLimit() {
285- return false
286- }
287 o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")187 o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
288 return true188 return true
189+ case timeoutMissing:
190+ kind := "state"
191+ if frame {
192+ kind = "frame"
193+ }
194+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
195+ return true
289 }196 }
290- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", o.pendingSerial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")197+ return false
291- return true
292 }198 }
293 199
294 // CursorLoader returns the image of a cursor at an output scale, at most200 // CursorLoader returns the image of a cursor at an output scale, at most
@@ -299,7 +205,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
299 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {205 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
300 log := card.log206 log := card.log
301 pipe := slices.Index(card.crtcs, crtc)207 pipe := slices.Index(card.crtcs, crtc)
302- o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}208+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)}
303 var err error209 var err error
304 if o.saved, err = getCrtc(card.fd, crtc); err != nil {210 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
305 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")211 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -451,8 +357,7 @@ func (o *Output) modeset() error {
451 }357 }
452 // Only now is nothing of ours pending or on screen: on failure the358 // Only now is nothing of ours pending or on screen: on failure the
453 // previous buffers may still show.359 // previous buffers may still show.
454- o.endPending()360+ o.frame.resetAfterModeset()
455- o.busySince = time.Time{}
456 o.shown, o.queued = 0, 0361 o.shown, o.queued = 0, 0
457 if o.modeBlob != 0 {362 if o.modeBlob != 0 {
458 _ = o.k.destroyBlob(o.modeBlob)363 _ = o.k.destroyBlob(o.modeBlob)
@@ -670,7 +575,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
670 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))575 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
671 }576 }
672 // The kernel takes its own reference on the fence.577 // The kernel takes its own reference on the fence.
673- if err := o.k.commit(req, flags, o.userData(userFrame)); err != nil {578+ if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
674 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {579 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
675 return o.commitWith(fb, fence, false, vrr, f, ov)580 return o.commitWith(fb, fence, false, vrr, f, ov)
676 }581 }
@@ -702,7 +607,7 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
702 f.frame, f.async = true, async607 f.frame, f.async = true, async
703 f.fences = dupFences(fence, ov.acquire)608 f.fences = dupFences(fence, ov.acquire)
704 o.setAsync(async)609 o.setAsync(async)
705- o.begin(f)610+ o.frame.begin(f, time.Now())
706 return nil611 return nil
707 }612 }
708 613
@@ -730,7 +635,7 @@ func (o *Output) commitState(vrr bool) error {
730 if len(req.objs) == 0 {635 if len(req.objs) == 0 {
731 return nil636 return nil
732 }637 }
733- if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.userData(userState)); err != nil {638+ if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
734 if o.overlayConflict(err, o.overlayOn) {639 if o.overlayConflict(err, o.overlayOn) {
735 // The next frame is composed without the overlay.640 // The next frame is composed without the overlay.
736 return errOverlayDropped641 return errOverlayDropped
@@ -747,7 +652,7 @@ func (o *Output) commitState(vrr bool) error {
747 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")652 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
748 }653 }
749 o.vrrOn = vrr654 o.vrrOn = vrr
750- o.begin(pendingFrame{})655+ o.frame.begin(pendingFrame{}, time.Now())
751 return nil656 return nil
752 }657 }
753 658
@@ -758,41 +663,6 @@ func boolValue(b bool) uint64 {
758 return 0663 return 0
759 }664 }
760 665
761-// scanoutFrame picks a fullscreen client buffer to flip directly. It
762-// returns fb 0, with the reason logged on change, when the frame must be
763-// composed.
764-func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
765- reason := "disabled"
766- if o.scanout {
767- c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
768- if reason == "" && isYUVFormat(c.DMABuf.Format) {
769- reason = "yuv"
770- }
771- if o.hdrOn && reason == "" {
772- switch {
773- case !c.Color.IsPQ2020():
774- reason = "hdr_sdr_content"
775- case !isTenBit(c.DMABuf.Format):
776- reason = "hdr_format"
777- }
778- } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
779- // An SDR connector must never show raw PQ values.
780- reason = "sdr_pq_content"
781- }
782- }
783- if reason == "" {
784- fb, reason = o.scanoutFB(c.DMABuf, time.Now())
785- }
786- if reason != o.reason {
787- o.log.Info().Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
788- o.reason = reason
789- }
790- if reason != "" {
791- return 0, c
792- }
793- return fb, c
794-}
795-
796 // commitScanout flips a client buffer. It reports false when KMS refused666 // commitScanout flips a client buffer. It reports false when KMS refused
797 // the buffer: the frame must be composed.667 // the buffer: the frame must be composed.
798 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {668 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
@@ -819,9 +689,9 @@ func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame
819 }689 }
820 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {690 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
821 // KMS refuses this buffer on the plane: compose it instead.691 // KMS refuses this buffer on the plane: compose it instead.
822- o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")692+ o.log.Info().Str("component", "render").Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
823- cfb.failed, o.reason = "flip_refused", "flip_refused"693+ cfb.failed = "flip_refused"
824- o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")694+ o.setScanoutReason("flip_refused")
825 return false, nil695 return false, nil
826 }696 }
827 if err == nil {697 if err == nil {
@@ -911,7 +781,7 @@ func (o *Output) Close() {
911 o.cursor.free(o.k)781 o.cursor.free(o.k)
912 }782 }
913 o.freeImages()783 o.freeImages()
914- o.endPending()784+ o.frame.endPending()
915 o.dropRead()785 o.dropRead()
916 o.shown, o.queued = 0, 0786 o.shown, o.queued = 0, 0
917 o.dropClientFBs(time.Now(), true)787 o.dropClientFBs(time.Now(), true)
@@ -987,13 +857,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
987 // guarantees a stopped/reset timer cannot deliver its old value.857 // guarantees a stopped/reset timer cannot deliver its old value.
988 retryTimer := time.NewTimer(time.Hour)858 retryTimer := time.NewTimer(time.Hour)
989 vrrTimer := time.NewTimer(time.Hour)859 vrrTimer := time.NewTimer(time.Hour)
990- stuckTimer := time.NewTimer(time.Hour)860+ o.frame.startTimer()
991 retryTimer.Stop()861 retryTimer.Stop()
992 vrrTimer.Stop()862 vrrTimer.Stop()
993- stuckTimer.Stop()
994 defer retryTimer.Stop()863 defer retryTimer.Stop()
995 defer vrrTimer.Stop()864 defer vrrTimer.Stop()
996- defer stuckTimer.Stop()865+ defer o.frame.stopTimer()
997 // seen is the latest content Seq per window. It is reported only while866 // seen is the latest content Seq per window. It is reported only while
998 // no frame is in flight: then the GPU has finished every frame that867 // no frame is in flight: then the GPU has finished every frame that
999 // read older buffers (the kernel waited for their fence to flip).868 // read older buffers (the kernel waited for their fence to flip).
@@ -1005,7 +874,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1005 }874 }
1006 for {875 for {
1007 readWait := false876 readWait := false
1008- if reportDirty && !o.pending {877+ if reportDirty && !o.frame.pendingCommit() {
1009 if readWait = !o.readDone(); !readWait {878 if readWait = !o.readDone(); !readWait {
1010 o.report(nil, seen)879 o.report(nil, seen)
1011 reportDirty = false880 reportDirty = false
@@ -1023,26 +892,20 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1023 }892 }
1024 // A composed screen that stopped changing still drops VRR on time.893 // A composed screen that stopped changing still drops VRR on time.
1025 var vrrOff <-chan time.Time894 var vrrOff <-chan time.Time
1026- if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {895+ if enabled && o.vrrOn && !o.frame.pendingCommit() && !o.composedSince.IsZero() {
1027 vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince)))896 vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince)))
1028 vrrOff = vrrTimer.C897 vrrOff = vrrTimer.C
1029 } else {898 } else {
1030 vrrTimer.Stop()899 vrrTimer.Stop()
1031 }900 }
1032 // A commit whose event never comes must not stop the output.901 // A commit whose event never comes must not stop the output.
1033- var stuck <-chan time.Time902+ stuck := o.frame.wait(enabled)
1034- if enabled && o.pending {
1035- stuckTimer.Reset(max(0, time.Until(o.stuckAt)))
1036- stuck = stuckTimer.C
1037- } else {
1038- stuckTimer.Stop()
1039- }
1040 stateDirty := false903 stateDirty := false
1041 select {904 select {
1042 case <-retry:905 case <-retry:
1043 continue906 continue
1044 case <-stuck:907 case <-stuck:
1045- if o.pending && o.stuckAt.After(time.Now()) {908+ if !o.frame.deadlineReached(time.Now()) {
1046 continue // waits for a fence909 continue // waits for a fence
1047 }910 }
1048 if o.expire() {911 if o.expire() {
@@ -1132,7 +995,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1132 dirty = dirty || scene.Shows(c.ID)995 dirty = dirty || scene.Shows(c.ID)
1133 case <-stats.C:996 case <-stats.C:
1134 o.dropClientFBs(time.Now(), false)997 o.dropClientFBs(time.Now(), false)
1135- ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.pending)998+ ev := o.log.Info().Str("connector", o.conn.name).Int("frames", frame).Int("flips", o.flips).Bool("pending", o.frame.pendingCommit())
1136 if o.cursor != nil {999 if o.cursor != nil {
1137 cs := o.cursor.TakeStats()1000 cs := o.cursor.TakeStats()
1138 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)1001 ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)
@@ -1145,7 +1008,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1145 }1008 }
1146 requests = nil1009 requests = nil
1147 }1010 }
1148- if !enabled || o.pending {1011+ if !enabled || o.frame.pendingCommit() {
1149 continue1012 continue
1150 }1013 }
1151 // Output power: off waits for no commit in flight; on is a modeset1014 // Output power: off waits for no commit in flight; on is a modeset
@@ -1181,21 +1044,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1181 // shows is what the frame puts on screen: presentation feedback1044 // shows is what the frame puts on screen: presentation feedback
1182 // of windows it does not draw is discarded, not presented.1045 // of windows it does not draw is discarded, not presented.
1183 f := pendingFrame{shows: o.shownBy(scene, seen)}1046 f := pendingFrame{shows: o.shownBy(scene, seen)}
1184- var fb uint321047+ decision := o.decideFrame(scene, surfaces, len(requests) > 0)
1185- var c ports.SurfaceContent
1186- forceCompose := len(requests) > 0
1187- if !forceCompose {
1188- fb, c = o.scanoutFrame(scene, surfaces)
1189- }
1190 direct := false1048 direct := false
1191- if fb != 0 {1049+ if decision.fb != 0 {
1192- direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})1050+ c := decision.content
1051+ direct, err = o.commitScanout(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})
1193 }1052 }
1194 if !direct {1053 if !direct {
1195- var ov overlayWin1054+ ov, composed := decision.overlay, decision.composed
1196- composed := scene1055+ if decision.fb != 0 {
1197- if !forceCompose {1056+ decision = o.composeFrame(scene, surfaces)
1198- ov, composed = o.overlayFrame(scene, surfaces)1057+ ov, composed = decision.overlay, decision.composed
1199 }1058 }
1200 r.UseTarget(o.back)1059 r.UseTarget(o.back)
1201 done, rerr := r.Render(composed, surfaces)1060 done, rerr := r.Render(composed, surfaces)
@@ -1267,19 +1126,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1267 func (o *Output) commitFailed(err error, enabled *bool) bool {1126 func (o *Output) commitFailed(err error, enabled *bool) bool {
1268 switch {1127 switch {
1269 case errors.Is(err, unix.EBUSY):1128 case errors.Is(err, unix.EBUSY):
1270- // The kernel still works on an earlier commit (e.g. across a VT1129+ // Any CRTC event can unblock a refusal; absent one, retry on time.
1271- // switch, or one whose event already came): wait for any event1130+ if o.frame.busy(time.Now()) {
1272- // of this CRTC, or retry after busyRetry since none may come.
1273- o.endPending()
1274- now := time.Now()
1275- o.pending, o.flipStart, o.stuckAt = true, now, now.Add(busyRetry)
1276- o.pendingSerial = 0
1277- // A run of EBUSY: each refusal follows the previous retry.
1278- if o.busySince.IsZero() || now.Sub(o.lastBusy) > 4*busyRetry {
1279- o.busySince = now
1280 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")1131 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
1281 }1132 }
1282- o.lastBusy = now
1283 return true1133 return true
1284 case lostMaster(err):1134 case lostMaster(err):
1285 o.log.Warn().Err(err).Msg("commit refused")1135 o.log.Warn().Err(err).Msg("commit refused")
@@ -1294,14 +1144,12 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
1294 // reported with its kernel timestamp. It reports false for an event of1144 // reported with its kernel timestamp. It reports false for an event of
1295 // another commit, which is dropped.1145 // another commit, which is dropped.
1296 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {1146 func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1297- if !o.pending || o.pendingSerial != 0 && ev.user>>userKindBits != o.pendingSerial {1147+ f, start, ok := o.frame.flip(ev.user)
1148+ if !ok {
1298 return false1149 return false
1299 }1150 }
1300- f := o.pendingFrame1151+ if age := time.Since(start); age > 20*time.Millisecond {
1301- o.endPending()1152+ o.log.Info().Dur("flip_ms", age).Str("connector", o.conn.name).Msg("slow flip")
1302- o.busySince = time.Time{}
1303- if d := time.Since(o.flipStart); d > 20*time.Millisecond {
1304- o.log.Info().Dur("flip_ms", d).Str("connector", o.conn.name).Msg("slow flip")
1305 }1153 }
1306 if o.cursor != nil {1154 if o.cursor != nil {
1307 o.cursor.landed()1155 o.cursor.landed()
M internal/adapters/drm/output_test.go +11 -11
@@ -64,7 +64,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
6464 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}
6565 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)
6666 cur.fbs = [2]uint32{90, 91}
67- o := &Output{k: k, serials: &atomic.Uint64{}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
67+ o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
6868 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
6969 primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
7070 return o, k, &commits, commitMu
@@ -89,7 +89,7 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
8989 t.Fatal(err)
9090 }
9191 c := (*commits)[0]
92- if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.pendingSerial {
92+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.frame.pendingSerial {
9393 t.Fatalf("flags %#x user %d", c.flags, c.user)
9494 }
9595 if v, _ := c.req.value(tPrimary, pFB); v != 70 {
@@ -104,8 +104,8 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
104104 if v, _ := c.req.value(tCrtc, pVRR); v != 1 {
105105 t.Fatalf("vrr %d", v)
106106 }
107- if !o.pending || !o.vrrOn || o.cursor.applied.x != 12 {
108- t.Fatalf("state pending=%v vrr=%v cursor=%+v", o.pending, o.vrrOn, o.cursor.applied)
107+ if !o.frame.pendingCommit() || !o.vrrOn || o.cursor.applied.x != 12 {
108+ t.Fatalf("state pending=%v vrr=%v cursor=%+v", o.frame.pendingCommit(), o.vrrOn, o.cursor.applied)
109109 }
110110 }
111111
@@ -177,14 +177,14 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
177177 t.Fatal(err)
178178 }
179179 c := (*commits)[0]
180- if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.pending {
181- t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)
180+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.frame.pendingCommit() {
181+ t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.frame.pendingCommit())
182182 }
183183 // Its event is not a frame: nothing is reported.
184184 seen := map[ports.WindowID]uint64{}
185185 o.completed(eventOf(c), seen)
186- if o.pending || len(o.unsent) != 0 || o.flips != 0 {
187- t.Fatalf("pending=%v unsent=%d flips=%d", o.pending, len(o.unsent), o.flips)
186+ if o.frame.pendingCommit() || len(o.unsent) != 0 || o.flips != 0 {
187+ t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), len(o.unsent), o.flips)
188188 }
189189 // Nothing changed: no commit.
190190 if err := o.commitState(false); err != nil || len(*commits) != 1 {
@@ -196,8 +196,8 @@ func TestBusyCommitStaysPending(t *testing.T) {
196196 o, _, _ := testOutput(t, unix.EBUSY)
197197 err := o.commitFrame(70, nil, false, false, pendingFrame{})
198198 enabled := true
199- if !o.commitFailed(err, &enabled) || !o.pending || !enabled {
200- t.Fatalf("EBUSY: pending=%v enabled=%v", o.pending, enabled)
199+ if !o.commitFailed(err, &enabled) || !o.frame.pendingCommit() || !enabled {
200+ t.Fatalf("EBUSY: pending=%v enabled=%v", o.frame.pendingCommit(), enabled)
201201 }
202202 }
203203
@@ -423,7 +423,7 @@ func TestVRRRefusedIsDisabled(t *testing.T) {
423423 if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
424424 t.Fatal(err)
425425 }
426- if len(*commits) != 2 || o.vrrProp != 0 || !o.pending {
426+ if len(*commits) != 2 || o.vrrProp != 0 || !o.frame.pendingCommit() {
427427 t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)
428428 }
429429 if v, _ := (*commits)[1].req.value(tCrtc, pVRR); v != 0 {
@@ -64,7 +64,7 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
64 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}64 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}
65 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)65 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)
66 cur.fbs = [2]uint32{90, 91}66 cur.fbs = [2]uint32{90, 91}
67- o := &Output{k: k, serials: &atomic.Uint64{}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),67+ o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
68 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,68 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
69 primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}69 primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
70 return o, k, &commits, commitMu70 return o, k, &commits, commitMu
@@ -89,7 +89,7 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
89 t.Fatal(err)89 t.Fatal(err)
90 }90 }
91 c := (*commits)[0]91 c := (*commits)[0]
92- if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.pendingSerial {92+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.frame.pendingSerial {
93 t.Fatalf("flags %#x user %d", c.flags, c.user)93 t.Fatalf("flags %#x user %d", c.flags, c.user)
94 }94 }
95 if v, _ := c.req.value(tPrimary, pFB); v != 70 {95 if v, _ := c.req.value(tPrimary, pFB); v != 70 {
@@ -104,8 +104,8 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
104 if v, _ := c.req.value(tCrtc, pVRR); v != 1 {104 if v, _ := c.req.value(tCrtc, pVRR); v != 1 {
105 t.Fatalf("vrr %d", v)105 t.Fatalf("vrr %d", v)
106 }106 }
107- if !o.pending || !o.vrrOn || o.cursor.applied.x != 12 {107+ if !o.frame.pendingCommit() || !o.vrrOn || o.cursor.applied.x != 12 {
108- t.Fatalf("state pending=%v vrr=%v cursor=%+v", o.pending, o.vrrOn, o.cursor.applied)108+ t.Fatalf("state pending=%v vrr=%v cursor=%+v", o.frame.pendingCommit(), o.vrrOn, o.cursor.applied)
109 }109 }
110 }110 }
111 111
@@ -177,14 +177,14 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
177 t.Fatal(err)177 t.Fatal(err)
178 }178 }
179 c := (*commits)[0]179 c := (*commits)[0]
180- if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.pending {180+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.frame.pendingCommit() {
181- t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)181+ t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.frame.pendingCommit())
182 }182 }
183 // Its event is not a frame: nothing is reported.183 // Its event is not a frame: nothing is reported.
184 seen := map[ports.WindowID]uint64{}184 seen := map[ports.WindowID]uint64{}
185 o.completed(eventOf(c), seen)185 o.completed(eventOf(c), seen)
186- if o.pending || len(o.unsent) != 0 || o.flips != 0 {186+ if o.frame.pendingCommit() || len(o.unsent) != 0 || o.flips != 0 {
187- t.Fatalf("pending=%v unsent=%d flips=%d", o.pending, len(o.unsent), o.flips)187+ t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), len(o.unsent), o.flips)
188 }188 }
189 // Nothing changed: no commit.189 // Nothing changed: no commit.
190 if err := o.commitState(false); err != nil || len(*commits) != 1 {190 if err := o.commitState(false); err != nil || len(*commits) != 1 {
@@ -196,8 +196,8 @@ func TestBusyCommitStaysPending(t *testing.T) {
196 o, _, _ := testOutput(t, unix.EBUSY)196 o, _, _ := testOutput(t, unix.EBUSY)
197 err := o.commitFrame(70, nil, false, false, pendingFrame{})197 err := o.commitFrame(70, nil, false, false, pendingFrame{})
198 enabled := true198 enabled := true
199- if !o.commitFailed(err, &enabled) || !o.pending || !enabled {199+ if !o.commitFailed(err, &enabled) || !o.frame.pendingCommit() || !enabled {
200- t.Fatalf("EBUSY: pending=%v enabled=%v", o.pending, enabled)200+ t.Fatalf("EBUSY: pending=%v enabled=%v", o.frame.pendingCommit(), enabled)
201 }201 }
202 }202 }
203 203
@@ -423,7 +423,7 @@ func TestVRRRefusedIsDisabled(t *testing.T) {
423 if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {423 if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
424 t.Fatal(err)424 t.Fatal(err)
425 }425 }
426- if len(*commits) != 2 || o.vrrProp != 0 || !o.pending {426+ if len(*commits) != 2 || o.vrrProp != 0 || !o.frame.pendingCommit() {
427 t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)427 t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)
428 }428 }
429 if v, _ := (*commits)[1].req.value(tCrtc, pVRR); v != 0 {429 if v, _ := (*commits)[1].req.value(tCrtc, pVRR); v != 0 {
M internal/adapters/drm/overlay.go +2 -46
@@ -3,7 +3,6 @@ package drm
33 import (
44 "errors"
55 "os"
6- "time"
76
87 "github.com/bnema/neferwl/internal/ports"
98 "golang.org/x/sys/unix"
@@ -107,49 +106,6 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107106 return *pick, c, ""
108107 }
109108
110-// overlayFrame decides the overlay of a frame: the window, and the scene
111-// the renderer composes (the window left out). A zero overlayWin means
112-// none; the reason is logged on change.
113-func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
114- reason := "no_plane"
115- var ov overlayWin
116- if o.hdrOn {
117- reason = "hdr"
118- } else if o.overlay != nil && o.scanout {
119- var w ports.SceneWindow
120- var c ports.SurfaceContent
121- w, c, reason = overlayCandidate(s, surfaces)
122- if reason == "" {
123- var fb uint32
124- if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
125- scale := s.Scale
126- if scale <= 0 {
127- scale = 1
128- }
129- ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
130- rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
131- acquire: dupFence(c.Acquire)}
132- }
133- }
134- }
135- if reason != o.overlayReason {
136- o.log.Info().Str("component", "render").Bool("overlay", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("overlay")
137- o.overlayReason = reason
138- }
139- if ov.fb == 0 {
140- return overlayWin{}, s
141- }
142- // The composed frame leaves the window out: the overlay shows it.
143- rest := s
144- rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))
145- for _, w := range s.Windows {
146- if w.ID != ov.id {
147- rest.Windows = append(rest.Windows, w)
148- }
149- }
150- return ov, rest
151-}
152-
153109 // overlayProps puts ov on the overlay plane, or turns it off.
154110 func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
155111 p := o.overlay
@@ -201,6 +157,7 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
201157 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
202158 cfb.overlayFailed = "overlay_refused"
203159 }
160+ o.setOverlayReason("overlay_refused")
204161 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")
205162 return false
206163 }
@@ -219,7 +176,6 @@ func (o *Output) overlayConflict(err error, buf uint64) bool {
219176 if cfb := o.clientFBs[buf]; cfb != nil {
220177 cfb.overlayFailed = "cursor_conflict"
221178 }
222- o.log.Info().Str("component", "render").Bool("overlay", false).Str("reason", "cursor_conflict").Str("connector", o.conn.name).Msg("overlay")
223- o.overlayReason = "cursor_conflict"
179+ o.setOverlayReason("cursor_conflict")
224180 return true
225181 }
@@ -3,7 +3,6 @@ package drm
3 import (3 import (
4 "errors"4 "errors"
5 "os"5 "os"
6- "time"
7 6
8 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
9 "golang.org/x/sys/unix"8 "golang.org/x/sys/unix"
@@ -107,49 +106,6 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107 return *pick, c, ""106 return *pick, c, ""
108 }107 }
109 108
110-// overlayFrame decides the overlay of a frame: the window, and the scene
111-// the renderer composes (the window left out). A zero overlayWin means
112-// none; the reason is logged on change.
113-func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
114- reason := "no_plane"
115- var ov overlayWin
116- if o.hdrOn {
117- reason = "hdr"
118- } else if o.overlay != nil && o.scanout {
119- var w ports.SceneWindow
120- var c ports.SurfaceContent
121- w, c, reason = overlayCandidate(s, surfaces)
122- if reason == "" {
123- var fb uint32
124- if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
125- scale := s.Scale
126- if scale <= 0 {
127- scale = 1
128- }
129- ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
130- rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
131- acquire: dupFence(c.Acquire)}
132- }
133- }
134- }
135- if reason != o.overlayReason {
136- o.log.Info().Str("component", "render").Bool("overlay", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("overlay")
137- o.overlayReason = reason
138- }
139- if ov.fb == 0 {
140- return overlayWin{}, s
141- }
142- // The composed frame leaves the window out: the overlay shows it.
143- rest := s
144- rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))
145- for _, w := range s.Windows {
146- if w.ID != ov.id {
147- rest.Windows = append(rest.Windows, w)
148- }
149- }
150- return ov, rest
151-}
152-
153 // overlayProps puts ov on the overlay plane, or turns it off.109 // overlayProps puts ov on the overlay plane, or turns it off.
154 func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {110 func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
155 p := o.overlay111 p := o.overlay
@@ -201,6 +157,7 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
201 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {157 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
202 cfb.overlayFailed = "overlay_refused"158 cfb.overlayFailed = "overlay_refused"
203 }159 }
160+ o.setOverlayReason("overlay_refused")
204 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")161 o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused")
205 return false162 return false
206 }163 }
@@ -219,7 +176,6 @@ func (o *Output) overlayConflict(err error, buf uint64) bool {
219 if cfb := o.clientFBs[buf]; cfb != nil {176 if cfb := o.clientFBs[buf]; cfb != nil {
220 cfb.overlayFailed = "cursor_conflict"177 cfb.overlayFailed = "cursor_conflict"
221 }178 }
222- o.log.Info().Str("component", "render").Bool("overlay", false).Str("reason", "cursor_conflict").Str("connector", o.conn.name).Msg("overlay")179+ o.setOverlayReason("cursor_conflict")
223- o.overlayReason = "cursor_conflict"
224 return true180 return true
225 }181 }
M internal/adapters/drm/overlay_test.go +4 -1
@@ -105,6 +105,9 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
105105 if o.testOverlay(70, ov) {
106106 t.Fatal("refusal taken")
107107 }
108+ if o.overlayReason != "overlay_refused" {
109+ t.Fatalf("refusal reason %q", o.overlayReason)
110+ }
108111 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {
109112 t.Fatal("refused buffer tried again")
110113 }
@@ -141,7 +144,7 @@ func TestOverlayCursorConflictDropsOverlay(t *testing.T) {
141144 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
142145 t.Fatal(err)
143146 }
144- o.pending = false
147+ o.frame.endPending()
145148 o.cursor.image = true
146149 o.cursor.Move(150, 50)
147150 if err := o.commitState(false); err != errOverlayDropped {
@@ -105,6 +105,9 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
105 if o.testOverlay(70, ov) {105 if o.testOverlay(70, ov) {
106 t.Fatal("refusal taken")106 t.Fatal("refusal taken")
107 }107 }
108+ if o.overlayReason != "overlay_refused" {
109+ t.Fatalf("refusal reason %q", o.overlayReason)
110+ }
108 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {111 if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 {
109 t.Fatal("refused buffer tried again")112 t.Fatal("refused buffer tried again")
110 }113 }
@@ -141,7 +144,7 @@ func TestOverlayCursorConflictDropsOverlay(t *testing.T) {
141 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {144 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
142 t.Fatal(err)145 t.Fatal(err)
143 }146 }
144- o.pending = false147+ o.frame.endPending()
145 o.cursor.image = true148 o.cursor.image = true
146 o.cursor.Move(150, 50)149 o.cursor.Move(150, 50)
147 if err := o.commitState(false); err != errOverlayDropped {150 if err := o.commitState(false); err != errOverlayDropped {
M internal/adapters/drm/scanout.go +122 -1
@@ -99,6 +99,127 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9999 return c, ""
100100 }
101101
102+// scanoutFrame picks a fullscreen client buffer to flip directly. It
103+// returns fb 0, with the reason logged on change, when the frame must be
104+// composed.
105+func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
106+ reason := "disabled"
107+ if o.scanout {
108+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
109+ if reason == "" && isYUVFormat(c.DMABuf.Format) {
110+ reason = "yuv"
111+ }
112+ if o.hdrOn && reason == "" {
113+ switch {
114+ case !c.Color.IsPQ2020():
115+ reason = "hdr_sdr_content"
116+ case !isTenBit(c.DMABuf.Format):
117+ reason = "hdr_format"
118+ }
119+ } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
120+ // An SDR connector must never show raw PQ values.
121+ reason = "sdr_pq_content"
122+ }
123+ }
124+ if reason == "" {
125+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
126+ }
127+ o.setScanoutReason(reason)
128+ if reason != "" {
129+ return 0, c
130+ }
131+ return fb, c
132+}
133+
134+// overlayFrame decides the overlay of a frame: the window, and the scene
135+// the renderer composes (the window left out). A zero overlayWin means
136+// none; the reason is logged on change.
137+func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
138+ reason := "no_plane"
139+ var ov overlayWin
140+ if o.hdrOn {
141+ reason = "hdr"
142+ } else if o.overlay != nil && o.scanout {
143+ var w ports.SceneWindow
144+ var c ports.SurfaceContent
145+ w, c, reason = overlayCandidate(s, surfaces)
146+ if reason == "" {
147+ var fb uint32
148+ if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
149+ scale := s.Scale
150+ if scale <= 0 {
151+ scale = 1
152+ }
153+ ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
154+ rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
155+ acquire: dupFence(c.Acquire)}
156+ }
157+ }
158+ }
159+ o.setOverlayReason(reason)
160+ if ov.fb == 0 {
161+ return overlayWin{}, s
162+ }
163+ // The composed frame leaves the window out: the overlay shows it.
164+ rest := s
165+ rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))
166+ for _, w := range s.Windows {
167+ if w.ID != ov.id {
168+ rest.Windows = append(rest.Windows, w)
169+ }
170+ }
171+ return ov, rest
172+}
173+
174+// frameDecision selects the zero-copy path or the scene to compose.
175+type frameDecision struct {
176+ fb uint32
177+ content ports.SurfaceContent
178+ overlay overlayWin
179+ composed ports.Scene
180+}
181+
182+// composeFrame chooses overlay or composition after direct scanout cannot commit.
183+// The caller owns the returned overlay fence.
184+func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) frameDecision {
185+ ov, rest := o.overlayFrame(s, surfaces)
186+ if ov.fb != 0 {
187+ return frameDecision{overlay: ov, composed: rest}
188+ }
189+ return frameDecision{composed: s}
190+}
191+
192+// decideFrame tries direct scanout, then overlay, then composition.
193+// Captures require composition; the caller owns the returned overlay fence.
194+func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, capture bool) frameDecision {
195+ if capture {
196+ o.setScanoutReason("capture")
197+ o.setOverlayReason("capture")
198+ return frameDecision{composed: s}
199+ }
200+ fb, c := o.scanoutFrame(s, surfaces)
201+ if fb != 0 {
202+ return frameDecision{fb: fb, content: c}
203+ }
204+ return o.composeFrame(s, surfaces)
205+}
206+
207+// setScanoutReason logs a direct-scanout transition once.
208+func (o *Output) setScanoutReason(reason string) {
209+ if reason != o.reason {
210+ o.log.Info().Str("component", "render").Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
211+ o.reason = reason
212+ }
213+}
214+
215+// setOverlayReason logs an overlay transition once.
216+func (o *Output) setOverlayReason(reason string) {
217+ if reason != o.overlayReason {
218+ o.log.Info().Str("component", "render").Bool("overlay", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("overlay")
219+ o.overlayReason = reason
220+ }
221+}
222+
102223 // clientFB is a client buffer imported as a KMS framebuffer.
103224 type clientFB struct {
104225 fbID uint32
@@ -151,7 +272,7 @@ func (o *Output) planeFB(p *plane, b *ports.DMABuf, now time.Time, reason func(*
151272 }
152273 id, err := o.k.addFB(b, format)
153274 if err != nil {
154- o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
275+ o.log.Info().Str("component", "render").Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
155276 fb.importErr, *why = true, "import_failed"
156277 return 0, *why
157278 }
@@ -99,6 +99,127 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
99 return c, ""99 return c, ""
100 }100 }
101 101
102+// scanoutFrame picks a fullscreen client buffer to flip directly. It
103+// returns fb 0, with the reason logged on change, when the frame must be
104+// composed.
105+func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
106+ reason := "disabled"
107+ if o.scanout {
108+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
109+ if reason == "" && isYUVFormat(c.DMABuf.Format) {
110+ reason = "yuv"
111+ }
112+ if o.hdrOn && reason == "" {
113+ switch {
114+ case !c.Color.IsPQ2020():
115+ reason = "hdr_sdr_content"
116+ case !isTenBit(c.DMABuf.Format):
117+ reason = "hdr_format"
118+ }
119+ } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
120+ // An SDR connector must never show raw PQ values.
121+ reason = "sdr_pq_content"
122+ }
123+ }
124+ if reason == "" {
125+ fb, reason = o.scanoutFB(c.DMABuf, time.Now())
126+ }
127+ o.setScanoutReason(reason)
128+ if reason != "" {
129+ return 0, c
130+ }
131+ return fb, c
132+}
133+
134+// overlayFrame decides the overlay of a frame: the window, and the scene
135+// the renderer composes (the window left out). A zero overlayWin means
136+// none; the reason is logged on change.
137+func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
138+ reason := "no_plane"
139+ var ov overlayWin
140+ if o.hdrOn {
141+ reason = "hdr"
142+ } else if o.overlay != nil && o.scanout {
143+ var w ports.SceneWindow
144+ var c ports.SurfaceContent
145+ w, c, reason = overlayCandidate(s, surfaces)
146+ if reason == "" {
147+ var fb uint32
148+ if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
149+ scale := s.Scale
150+ if scale <= 0 {
151+ scale = 1
152+ }
153+ ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
154+ rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
155+ acquire: dupFence(c.Acquire)}
156+ }
157+ }
158+ }
159+ o.setOverlayReason(reason)
160+ if ov.fb == 0 {
161+ return overlayWin{}, s
162+ }
163+ // The composed frame leaves the window out: the overlay shows it.
164+ rest := s
165+ rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))
166+ for _, w := range s.Windows {
167+ if w.ID != ov.id {
168+ rest.Windows = append(rest.Windows, w)
169+ }
170+ }
171+ return ov, rest
172+}
173+
174+// frameDecision selects the zero-copy path or the scene to compose.
175+type frameDecision struct {
176+ fb uint32
177+ content ports.SurfaceContent
178+ overlay overlayWin
179+ composed ports.Scene
180+}
181+
182+// composeFrame chooses overlay or composition after direct scanout cannot commit.
183+// The caller owns the returned overlay fence.
184+func (o *Output) composeFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) frameDecision {
185+ ov, rest := o.overlayFrame(s, surfaces)
186+ if ov.fb != 0 {
187+ return frameDecision{overlay: ov, composed: rest}
188+ }
189+ return frameDecision{composed: s}
190+}
191+
192+// decideFrame tries direct scanout, then overlay, then composition.
193+// Captures require composition; the caller owns the returned overlay fence.
194+func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, capture bool) frameDecision {
195+ if capture {
196+ o.setScanoutReason("capture")
197+ o.setOverlayReason("capture")
198+ return frameDecision{composed: s}
199+ }
200+ fb, c := o.scanoutFrame(s, surfaces)
201+ if fb != 0 {
202+ return frameDecision{fb: fb, content: c}
203+ }
204+ return o.composeFrame(s, surfaces)
205+}
206+
207+// setScanoutReason logs a direct-scanout transition once.
208+func (o *Output) setScanoutReason(reason string) {
209+ if reason != o.reason {
210+ o.log.Info().Str("component", "render").Bool("direct_scanout", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("scanout")
211+ o.reason = reason
212+ }
213+}
214+
215+// setOverlayReason logs an overlay transition once.
216+func (o *Output) setOverlayReason(reason string) {
217+ if reason != o.overlayReason {
218+ o.log.Info().Str("component", "render").Bool("overlay", reason == "").Str("reason", reason).Str("connector", o.conn.name).Msg("overlay")
219+ o.overlayReason = reason
220+ }
221+}
222+
102 // clientFB is a client buffer imported as a KMS framebuffer.223 // clientFB is a client buffer imported as a KMS framebuffer.
103 type clientFB struct {224 type clientFB struct {
104 fbID uint32225 fbID uint32
@@ -151,7 +272,7 @@ func (o *Output) planeFB(p *plane, b *ports.DMABuf, now time.Time, reason func(*
151 }272 }
152 id, err := o.k.addFB(b, format)273 id, err := o.k.addFB(b, format)
153 if err != nil {274 if err != nil {
154- o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")275+ o.log.Info().Str("component", "render").Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
155 fb.importErr, *why = true, "import_failed"276 fb.importErr, *why = true, "import_failed"
156 return 0, *why277 return 0, *why
157 }278 }
M internal/adapters/drm/scanout_test.go +58 -0
@@ -279,3 +279,61 @@ func TestShownByScene(t *testing.T) {
279279 t.Fatalf("shows %v", got)
280280 }
281281 }
282+
283+// The decision is callable without Run and keeps the zero-allocation
284+// composition and cached direct-scanout paths cheap.
285+func TestFrameDecisionAllocations(t *testing.T) {
286+ o, _, _ := testOutput(t)
287+ o.scanout = true
288+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
289+ c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 17, Format: fourccXRGB}}
290+ surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
291+ o.clientFBs[17] = &clientFB{fbID: 77}
292+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
293+ for _, tc := range []struct {
294+ name string
295+ capture bool
296+ wantFB uint32
297+ wantReason string
298+ }{
299+ {"direct", false, 77, ""}, {"capture", true, 0, "capture"},
300+ } {
301+ t.Run(tc.name, func(t *testing.T) {
302+ d := o.decideFrame(s, surfaces, tc.capture)
303+ if d.fb != tc.wantFB || o.reason != tc.wantReason {
304+ t.Fatalf("decision: fb=%d reason=%q", d.fb, o.reason)
305+ }
306+ if tc.capture && o.overlayReason != "capture" {
307+ t.Fatalf("capture overlay reason %q", o.overlayReason)
308+ }
309+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, tc.capture) }); n != 0 {
310+ t.Fatalf("decision: %.1f allocs", n)
311+ }
312+ })
313+ }
314+ o.primary.formats = nil
315+ d := o.decideFrame(s, surfaces, false)
316+ if d.fb != 0 || o.overlayReason != "no_plane" || len(d.composed.Windows) != 1 {
317+ t.Fatalf("composition: %+v", d)
318+ }
319+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
320+ t.Fatalf("composition decision: %.1f allocs", n)
321+ }
322+}
323+
324+func TestFrameDecisionOverlayFallback(t *testing.T) {
325+ o, k, _ := overlayOutput(t)
326+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
327+ s, surfaces := overlayScene()
328+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
329+ d := o.decideFrame(s, surfaces, false)
330+ if d.fb != 0 || d.overlay.fb != 88 || o.reason != "other_windows" || len(d.composed.Windows) != 1 {
331+ t.Fatalf("overlay decision: %+v", d)
332+ }
333+ d.overlay.close()
334+ o.clientFBs[9].overlayFailed = "overlay_refused"
335+ d = o.decideFrame(s, surfaces, false)
336+ if d.overlay.fb != 0 || o.overlayReason != "overlay_refused" || len(d.composed.Windows) != 2 {
337+ t.Fatalf("fallback decision: %+v", d)
338+ }
339+}
@@ -279,3 +279,61 @@ func TestShownByScene(t *testing.T) {
279 t.Fatalf("shows %v", got)279 t.Fatalf("shows %v", got)
280 }280 }
281 }281 }
282+
283+// The decision is callable without Run and keeps the zero-allocation
284+// composition and cached direct-scanout paths cheap.
285+func TestFrameDecisionAllocations(t *testing.T) {
286+ o, _, _ := testOutput(t)
287+ o.scanout = true
288+ s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
289+ c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 17, Format: fourccXRGB}}
290+ surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
291+ o.clientFBs[17] = &clientFB{fbID: 77}
292+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
293+ for _, tc := range []struct {
294+ name string
295+ capture bool
296+ wantFB uint32
297+ wantReason string
298+ }{
299+ {"direct", false, 77, ""}, {"capture", true, 0, "capture"},
300+ } {
301+ t.Run(tc.name, func(t *testing.T) {
302+ d := o.decideFrame(s, surfaces, tc.capture)
303+ if d.fb != tc.wantFB || o.reason != tc.wantReason {
304+ t.Fatalf("decision: fb=%d reason=%q", d.fb, o.reason)
305+ }
306+ if tc.capture && o.overlayReason != "capture" {
307+ t.Fatalf("capture overlay reason %q", o.overlayReason)
308+ }
309+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, tc.capture) }); n != 0 {
310+ t.Fatalf("decision: %.1f allocs", n)
311+ }
312+ })
313+ }
314+ o.primary.formats = nil
315+ d := o.decideFrame(s, surfaces, false)
316+ if d.fb != 0 || o.overlayReason != "no_plane" || len(d.composed.Windows) != 1 {
317+ t.Fatalf("composition: %+v", d)
318+ }
319+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
320+ t.Fatalf("composition decision: %.1f allocs", n)
321+ }
322+}
323+
324+func TestFrameDecisionOverlayFallback(t *testing.T) {
325+ o, k, _ := overlayOutput(t)
326+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
327+ s, surfaces := overlayScene()
328+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
329+ d := o.decideFrame(s, surfaces, false)
330+ if d.fb != 0 || d.overlay.fb != 88 || o.reason != "other_windows" || len(d.composed.Windows) != 1 {
331+ t.Fatalf("overlay decision: %+v", d)
332+ }
333+ d.overlay.close()
334+ o.clientFBs[9].overlayFailed = "overlay_refused"
335+ d = o.decideFrame(s, surfaces, false)
336+ if d.overlay.fb != 0 || o.overlayReason != "overlay_refused" || len(d.composed.Windows) != 2 {
337+ t.Fatalf("fallback decision: %+v", d)
338+ }
339+}
M internal/adapters/drm/stuck_test.go +3 -3
@@ -43,7 +43,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
4343 t.Helper()
4444 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
4545 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
46- o.stuckAfter = 200 * time.Millisecond
46+ o.frame.stuckAfter = 200 * time.Millisecond
4747 o.flipped = make(chan flipEvent) // no event ever
4848 r := portsmocks.NewMockRenderer(t)
4949 buf := func() ports.DMABuf {
@@ -214,14 +214,14 @@ func TestSeenWaitsForRefusedFrameFence(t *testing.T) {
214214 // retried, not taken as the same run lasting past the limit.
215215 func TestLaterBusyStartsNewRun(t *testing.T) {
216216 o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY)
217- o.stuckAfter = 100 * time.Millisecond
217+ o.frame.stuckAfter = 100 * time.Millisecond
218218 enabled := true
219219 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
220220 time.Sleep(busyRetry)
221221 if o.expire() {
222222 t.Fatal("first EBUSY retry modesets")
223223 }
224- time.Sleep(2 * o.stuckAfter)
224+ time.Sleep(2 * o.frame.stuckAfter)
225225 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
226226 time.Sleep(busyRetry)
227227 if o.expire() {
@@ -43,7 +43,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
43 t.Helper()43 t.Helper()
44 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)44 o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...)
45 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}45 o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
46- o.stuckAfter = 200 * time.Millisecond46+ o.frame.stuckAfter = 200 * time.Millisecond
47 o.flipped = make(chan flipEvent) // no event ever47 o.flipped = make(chan flipEvent) // no event ever
48 r := portsmocks.NewMockRenderer(t)48 r := portsmocks.NewMockRenderer(t)
49 buf := func() ports.DMABuf {49 buf := func() ports.DMABuf {
@@ -214,14 +214,14 @@ func TestSeenWaitsForRefusedFrameFence(t *testing.T) {
214 // retried, not taken as the same run lasting past the limit.214 // retried, not taken as the same run lasting past the limit.
215 func TestLaterBusyStartsNewRun(t *testing.T) {215 func TestLaterBusyStartsNewRun(t *testing.T) {
216 o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY)216 o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY)
217- o.stuckAfter = 100 * time.Millisecond217+ o.frame.stuckAfter = 100 * time.Millisecond
218 enabled := true218 enabled := true
219 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)219 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
220 time.Sleep(busyRetry)220 time.Sleep(busyRetry)
221 if o.expire() {221 if o.expire() {
222 t.Fatal("first EBUSY retry modesets")222 t.Fatal("first EBUSY retry modesets")
223 }223 }
224- time.Sleep(2 * o.stuckAfter)224+ time.Sleep(2 * o.frame.stuckAfter)
225 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)225 o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled)
226 time.Sleep(busyRetry)226 time.Sleep(busyRetry)
227 if o.expire() {227 if o.expire() {
A internal/adapters/wayland/commit_hotpath_bench_test.go +133 -0
@@ -0,0 +1,133 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/logging"
10+ "github.com/bnema/purego-libwayland/protocol/fifo"
11+ "github.com/bnema/purego-libwayland/protocol/viewporter"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// BenchmarkProtocolQueuedCommit measures a real client's requests and server
18+// dispatch, queue capture, and paced application. Socket roundtrips synchronize
19+// each frame; this is end-to-end latency, not an allocation-free apply microbench.
20+func BenchmarkProtocolQueuedCommit(b *testing.B) {
21+ dir := b.TempDir()
22+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
23+ if err != nil {
24+ b.Fatal(err)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ done := make(chan error, 1)
28+ go func() { done <- s.Run(ctx) }()
29+ defer func() { cancel(); <-done }()
30+ c, err := wlturbo.Connect(filepath.Join(dir, s.SocketName()))
31+ if err != nil {
32+ b.Fatal(err)
33+ }
34+ defer c.Close()
35+ if err := c.Roundtrip(); err != nil {
36+ b.Fatal(err)
37+ }
38+ bind := func(name string) uint32 {
39+ g, ok := c.Registry().FindGlobal(name)
40+ if !ok {
41+ b.Fatalf("missing %s", name)
42+ }
43+ id, err := c.Registry().BindID(g.Name, g.Interface, 1)
44+ if err != nil {
45+ b.Fatal(err)
46+ }
47+ return id
48+ }
49+ send := func(id, op uint32, args ...any) {
50+ if err := c.SendRequest(id, uint16(op), args...); err != nil {
51+ b.Fatal(err)
52+ }
53+ }
54+ comp, fm, vpm := bind("wl_compositor"), bind("wp_fifo_manager_v1"), bind("wp_viewporter")
55+ shm := bind("wl_shm")
56+ register := func(id uint32) {
57+ p := &protocolProxy{}
58+ p.SetID(id)
59+ c.Context().Register(p)
60+ }
61+ register(shm)
62+ surf := c.AllocateID()
63+ send(comp, wayland.CompositorRequestCreateSurface, surf)
64+ register(surf)
65+ f := c.AllocateID()
66+ send(fm, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
67+ register(f)
68+ vp := c.AllocateID()
69+ send(vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
70+ register(vp)
71+ // One real SHM buffer, reused for every commit.
72+ fd, err := unix.MemfdCreate("bench-buffer", 0)
73+ if err != nil {
74+ b.Fatal(err)
75+ }
76+ defer unix.Close(fd)
77+ if err := unix.Ftruncate(fd, 4); err != nil {
78+ b.Fatal(err)
79+ }
80+ pool, buf := c.AllocateID(), c.AllocateID()
81+ register(pool)
82+ register(buf)
83+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
84+ b.Fatal(err)
85+ }
86+ send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
87+ if err := c.Roundtrip(); err != nil {
88+ b.Fatal(err)
89+ }
90+ // Install a barrier so each measured commit really waits in the queue.
91+ send(f, fifo.WpFifoV1RequestSetBarrier)
92+ send(surf, wayland.SurfaceRequestCommit)
93+ if err := c.Roundtrip(); err != nil {
94+ b.Fatal(err)
95+ }
96+ b.ReportAllocs()
97+ b.ResetTimer()
98+ for range b.N {
99+ send(f, fifo.WpFifoV1RequestWaitBarrier)
100+ send(surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
101+ send(surf, wayland.SurfaceRequestDamage, int32(0), int32(0), int32(1), int32(1))
102+ send(vp, viewporter.WpViewportRequestSetDestination, int32(1), int32(1))
103+ send(surf, wayland.SurfaceRequestCommit)
104+ // Later requests must not be consumed by the captured update.
105+ send(vp, viewporter.WpViewportRequestSetDestination, int32(2), int32(2))
106+ if err := c.Roundtrip(); err != nil {
107+ b.Fatal(err)
108+ }
109+ s.display.Do(func() {
110+ for _, state := range s.surfaces {
111+ if state.wl != nil && state.wl.ID() == surf {
112+ if len(state.queue) != 1 {
113+ b.Errorf("queued commits: %d", len(state.queue))
114+ return
115+ }
116+ state.barrier = false
117+ s.tickFifo(time.Now(), nil)
118+ if len(state.queue) != 0 {
119+ b.Error("commit still queued")
120+ }
121+ return
122+ }
123+ }
124+ b.Error("missing surface")
125+ })
126+ // Next iteration overwrites the pending viewport destination.
127+ send(f, fifo.WpFifoV1RequestSetBarrier)
128+ send(surf, wayland.SurfaceRequestCommit)
129+ if err := c.Roundtrip(); err != nil {
130+ b.Fatal(err)
131+ }
132+ }
133+}
@@ -0,0 +1,133 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/logging"
10+ "github.com/bnema/purego-libwayland/protocol/fifo"
11+ "github.com/bnema/purego-libwayland/protocol/viewporter"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// BenchmarkProtocolQueuedCommit measures a real client's requests and server
18+// dispatch, queue capture, and paced application. Socket roundtrips synchronize
19+// each frame; this is end-to-end latency, not an allocation-free apply microbench.
20+func BenchmarkProtocolQueuedCommit(b *testing.B) {
21+ dir := b.TempDir()
22+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
23+ if err != nil {
24+ b.Fatal(err)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ done := make(chan error, 1)
28+ go func() { done <- s.Run(ctx) }()
29+ defer func() { cancel(); <-done }()
30+ c, err := wlturbo.Connect(filepath.Join(dir, s.SocketName()))
31+ if err != nil {
32+ b.Fatal(err)
33+ }
34+ defer c.Close()
35+ if err := c.Roundtrip(); err != nil {
36+ b.Fatal(err)
37+ }
38+ bind := func(name string) uint32 {
39+ g, ok := c.Registry().FindGlobal(name)
40+ if !ok {
41+ b.Fatalf("missing %s", name)
42+ }
43+ id, err := c.Registry().BindID(g.Name, g.Interface, 1)
44+ if err != nil {
45+ b.Fatal(err)
46+ }
47+ return id
48+ }
49+ send := func(id, op uint32, args ...any) {
50+ if err := c.SendRequest(id, uint16(op), args...); err != nil {
51+ b.Fatal(err)
52+ }
53+ }
54+ comp, fm, vpm := bind("wl_compositor"), bind("wp_fifo_manager_v1"), bind("wp_viewporter")
55+ shm := bind("wl_shm")
56+ register := func(id uint32) {
57+ p := &protocolProxy{}
58+ p.SetID(id)
59+ c.Context().Register(p)
60+ }
61+ register(shm)
62+ surf := c.AllocateID()
63+ send(comp, wayland.CompositorRequestCreateSurface, surf)
64+ register(surf)
65+ f := c.AllocateID()
66+ send(fm, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
67+ register(f)
68+ vp := c.AllocateID()
69+ send(vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
70+ register(vp)
71+ // One real SHM buffer, reused for every commit.
72+ fd, err := unix.MemfdCreate("bench-buffer", 0)
73+ if err != nil {
74+ b.Fatal(err)
75+ }
76+ defer unix.Close(fd)
77+ if err := unix.Ftruncate(fd, 4); err != nil {
78+ b.Fatal(err)
79+ }
80+ pool, buf := c.AllocateID(), c.AllocateID()
81+ register(pool)
82+ register(buf)
83+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
84+ b.Fatal(err)
85+ }
86+ send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
87+ if err := c.Roundtrip(); err != nil {
88+ b.Fatal(err)
89+ }
90+ // Install a barrier so each measured commit really waits in the queue.
91+ send(f, fifo.WpFifoV1RequestSetBarrier)
92+ send(surf, wayland.SurfaceRequestCommit)
93+ if err := c.Roundtrip(); err != nil {
94+ b.Fatal(err)
95+ }
96+ b.ReportAllocs()
97+ b.ResetTimer()
98+ for range b.N {
99+ send(f, fifo.WpFifoV1RequestWaitBarrier)
100+ send(surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
101+ send(surf, wayland.SurfaceRequestDamage, int32(0), int32(0), int32(1), int32(1))
102+ send(vp, viewporter.WpViewportRequestSetDestination, int32(1), int32(1))
103+ send(surf, wayland.SurfaceRequestCommit)
104+ // Later requests must not be consumed by the captured update.
105+ send(vp, viewporter.WpViewportRequestSetDestination, int32(2), int32(2))
106+ if err := c.Roundtrip(); err != nil {
107+ b.Fatal(err)
108+ }
109+ s.display.Do(func() {
110+ for _, state := range s.surfaces {
111+ if state.wl != nil && state.wl.ID() == surf {
112+ if len(state.queue) != 1 {
113+ b.Errorf("queued commits: %d", len(state.queue))
114+ return
115+ }
116+ state.barrier = false
117+ s.tickFifo(time.Now(), nil)
118+ if len(state.queue) != 0 {
119+ b.Error("commit still queued")
120+ }
121+ return
122+ }
123+ }
124+ b.Error("missing surface")
125+ })
126+ // Next iteration overwrites the pending viewport destination.
127+ send(f, fifo.WpFifoV1RequestSetBarrier)
128+ send(surf, wayland.SurfaceRequestCommit)
129+ if err := c.Roundtrip(); err != nil {
130+ b.Fatal(err)
131+ }
132+ }
133+}
A internal/adapters/wayland/commit_lifecycle_test.go +392 -0
@@ -0,0 +1,392 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/fifo"
9+ "github.com/bnema/purego-libwayland/protocol/viewporter"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
12+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
13+ "github.com/bnema/purego-libwayland/server"
14+)
15+
16+// Applying an older captured commit must never transiently own newer requests.
17+// The next commit captures those requests without losing its one-shot state.
18+func TestCapturedCommitKeepsLaterRequests(t *testing.T) {
19+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}}
20+ surf := &surface{server: srv, xdg: &xdgSurface{}}
21+ cons := &constraint{surface: surf}
22+ srv.constraints[surf] = cons
23+ firstRegion := &ports.Rect{W: 2}
24+ cons.pending = firstRegion
25+ surf.pendingScale, surf.pendingAsync, surf.pendingKind = 2, true, 1
26+ surf.xdg.pendingGeometry = ports.Rect{W: 10}
27+ surf.pendingInputSet, surf.pendingInputAll = true, true
28+ surf.pendingDamage = []ports.Rect{{W: 2}}
29+ surf.pendingBarrier = true
30+ surf.queueUpdate()
31+
32+ laterRegion := &ports.Rect{W: 3}
33+ cons.pending = laterRegion
34+ surf.pendingScale, surf.pendingAsync, surf.pendingKind = 3, false, 2
35+ surf.sub.pendingLayout = []childLayout{{x: 7}}
36+ surf.xdg.pendingGeometry = ports.Rect{W: 20}
37+ surf.pendingInputSet, surf.pendingInputAll = true, false
38+ surf.pendingInputRects = []ports.Rect{{W: 3}}
39+ surf.pendingDamage = []ports.Rect{{W: 3}}
40+ surf.pendingBarrier = true
41+ laterTime := time.Now().Add(time.Second)
42+ surf.pendingTime = laterTime
43+
44+ surf.queue[0].applyGraph()
45+ if surf.bufferScale != 2 || !surf.async || surf.contentKind != 1 || len(surf.sub.layout) != 0 || surf.xdg.geometry.W != 10 || cons.region.W != 2 || !surf.inputAll || !surf.barrier {
46+ t.Fatalf("captured state changed: scale=%d async=%v kind=%d geometry=%v region=%v input=%v barrier=%v", surf.bufferScale, surf.async, surf.contentKind, surf.xdg.geometry, cons.region, surf.inputAll, surf.barrier)
47+ }
48+ if surf.pendingScale != 3 || surf.pendingAsync || surf.pendingKind != 2 || len(surf.sub.pendingLayout) != 1 || surf.xdg.pendingGeometry.W != 20 || cons.pending != laterRegion || !surf.pendingInputSet || surf.pendingInputAll || len(surf.pendingInputRects) != 1 || len(surf.pendingDamage) != 1 || !surf.pendingBarrier || !surf.pendingTime.Equal(laterTime) {
49+ t.Fatal("application consumed a later request")
50+ }
51+ surf.queueUpdate()
52+ second := surf.queue[0]
53+ if second.scale != 3 || second.async || second.kind != 2 || len(second.layout) != 1 || second.geometry.W != 20 || second.region != laterRegion || !second.inputSet || second.inputAll || len(second.inputRects) != 1 || len(second.damage) != 1 || !second.barrier || !second.at.Equal(laterTime) {
54+ t.Fatal("subsequent commit did not capture later requests")
55+ }
56+ surf.dropQueue()
57+}
58+
59+// The viewport resource is live, but its requests after capture must not be
60+// installed by an older queued commit.
61+func TestCapturedViewportKeepsLaterRequests(t *testing.T) {
62+ s, _, _, _, dir := contentServer(t)
63+ c := protocolClient(t, s, dir)
64+ comp := bindProtocol(t, c, "wl_compositor")
65+ vpm := bindProtocol(t, c, "wp_viewporter")
66+ surfID := c.AllocateID()
67+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
68+ registerProtocol(t, c, surfID)
69+ vpID := c.AllocateID()
70+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vpID, surfID)
71+ registerProtocol(t, c, vpID)
72+ if err := c.Roundtrip(); err != nil {
73+ t.Fatal(err)
74+ }
75+ s.display.Do(func() {
76+ var surf *surface
77+ for _, candidate := range s.surfaces {
78+ if candidate.wl != nil && candidate.wl.ID() == surfID {
79+ surf = candidate
80+ }
81+ }
82+ if surf == nil || surf.viewport == nil {
83+ t.Error("missing viewport")
84+ return
85+ }
86+ v := surf.viewport
87+ v.SetSource(v.resource, 0, 0, server.Fixed(256), server.Fixed(256))
88+ v.SetDestination(v.resource, 1, 1)
89+ surf.queueUpdate()
90+ v.SetSource(v.resource, 0, 0, server.Fixed(512), server.Fixed(512))
91+ v.SetDestination(v.resource, 2, 2)
92+ surf.queue[0].applyGraph()
93+ if surf.committedViewport.destW != 1 || surf.committedViewport.src[2] != 256 || v.pendingW != 2 || v.pendingSrc[2] != 512 {
94+ t.Errorf("captured=%+v pending=%+v", surf.committedViewport, v)
95+ }
96+ surf.queueUpdate()
97+ surf.queue[0].applyGraph()
98+ if surf.committedViewport.destW != 2 || surf.committedViewport.src[2] != 512 {
99+ t.Errorf("next commit: %+v", surf.committedViewport)
100+ }
101+ })
102+}
103+
104+// Real protocol requests captured behind a barrier keep the first buffer;
105+// an attach made after capture is owned by the next commit.
106+func TestQueuedAttachKeepsLaterBuffer(t *testing.T) {
107+ s, _, _, _, dir := contentServer(t)
108+ c := protocolClient(t, s, dir)
109+ comp := bindProtocol(t, c, "wl_compositor")
110+ fm := bindProtocol(t, c, "wp_fifo_manager_v1")
111+ id := c.AllocateID()
112+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, id)
113+ registerProtocol(t, c, id)
114+ f := c.AllocateID()
115+ requestProtocol(t, c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, id)
116+ registerProtocol(t, c, f)
117+ first, second := shmBuffer(t, c), shmBuffer(t, c)
118+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
119+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
120+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
121+ requestProtocol(t, c, id, wayland.SurfaceRequestAttach, first, int32(0), int32(0))
122+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
123+ requestProtocol(t, c, id, wayland.SurfaceRequestAttach, second, int32(0), int32(0))
124+ if err := c.Roundtrip(); err != nil {
125+ t.Fatal(err)
126+ }
127+ s.display.Do(func() {
128+ var surf *surface
129+ for _, candidate := range s.surfaces {
130+ if candidate.wl != nil && candidate.wl.ID() == id {
131+ surf = candidate
132+ }
133+ }
134+ if surf == nil || len(surf.queue) != 1 || surf.queue[0].buffer.ID() != first || surf.pending.ID() != second {
135+ t.Error("queued attach lost its captured or later buffer")
136+ return
137+ }
138+ surf.barrier = false
139+ s.tickFifo(time.Now(), nil)
140+ if surf.current == nil || surf.current.ID() != first || surf.pending == nil || surf.pending.ID() != second {
141+ t.Error("applying captured attach consumed later request")
142+ }
143+ })
144+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
145+ if err := c.Roundtrip(); err != nil {
146+ t.Fatal(err)
147+ }
148+ s.display.Do(func() {
149+ for _, surf := range s.surfaces {
150+ if surf.wl != nil && surf.wl.ID() == id && (surf.current == nil || surf.current.ID() != second) {
151+ t.Error("later attach not applied by next commit")
152+ }
153+ }
154+ })
155+}
156+
157+// Layer requests are sticky, but must not be read from live pending state
158+// when an older queued update is finally applied.
159+func TestCapturedLayerKeepsLaterRequests(t *testing.T) {
160+ s, _, _, _, dir := contentServer(t)
161+ c := protocolClient(t, s, dir)
162+ comp := bindProtocol(t, c, "wl_compositor")
163+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
164+ id := c.AllocateID()
165+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, id)
166+ registerProtocol(t, c, id)
167+ layerID := c.AllocateID()
168+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layerID, id, uint32(0), uint32(ports.LayerTop), "captured")
169+ registerProtocol(t, c, layerID)
170+ if err := c.Roundtrip(); err != nil {
171+ t.Fatal(err)
172+ }
173+ s.display.Do(func() {
174+ var surf *surface
175+ for _, candidate := range s.surfaces {
176+ if candidate.wl != nil && candidate.wl.ID() == id {
177+ surf = candidate
178+ }
179+ }
180+ if surf == nil || surf.layer == nil {
181+ t.Error("missing layer")
182+ return
183+ }
184+ l := surf.layer
185+ l.SetSize(l.resource, 1, 1)
186+ l.SetAnchor(l.resource, 1)
187+ l.SetMargin(l.resource, 1, 2, 3, 4)
188+ l.SetExclusiveZone(l.resource, 5)
189+ surf.queueUpdate()
190+ l.SetSize(l.resource, 2, 2)
191+ l.SetAnchor(l.resource, 2)
192+ l.SetMargin(l.resource, 5, 6, 7, 8)
193+ l.SetExclusiveZone(l.resource, 9)
194+ surf.queue[0].applyGraph()
195+ if l.current.width != 1 || l.current.anchor != 1 || l.current.margin != [4]int32{1, 2, 3, 4} || l.current.zone != 5 || l.pending.width != 2 || l.pending.anchor != 2 || l.pending.margin != [4]int32{5, 6, 7, 8} || l.pending.zone != 9 {
196+ t.Errorf("captured=%+v pending=%+v", l.current, l.pending)
197+ }
198+ surf.queueUpdate()
199+ surf.queue[0].applyGraph()
200+ if l.current.width != 2 || l.current.anchor != 2 || l.current.zone != 9 {
201+ t.Errorf("next commit: %+v", l.current)
202+ }
203+ })
204+}
205+
206+// Role and surface destruction drop their queues. Viewport and buffer
207+// destruction leave the queued update to apply safely at the next tick.
208+// Roundtrip orders destruction before examining the display owner.
209+func TestQueuedDestructionDropsUpdates(t *testing.T) {
210+ for _, kind := range []string{"viewport", "toplevel", "layer", "surface", "buffer"} {
211+ t.Run(kind, func(t *testing.T) {
212+ s, events, _, _, dir := contentServer(t)
213+ c := protocolClient(t, s, dir)
214+ var surfID, objectID, bufferID uint32
215+ if kind == "toplevel" {
216+ _, surfID, _ = surfaceMapper(t, c, events)()
217+ s.display.Do(func() {
218+ for _, surf := range s.surfaces {
219+ if surf.wl != nil && surf.wl.ID() == surfID {
220+ objectID = surf.xdg.window.toplevel.ID()
221+ }
222+ }
223+ })
224+ } else {
225+ comp := bindProtocol(t, c, "wl_compositor")
226+ surfID = c.AllocateID()
227+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
228+ registerProtocol(t, c, surfID)
229+ if kind == "viewport" {
230+ vpm := bindProtocol(t, c, "wp_viewporter")
231+ objectID = c.AllocateID()
232+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, objectID, surfID)
233+ registerProtocol(t, c, objectID)
234+ } else if kind == "layer" {
235+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
236+ objectID = c.AllocateID()
237+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, objectID, surfID, uint32(0), uint32(ports.LayerTop), "drop")
238+ registerProtocol(t, c, objectID)
239+ requestProtocol(t, c, objectID, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
240+ } else if kind == "buffer" {
241+ bufferID = shmBuffer(t, c)
242+ }
243+ }
244+ if err := c.Roundtrip(); err != nil {
245+ t.Fatal(err)
246+ }
247+ s.display.Do(func() {
248+ for _, surf := range s.surfaces {
249+ if surf.wl != nil && surf.wl.ID() == surfID {
250+ surf.pendingScale = 3
251+ surf.pendingDamage = []ports.Rect{{W: 3}}
252+ if kind == "viewport" {
253+ surf.viewport.SetDestination(surf.viewport.resource, 2, 2)
254+ }
255+ surf.pendingTime = time.Now().Add(time.Second)
256+ if kind == "buffer" {
257+ for resource := range s.buffers {
258+ if resource.ID() == bufferID {
259+ surf.pending = wayland.WrapBuffer(resource)
260+ surf.attached = true
261+ }
262+ }
263+ }
264+ surf.queueUpdate()
265+ }
266+ }
267+ })
268+ switch kind {
269+ case "viewport":
270+ requestProtocol(t, c, objectID, viewporter.WpViewportRequestDestroy)
271+ case "toplevel":
272+ requestProtocol(t, c, objectID, xdgshell.ToplevelRequestDestroy)
273+ case "layer":
274+ requestProtocol(t, c, objectID, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
275+ case "surface":
276+ requestProtocol(t, c, surfID, wayland.SurfaceRequestDestroy)
277+ case "buffer":
278+ requestProtocol(t, c, bufferID, wayland.BufferRequestDestroy)
279+ }
280+ if err := c.Roundtrip(); err != nil {
281+ t.Fatal(err)
282+ }
283+ s.display.Do(func() {
284+ for _, surf := range s.surfaces {
285+ if surf.wl != nil && surf.wl.ID() == surfID {
286+ if kind == "viewport" || kind == "buffer" {
287+ if kind == "buffer" && (len(surf.queue) != 1 || surf.queue[0].buffer == nil || surf.queue[0].buffer.Resource.Alive()) {
288+ t.Error("expected destroyed buffer in queued update")
289+ }
290+ if len(surf.queue) != 1 {
291+ t.Errorf("%s should retain queued update until tick", kind)
292+ }
293+ s.tickFifo(time.Now().Add(2*time.Second), nil)
294+ }
295+ if len(surf.queue) != 0 {
296+ t.Errorf("%s retained queued update", kind)
297+ }
298+ }
299+ }
300+ fresh := &surface{server: s}
301+ fresh.queueUpdate()
302+ u := fresh.queue[0]
303+ if u.scale != 0 || u.buffer != nil || u.vp != nil || u.layer != nil || len(u.damage) != 0 || u.owner != fresh {
304+ t.Errorf("%s recycled stale update: %+v", kind, u)
305+ }
306+ fresh.queue[0].applyGraph()
307+ })
308+ })
309+ }
310+}
311+
312+// A dropped update must be cleared before a different surface reuses it.
313+func TestDroppedUpdateRecycledWithoutState(t *testing.T) {
314+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
315+ old := &surface{server: srv, pendingScale: 2, pendingBarrier: true, pendingDamage: []ports.Rect{{W: 2}}}
316+ old.queueUpdate()
317+ old.destroyed = true
318+ old.dropQueue()
319+ fresh := &surface{server: srv}
320+ fresh.queueUpdate()
321+ if u := fresh.queue[0]; u.scale != 0 || u.barrier || len(u.damage) != 0 || u.owner != fresh || u.buffer != nil || u.vp != nil || u.layer != nil || u.xdg != nil {
322+ t.Fatalf("recycled update retained old state: %+v", u)
323+ }
324+ fresh.queue[0].applyGraph()
325+}
326+
327+// BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's
328+// in-process queue/apply loop. It excludes protocol dispatch and real buffers.
329+func BenchmarkEmptySurfaceQueueApply(b *testing.B) {
330+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
331+ surf := &surface{server: srv}
332+ surf.queueUpdate()
333+ surf.queue[0].applyGraph()
334+ b.ReportAllocs()
335+ b.ResetTimer()
336+ for range b.N {
337+ surf.queueUpdate()
338+ surf.queue[0].applyGraph()
339+ }
340+}
341+
342+// TestCapturedViewportApplyAllocations exercises the real viewport resource
343+// on the display owner: a viewport-only commit has no heap work.
344+func TestCapturedViewportApplyAllocations(t *testing.T) {
345+ s, _, _, _, dir := contentServer(t)
346+ c := protocolClient(t, s, dir)
347+ comp := bindProtocol(t, c, "wl_compositor")
348+ vpm := bindProtocol(t, c, "wp_viewporter")
349+ surfID := c.AllocateID()
350+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
351+ registerProtocol(t, c, surfID)
352+ vpID := c.AllocateID()
353+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vpID, surfID)
354+ registerProtocol(t, c, vpID)
355+ if err := c.Roundtrip(); err != nil {
356+ t.Fatal(err)
357+ }
358+ s.display.Do(func() {
359+ var surf *surface
360+ for _, candidate := range s.surfaces {
361+ if candidate.wl != nil && candidate.wl.ID() == surfID {
362+ surf = candidate
363+ break
364+ }
365+ }
366+ if surf == nil || surf.viewport == nil || surf.viewport.resource == nil {
367+ t.Error("missing viewport")
368+ return
369+ }
370+ apply := func() {
371+ surf.queueUpdate()
372+ surf.queue[0].applyGraph()
373+ }
374+ apply()
375+ if allocs := testing.AllocsPerRun(100, apply); allocs != 0 {
376+ t.Errorf("viewport queue/apply: %.1f allocs/commit; want 0", allocs)
377+ }
378+ })
379+}
380+
381+func TestCapturedCommitApplyAllocations(t *testing.T) {
382+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
383+ surf := &surface{server: srv}
384+ apply := func() {
385+ surf.queueUpdate()
386+ surf.queue[0].applyGraph()
387+ }
388+ apply() // warm pool and queue backing storage
389+ if allocs := testing.AllocsPerRun(100, apply); allocs != 0 {
390+ t.Errorf("steady-state queue/apply: %.1f allocs/commit; want 0", allocs)
391+ }
392+}
@@ -0,0 +1,392 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/fifo"
9+ "github.com/bnema/purego-libwayland/protocol/viewporter"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
12+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
13+ "github.com/bnema/purego-libwayland/server"
14+)
15+
16+// Applying an older captured commit must never transiently own newer requests.
17+// The next commit captures those requests without losing its one-shot state.
18+func TestCapturedCommitKeepsLaterRequests(t *testing.T) {
19+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1), constraints: map[*surface]*constraint{}}
20+ surf := &surface{server: srv, xdg: &xdgSurface{}}
21+ cons := &constraint{surface: surf}
22+ srv.constraints[surf] = cons
23+ firstRegion := &ports.Rect{W: 2}
24+ cons.pending = firstRegion
25+ surf.pendingScale, surf.pendingAsync, surf.pendingKind = 2, true, 1
26+ surf.xdg.pendingGeometry = ports.Rect{W: 10}
27+ surf.pendingInputSet, surf.pendingInputAll = true, true
28+ surf.pendingDamage = []ports.Rect{{W: 2}}
29+ surf.pendingBarrier = true
30+ surf.queueUpdate()
31+
32+ laterRegion := &ports.Rect{W: 3}
33+ cons.pending = laterRegion
34+ surf.pendingScale, surf.pendingAsync, surf.pendingKind = 3, false, 2
35+ surf.sub.pendingLayout = []childLayout{{x: 7}}
36+ surf.xdg.pendingGeometry = ports.Rect{W: 20}
37+ surf.pendingInputSet, surf.pendingInputAll = true, false
38+ surf.pendingInputRects = []ports.Rect{{W: 3}}
39+ surf.pendingDamage = []ports.Rect{{W: 3}}
40+ surf.pendingBarrier = true
41+ laterTime := time.Now().Add(time.Second)
42+ surf.pendingTime = laterTime
43+
44+ surf.queue[0].applyGraph()
45+ if surf.bufferScale != 2 || !surf.async || surf.contentKind != 1 || len(surf.sub.layout) != 0 || surf.xdg.geometry.W != 10 || cons.region.W != 2 || !surf.inputAll || !surf.barrier {
46+ t.Fatalf("captured state changed: scale=%d async=%v kind=%d geometry=%v region=%v input=%v barrier=%v", surf.bufferScale, surf.async, surf.contentKind, surf.xdg.geometry, cons.region, surf.inputAll, surf.barrier)
47+ }
48+ if surf.pendingScale != 3 || surf.pendingAsync || surf.pendingKind != 2 || len(surf.sub.pendingLayout) != 1 || surf.xdg.pendingGeometry.W != 20 || cons.pending != laterRegion || !surf.pendingInputSet || surf.pendingInputAll || len(surf.pendingInputRects) != 1 || len(surf.pendingDamage) != 1 || !surf.pendingBarrier || !surf.pendingTime.Equal(laterTime) {
49+ t.Fatal("application consumed a later request")
50+ }
51+ surf.queueUpdate()
52+ second := surf.queue[0]
53+ if second.scale != 3 || second.async || second.kind != 2 || len(second.layout) != 1 || second.geometry.W != 20 || second.region != laterRegion || !second.inputSet || second.inputAll || len(second.inputRects) != 1 || len(second.damage) != 1 || !second.barrier || !second.at.Equal(laterTime) {
54+ t.Fatal("subsequent commit did not capture later requests")
55+ }
56+ surf.dropQueue()
57+}
58+
59+// The viewport resource is live, but its requests after capture must not be
60+// installed by an older queued commit.
61+func TestCapturedViewportKeepsLaterRequests(t *testing.T) {
62+ s, _, _, _, dir := contentServer(t)
63+ c := protocolClient(t, s, dir)
64+ comp := bindProtocol(t, c, "wl_compositor")
65+ vpm := bindProtocol(t, c, "wp_viewporter")
66+ surfID := c.AllocateID()
67+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
68+ registerProtocol(t, c, surfID)
69+ vpID := c.AllocateID()
70+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vpID, surfID)
71+ registerProtocol(t, c, vpID)
72+ if err := c.Roundtrip(); err != nil {
73+ t.Fatal(err)
74+ }
75+ s.display.Do(func() {
76+ var surf *surface
77+ for _, candidate := range s.surfaces {
78+ if candidate.wl != nil && candidate.wl.ID() == surfID {
79+ surf = candidate
80+ }
81+ }
82+ if surf == nil || surf.viewport == nil {
83+ t.Error("missing viewport")
84+ return
85+ }
86+ v := surf.viewport
87+ v.SetSource(v.resource, 0, 0, server.Fixed(256), server.Fixed(256))
88+ v.SetDestination(v.resource, 1, 1)
89+ surf.queueUpdate()
90+ v.SetSource(v.resource, 0, 0, server.Fixed(512), server.Fixed(512))
91+ v.SetDestination(v.resource, 2, 2)
92+ surf.queue[0].applyGraph()
93+ if surf.committedViewport.destW != 1 || surf.committedViewport.src[2] != 256 || v.pendingW != 2 || v.pendingSrc[2] != 512 {
94+ t.Errorf("captured=%+v pending=%+v", surf.committedViewport, v)
95+ }
96+ surf.queueUpdate()
97+ surf.queue[0].applyGraph()
98+ if surf.committedViewport.destW != 2 || surf.committedViewport.src[2] != 512 {
99+ t.Errorf("next commit: %+v", surf.committedViewport)
100+ }
101+ })
102+}
103+
104+// Real protocol requests captured behind a barrier keep the first buffer;
105+// an attach made after capture is owned by the next commit.
106+func TestQueuedAttachKeepsLaterBuffer(t *testing.T) {
107+ s, _, _, _, dir := contentServer(t)
108+ c := protocolClient(t, s, dir)
109+ comp := bindProtocol(t, c, "wl_compositor")
110+ fm := bindProtocol(t, c, "wp_fifo_manager_v1")
111+ id := c.AllocateID()
112+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, id)
113+ registerProtocol(t, c, id)
114+ f := c.AllocateID()
115+ requestProtocol(t, c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, id)
116+ registerProtocol(t, c, f)
117+ first, second := shmBuffer(t, c), shmBuffer(t, c)
118+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
119+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
120+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
121+ requestProtocol(t, c, id, wayland.SurfaceRequestAttach, first, int32(0), int32(0))
122+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
123+ requestProtocol(t, c, id, wayland.SurfaceRequestAttach, second, int32(0), int32(0))
124+ if err := c.Roundtrip(); err != nil {
125+ t.Fatal(err)
126+ }
127+ s.display.Do(func() {
128+ var surf *surface
129+ for _, candidate := range s.surfaces {
130+ if candidate.wl != nil && candidate.wl.ID() == id {
131+ surf = candidate
132+ }
133+ }
134+ if surf == nil || len(surf.queue) != 1 || surf.queue[0].buffer.ID() != first || surf.pending.ID() != second {
135+ t.Error("queued attach lost its captured or later buffer")
136+ return
137+ }
138+ surf.barrier = false
139+ s.tickFifo(time.Now(), nil)
140+ if surf.current == nil || surf.current.ID() != first || surf.pending == nil || surf.pending.ID() != second {
141+ t.Error("applying captured attach consumed later request")
142+ }
143+ })
144+ requestProtocol(t, c, id, wayland.SurfaceRequestCommit)
145+ if err := c.Roundtrip(); err != nil {
146+ t.Fatal(err)
147+ }
148+ s.display.Do(func() {
149+ for _, surf := range s.surfaces {
150+ if surf.wl != nil && surf.wl.ID() == id && (surf.current == nil || surf.current.ID() != second) {
151+ t.Error("later attach not applied by next commit")
152+ }
153+ }
154+ })
155+}
156+
157+// Layer requests are sticky, but must not be read from live pending state
158+// when an older queued update is finally applied.
159+func TestCapturedLayerKeepsLaterRequests(t *testing.T) {
160+ s, _, _, _, dir := contentServer(t)
161+ c := protocolClient(t, s, dir)
162+ comp := bindProtocol(t, c, "wl_compositor")
163+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
164+ id := c.AllocateID()
165+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, id)
166+ registerProtocol(t, c, id)
167+ layerID := c.AllocateID()
168+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layerID, id, uint32(0), uint32(ports.LayerTop), "captured")
169+ registerProtocol(t, c, layerID)
170+ if err := c.Roundtrip(); err != nil {
171+ t.Fatal(err)
172+ }
173+ s.display.Do(func() {
174+ var surf *surface
175+ for _, candidate := range s.surfaces {
176+ if candidate.wl != nil && candidate.wl.ID() == id {
177+ surf = candidate
178+ }
179+ }
180+ if surf == nil || surf.layer == nil {
181+ t.Error("missing layer")
182+ return
183+ }
184+ l := surf.layer
185+ l.SetSize(l.resource, 1, 1)
186+ l.SetAnchor(l.resource, 1)
187+ l.SetMargin(l.resource, 1, 2, 3, 4)
188+ l.SetExclusiveZone(l.resource, 5)
189+ surf.queueUpdate()
190+ l.SetSize(l.resource, 2, 2)
191+ l.SetAnchor(l.resource, 2)
192+ l.SetMargin(l.resource, 5, 6, 7, 8)
193+ l.SetExclusiveZone(l.resource, 9)
194+ surf.queue[0].applyGraph()
195+ if l.current.width != 1 || l.current.anchor != 1 || l.current.margin != [4]int32{1, 2, 3, 4} || l.current.zone != 5 || l.pending.width != 2 || l.pending.anchor != 2 || l.pending.margin != [4]int32{5, 6, 7, 8} || l.pending.zone != 9 {
196+ t.Errorf("captured=%+v pending=%+v", l.current, l.pending)
197+ }
198+ surf.queueUpdate()
199+ surf.queue[0].applyGraph()
200+ if l.current.width != 2 || l.current.anchor != 2 || l.current.zone != 9 {
201+ t.Errorf("next commit: %+v", l.current)
202+ }
203+ })
204+}
205+
206+// Role and surface destruction drop their queues. Viewport and buffer
207+// destruction leave the queued update to apply safely at the next tick.
208+// Roundtrip orders destruction before examining the display owner.
209+func TestQueuedDestructionDropsUpdates(t *testing.T) {
210+ for _, kind := range []string{"viewport", "toplevel", "layer", "surface", "buffer"} {
211+ t.Run(kind, func(t *testing.T) {
212+ s, events, _, _, dir := contentServer(t)
213+ c := protocolClient(t, s, dir)
214+ var surfID, objectID, bufferID uint32
215+ if kind == "toplevel" {
216+ _, surfID, _ = surfaceMapper(t, c, events)()
217+ s.display.Do(func() {
218+ for _, surf := range s.surfaces {
219+ if surf.wl != nil && surf.wl.ID() == surfID {
220+ objectID = surf.xdg.window.toplevel.ID()
221+ }
222+ }
223+ })
224+ } else {
225+ comp := bindProtocol(t, c, "wl_compositor")
226+ surfID = c.AllocateID()
227+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
228+ registerProtocol(t, c, surfID)
229+ if kind == "viewport" {
230+ vpm := bindProtocol(t, c, "wp_viewporter")
231+ objectID = c.AllocateID()
232+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, objectID, surfID)
233+ registerProtocol(t, c, objectID)
234+ } else if kind == "layer" {
235+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
236+ objectID = c.AllocateID()
237+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, objectID, surfID, uint32(0), uint32(ports.LayerTop), "drop")
238+ registerProtocol(t, c, objectID)
239+ requestProtocol(t, c, objectID, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
240+ } else if kind == "buffer" {
241+ bufferID = shmBuffer(t, c)
242+ }
243+ }
244+ if err := c.Roundtrip(); err != nil {
245+ t.Fatal(err)
246+ }
247+ s.display.Do(func() {
248+ for _, surf := range s.surfaces {
249+ if surf.wl != nil && surf.wl.ID() == surfID {
250+ surf.pendingScale = 3
251+ surf.pendingDamage = []ports.Rect{{W: 3}}
252+ if kind == "viewport" {
253+ surf.viewport.SetDestination(surf.viewport.resource, 2, 2)
254+ }
255+ surf.pendingTime = time.Now().Add(time.Second)
256+ if kind == "buffer" {
257+ for resource := range s.buffers {
258+ if resource.ID() == bufferID {
259+ surf.pending = wayland.WrapBuffer(resource)
260+ surf.attached = true
261+ }
262+ }
263+ }
264+ surf.queueUpdate()
265+ }
266+ }
267+ })
268+ switch kind {
269+ case "viewport":
270+ requestProtocol(t, c, objectID, viewporter.WpViewportRequestDestroy)
271+ case "toplevel":
272+ requestProtocol(t, c, objectID, xdgshell.ToplevelRequestDestroy)
273+ case "layer":
274+ requestProtocol(t, c, objectID, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
275+ case "surface":
276+ requestProtocol(t, c, surfID, wayland.SurfaceRequestDestroy)
277+ case "buffer":
278+ requestProtocol(t, c, bufferID, wayland.BufferRequestDestroy)
279+ }
280+ if err := c.Roundtrip(); err != nil {
281+ t.Fatal(err)
282+ }
283+ s.display.Do(func() {
284+ for _, surf := range s.surfaces {
285+ if surf.wl != nil && surf.wl.ID() == surfID {
286+ if kind == "viewport" || kind == "buffer" {
287+ if kind == "buffer" && (len(surf.queue) != 1 || surf.queue[0].buffer == nil || surf.queue[0].buffer.Resource.Alive()) {
288+ t.Error("expected destroyed buffer in queued update")
289+ }
290+ if len(surf.queue) != 1 {
291+ t.Errorf("%s should retain queued update until tick", kind)
292+ }
293+ s.tickFifo(time.Now().Add(2*time.Second), nil)
294+ }
295+ if len(surf.queue) != 0 {
296+ t.Errorf("%s retained queued update", kind)
297+ }
298+ }
299+ }
300+ fresh := &surface{server: s}
301+ fresh.queueUpdate()
302+ u := fresh.queue[0]
303+ if u.scale != 0 || u.buffer != nil || u.vp != nil || u.layer != nil || len(u.damage) != 0 || u.owner != fresh {
304+ t.Errorf("%s recycled stale update: %+v", kind, u)
305+ }
306+ fresh.queue[0].applyGraph()
307+ })
308+ })
309+ }
310+}
311+
312+// A dropped update must be cleared before a different surface reuses it.
313+func TestDroppedUpdateRecycledWithoutState(t *testing.T) {
314+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
315+ old := &surface{server: srv, pendingScale: 2, pendingBarrier: true, pendingDamage: []ports.Rect{{W: 2}}}
316+ old.queueUpdate()
317+ old.destroyed = true
318+ old.dropQueue()
319+ fresh := &surface{server: srv}
320+ fresh.queueUpdate()
321+ if u := fresh.queue[0]; u.scale != 0 || u.barrier || len(u.damage) != 0 || u.owner != fresh || u.buffer != nil || u.vp != nil || u.layer != nil || u.xdg != nil {
322+ t.Fatalf("recycled update retained old state: %+v", u)
323+ }
324+ fresh.queue[0].applyGraph()
325+}
326+
327+// BenchmarkEmptySurfaceQueueApply measures an empty, roleless surface's
328+// in-process queue/apply loop. It excludes protocol dispatch and real buffers.
329+func BenchmarkEmptySurfaceQueueApply(b *testing.B) {
330+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
331+ surf := &surface{server: srv}
332+ surf.queueUpdate()
333+ surf.queue[0].applyGraph()
334+ b.ReportAllocs()
335+ b.ResetTimer()
336+ for range b.N {
337+ surf.queueUpdate()
338+ surf.queue[0].applyGraph()
339+ }
340+}
341+
342+// TestCapturedViewportApplyAllocations exercises the real viewport resource
343+// on the display owner: a viewport-only commit has no heap work.
344+func TestCapturedViewportApplyAllocations(t *testing.T) {
345+ s, _, _, _, dir := contentServer(t)
346+ c := protocolClient(t, s, dir)
347+ comp := bindProtocol(t, c, "wl_compositor")
348+ vpm := bindProtocol(t, c, "wp_viewporter")
349+ surfID := c.AllocateID()
350+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
351+ registerProtocol(t, c, surfID)
352+ vpID := c.AllocateID()
353+ requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vpID, surfID)
354+ registerProtocol(t, c, vpID)
355+ if err := c.Roundtrip(); err != nil {
356+ t.Fatal(err)
357+ }
358+ s.display.Do(func() {
359+ var surf *surface
360+ for _, candidate := range s.surfaces {
361+ if candidate.wl != nil && candidate.wl.ID() == surfID {
362+ surf = candidate
363+ break
364+ }
365+ }
366+ if surf == nil || surf.viewport == nil || surf.viewport.resource == nil {
367+ t.Error("missing viewport")
368+ return
369+ }
370+ apply := func() {
371+ surf.queueUpdate()
372+ surf.queue[0].applyGraph()
373+ }
374+ apply()
375+ if allocs := testing.AllocsPerRun(100, apply); allocs != 0 {
376+ t.Errorf("viewport queue/apply: %.1f allocs/commit; want 0", allocs)
377+ }
378+ })
379+}
380+
381+func TestCapturedCommitApplyAllocations(t *testing.T) {
382+ srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)}
383+ surf := &surface{server: srv}
384+ apply := func() {
385+ surf.queueUpdate()
386+ surf.queue[0].applyGraph()
387+ }
388+ apply() // warm pool and queue backing storage
389+ if allocs := testing.AllocsPerRun(100, apply); allocs != 0 {
390+ t.Errorf("steady-state queue/apply: %.1f allocs/commit; want 0", allocs)
391+ }
392+}
M internal/adapters/wayland/constraints.go +13 -7
@@ -169,15 +169,21 @@ func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, re
169169 h.c.pending = &r
170170 }
171171
172-// commitConstraint applies a pending region on the surface commit, after
173-// the new window geometry: core receives the region window-local.
174-func (s *surface) commitConstraint(geometry bool) {
175- c := s.server.constraints[s]
176- if c == nil || (c.pending == nil && !geometry) {
172+// commitConstraint applies this commit's captured region after its geometry;
173+// later set_region requests remain pending. Core receives window-local state.
174+func (s *surface) commitConstraint(c *constraint, region *ports.Rect, geometry bool) {
175+ if c == nil || s.server.constraints[s] != c {
176+ if !geometry {
177+ return
178+ }
179+ c = s.server.constraints[s]
180+ region = nil // never apply an old constraint's region to its replacement
181+ }
182+ if c == nil || (region == nil && !geometry) {
177183 return
178184 }
179- if c.pending != nil {
180- c.region, c.pending = *c.pending, nil
185+ if region != nil && s.server.constraints[s] == c {
186+ c.region = *region
181187 }
182188 if c.active {
183189 s.server.emitConstraint(c)
@@ -169,15 +169,21 @@ func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, re
169 h.c.pending = &r169 h.c.pending = &r
170 }170 }
171 171
172-// commitConstraint applies a pending region on the surface commit, after172+// commitConstraint applies this commit's captured region after its geometry;
173-// the new window geometry: core receives the region window-local.173+// later set_region requests remain pending. Core receives window-local state.
174-func (s *surface) commitConstraint(geometry bool) {174+func (s *surface) commitConstraint(c *constraint, region *ports.Rect, geometry bool) {
175- c := s.server.constraints[s]175+ if c == nil || s.server.constraints[s] != c {
176- if c == nil || (c.pending == nil && !geometry) {176+ if !geometry {
177+ return
178+ }
179+ c = s.server.constraints[s]
180+ region = nil // never apply an old constraint's region to its replacement
181+ }
182+ if c == nil || (region == nil && !geometry) {
177 return183 return
178 }184 }
179- if c.pending != nil {185+ if region != nil && s.server.constraints[s] == c {
180- c.region, c.pending = *c.pending, nil186+ c.region = *region
181 }187 }
182 if c.active {188 if c.active {
183 s.server.emitConstraint(c)189 s.server.emitConstraint(c)
M internal/adapters/wayland/fifo.go +8 -52
@@ -22,9 +22,9 @@ import (
2222 // - wp_commit_timing_v1: a commit waits until the refresh that shows it
2323 // is not before its timestamp (CLOCK_MONOTONIC).
2424 //
25-// A commit that must wait is taken out of the surface's pending state and
26-// queued; the pacer (frames.go) applies queued updates in order when they
27-// are ready. wp_content_type_v1 is recorded and logged only.
25+// Every commit captures its pending state into an update; the pacer (frames.go)
26+// applies queued updates in order when they are ready. Requests arriving after
27+// capture stay in live pending state. wp_content_type_v1 is logged only.
2828
2929 func registerPresentationConstraints(d *server.Display, s *Server) error {
3030 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
@@ -197,7 +197,7 @@ func (s *Server) surfaceOf(w *wayland.Surface) *surface {
197197
198198 // Content update queue
199199
200-// update is a surface's pending state taken out by a commit that waits.
200+// update owns a captured commit until it applies or is discarded.
201201 type update struct {
202202 inputSet, inputAll bool
203203 inputRects []ports.Rect
@@ -331,36 +331,6 @@ func (s *surface) takePending() update {
331331 return u
332332 }
333333
334-// putPending makes an update the surface's pending state again. Parts
335-// whose object is gone (viewport, child, role, constraint) are dropped.
336-func (s *surface) putPending(u update) {
337- s.pendingInputSet, s.pendingInputAll, s.pendingInputRects = u.inputSet, u.inputAll, u.inputRects
338- s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks
339- s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
340- s.pendingColor = u.color
341- s.pendingRepresentation = u.representation
342- s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
343- s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
344- s.pendingFeedback, s.pendingSync = u.feedback, u.sync
345- if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
346- s.viewport = u.vp
347- u.vp.pendingW, u.vp.pendingH, u.vp.pendingSet = u.vpW, u.vpH, u.vpSet
348- u.vp.pendingSrc, u.vp.pendingCrop = u.vpSrc, u.vpCrop
349- } else {
350- s.viewport = nil
351- }
352- s.sub.pendingLayout = u.layout
353- if u.xdg != nil && s.xdg == u.xdg {
354- u.xdg.pendingGeometry = u.geometry
355- }
356- if u.cons != nil && s.server.constraints[s] == u.cons {
357- u.cons.pending = u.region
358- }
359- if u.layer != nil && s.layer == u.layer {
360- u.layer.pending = u.layerNext
361- }
362-}
363-
364334 // tooEarly reports whether content applied now would show before at:
365335 // it reaches the screen one refresh later.
366336 func (s *surface) tooEarly(at, now time.Time) bool {
@@ -415,7 +385,7 @@ func (s *surface) queueUpdate() {
415385 s.server.wakePacer()
416386 }
417387
418-// applyUpdate applies a queued update, keeping the requests made since.
388+// applyUpdate applies only the captured commit; later requests remain pending.
419389 func (s *surface) applyUpdate(u *update) {
420390 // A buffer destroyed while queued (a swapchain resize) is skipped: the
421391 // surface keeps its content, and is not unmapped behind the client.
@@ -433,25 +403,11 @@ func (s *surface) applyUpdate(u *update) {
433403 // The viewport change was made for that buffer: keep the crop that
434404 // matches the retained content instead of validating a mismatch.
435405 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
436- if v := s.committedViewport; v != nil {
437- u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop
438- } else {
439- u.vpSet, u.vpCrop = false, false
440- }
406+ v := &s.committedViewport
407+ u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop
441408 }
442409 }
443- later := s.takePending()
444- originalViewport := s.viewport
445- originalLayout := s.sub.pendingLayout
446- s.putPending(*u)
447- s.applyCommit()
448- s.putPending(later)
449- if originalViewport != nil {
450- originalViewport.pendingW, originalViewport.pendingH, originalViewport.pendingSet = later.vpW, later.vpH, later.vpSet
451- originalViewport.pendingSrc, originalViewport.pendingCrop = later.vpSrc, later.vpCrop
452- }
453- s.viewport = originalViewport
454- s.sub.pendingLayout = originalLayout
410+ s.applyCommit(u)
455411 s.commitSkipped = false
456412 }
457413
@@ -22,9 +22,9 @@ import (
22 // - wp_commit_timing_v1: a commit waits until the refresh that shows it22 // - wp_commit_timing_v1: a commit waits until the refresh that shows it
23 // is not before its timestamp (CLOCK_MONOTONIC).23 // is not before its timestamp (CLOCK_MONOTONIC).
24 //24 //
25-// A commit that must wait is taken out of the surface's pending state and25+// Every commit captures its pending state into an update; the pacer (frames.go)
26-// queued; the pacer (frames.go) applies queued updates in order when they26+// applies queued updates in order when they are ready. Requests arriving after
27-// are ready. wp_content_type_v1 is recorded and logged only.27+// capture stay in live pending state. wp_content_type_v1 is logged only.
28 28
29 func registerPresentationConstraints(d *server.Display, s *Server) error {29 func registerPresentationConstraints(d *server.Display, s *Server) error {
30 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {30 if err := fifo.NewWpFifoManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
@@ -197,7 +197,7 @@ func (s *Server) surfaceOf(w *wayland.Surface) *surface {
197 197
198 // Content update queue198 // Content update queue
199 199
200-// update is a surface's pending state taken out by a commit that waits.200+// update owns a captured commit until it applies or is discarded.
201 type update struct {201 type update struct {
202 inputSet, inputAll bool202 inputSet, inputAll bool
203 inputRects []ports.Rect203 inputRects []ports.Rect
@@ -331,36 +331,6 @@ func (s *surface) takePending() update {
331 return u331 return u
332 }332 }
333 333
334-// putPending makes an update the surface's pending state again. Parts
335-// whose object is gone (viewport, child, role, constraint) are dropped.
336-func (s *surface) putPending(u update) {
337- s.pendingInputSet, s.pendingInputAll, s.pendingInputRects = u.inputSet, u.inputAll, u.inputRects
338- s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks
339- s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
340- s.pendingColor = u.color
341- s.pendingRepresentation = u.representation
342- s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
343- s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
344- s.pendingFeedback, s.pendingSync = u.feedback, u.sync
345- if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
346- s.viewport = u.vp
347- u.vp.pendingW, u.vp.pendingH, u.vp.pendingSet = u.vpW, u.vpH, u.vpSet
348- u.vp.pendingSrc, u.vp.pendingCrop = u.vpSrc, u.vpCrop
349- } else {
350- s.viewport = nil
351- }
352- s.sub.pendingLayout = u.layout
353- if u.xdg != nil && s.xdg == u.xdg {
354- u.xdg.pendingGeometry = u.geometry
355- }
356- if u.cons != nil && s.server.constraints[s] == u.cons {
357- u.cons.pending = u.region
358- }
359- if u.layer != nil && s.layer == u.layer {
360- u.layer.pending = u.layerNext
361- }
362-}
363-
364 // tooEarly reports whether content applied now would show before at:334 // tooEarly reports whether content applied now would show before at:
365 // it reaches the screen one refresh later.335 // it reaches the screen one refresh later.
366 func (s *surface) tooEarly(at, now time.Time) bool {336 func (s *surface) tooEarly(at, now time.Time) bool {
@@ -415,7 +385,7 @@ func (s *surface) queueUpdate() {
415 s.server.wakePacer()385 s.server.wakePacer()
416 }386 }
417 387
418-// applyUpdate applies a queued update, keeping the requests made since.388+// applyUpdate applies only the captured commit; later requests remain pending.
419 func (s *surface) applyUpdate(u *update) {389 func (s *surface) applyUpdate(u *update) {
420 // A buffer destroyed while queued (a swapchain resize) is skipped: the390 // A buffer destroyed while queued (a swapchain resize) is skipped: the
421 // surface keeps its content, and is not unmapped behind the client.391 // surface keeps its content, and is not unmapped behind the client.
@@ -433,25 +403,11 @@ func (s *surface) applyUpdate(u *update) {
433 // The viewport change was made for that buffer: keep the crop that403 // The viewport change was made for that buffer: keep the crop that
434 // matches the retained content instead of validating a mismatch.404 // matches the retained content instead of validating a mismatch.
435 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {405 if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
436- if v := s.committedViewport; v != nil {406+ v := &s.committedViewport
437- u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop407+ u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop
438- } else {
439- u.vpSet, u.vpCrop = false, false
440- }
441 }408 }
442 }409 }
443- later := s.takePending()410+ s.applyCommit(u)
444- originalViewport := s.viewport
445- originalLayout := s.sub.pendingLayout
446- s.putPending(*u)
447- s.applyCommit()
448- s.putPending(later)
449- if originalViewport != nil {
450- originalViewport.pendingW, originalViewport.pendingH, originalViewport.pendingSet = later.vpW, later.vpH, later.vpSet
451- originalViewport.pendingSrc, originalViewport.pendingCrop = later.vpSrc, later.vpCrop
452- }
453- s.viewport = originalViewport
454- s.sub.pendingLayout = originalLayout
455 s.commitSkipped = false411 s.commitSkipped = false
456 }412 }
457 413
M internal/adapters/wayland/layer.go +6 -1
@@ -224,6 +224,12 @@ func (l *layerSurface) sendConfigure() {
224224 }
225225
226226 func (l *layerSurface) commit(buffer bool) {
227+ l.commitState(l.pending, buffer)
228+}
229+
230+// commitState applies the state captured by this surface commit, not later
231+// layer-shell requests still pending on the role object.
232+func (l *layerSurface) commitState(p layerState, buffer bool) {
227233 if l.surface.destroyed {
228234 l.unmap()
229235 delete(l.shell.server.layers, l.id)
@@ -232,7 +238,6 @@ func (l *layerSurface) commit(buffer bool) {
232238 if l.closed {
233239 return
234240 }
235- p := l.pending
236241 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
237242 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
238243 return
@@ -224,6 +224,12 @@ func (l *layerSurface) sendConfigure() {
224 }224 }
225 225
226 func (l *layerSurface) commit(buffer bool) {226 func (l *layerSurface) commit(buffer bool) {
227+ l.commitState(l.pending, buffer)
228+}
229+
230+// commitState applies the state captured by this surface commit, not later
231+// layer-shell requests still pending on the role object.
232+func (l *layerSurface) commitState(p layerState, buffer bool) {
227 if l.surface.destroyed {233 if l.surface.destroyed {
228 l.unmap()234 l.unmap()
229 delete(l.shell.server.layers, l.id)235 delete(l.shell.server.layers, l.id)
@@ -232,7 +238,6 @@ func (l *layerSurface) commit(buffer bool) {
232 if l.closed {238 if l.closed {
233 return239 return
234 }240 }
235- p := l.pending
236 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {241 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
237 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")242 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
238 return243 return
M internal/adapters/wayland/outputconfiguration.go +10 -4
@@ -20,6 +20,7 @@ type headConfiguration struct {
2020 parent *outputConfiguration
2121 res *wlr.ZwlrOutputConfigurationHeadV1
2222 change ports.HeadChange
23+ unsupported bool
2324 modeSet, positionSet, transformSet, scaleSet, syncSet bool
2425 }
2526
@@ -92,6 +93,10 @@ func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool
9293 r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
9394 return
9495 }
96+ if change.unsupported {
97+ r.SendFailed()
98+ return
99+ }
95100 request.Heads = append(request.Heads, change.change)
96101 }
97102 if c.manager.s.channels.OutputApply == nil {
@@ -138,7 +143,7 @@ func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1,
138143 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode")
139144 return
140145 }
141- h.change.CustomMode = true
146+ h.unsupported = true
142147 h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)}
143148 }
144149 func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) {
@@ -154,7 +159,9 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
154159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
155160 return
156161 }
157- h.change.Transform = int(v)
162+ if v != 0 {
163+ h.unsupported = true
164+ }
158165 }
159166 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
160167 if !h.once(&h.scaleSet, r) {
@@ -175,6 +182,5 @@ func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1
175182 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync")
176183 return
177184 }
178- enabled := v == 1
179- h.change.AdaptiveSync = &enabled
185+ h.unsupported = true
180186 }
@@ -20,6 +20,7 @@ type headConfiguration struct {
20 parent *outputConfiguration20 parent *outputConfiguration
21 res *wlr.ZwlrOutputConfigurationHeadV121 res *wlr.ZwlrOutputConfigurationHeadV1
22 change ports.HeadChange22 change ports.HeadChange
23+ unsupported bool
23 modeSet, positionSet, transformSet, scaleSet, syncSet bool24 modeSet, positionSet, transformSet, scaleSet, syncSet bool
24 }25 }
25 26
@@ -92,6 +93,10 @@ func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool
92 r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")93 r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured")
93 return94 return
94 }95 }
96+ if change.unsupported {
97+ r.SendFailed()
98+ return
99+ }
95 request.Heads = append(request.Heads, change.change)100 request.Heads = append(request.Heads, change.change)
96 }101 }
97 if c.manager.s.channels.OutputApply == nil {102 if c.manager.s.channels.OutputApply == nil {
@@ -138,7 +143,7 @@ func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1,
138 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode")143 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode")
139 return144 return
140 }145 }
141- h.change.CustomMode = true146+ h.unsupported = true
142 h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)}147 h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)}
143 }148 }
144 func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) {149 func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) {
@@ -154,7 +159,9 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
154 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
155 return160 return
156 }161 }
157- h.change.Transform = int(v)162+ if v != 0 {
163+ h.unsupported = true
164+ }
158 }165 }
159 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {166 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
160 if !h.once(&h.scaleSet, r) {167 if !h.once(&h.scaleSet, r) {
@@ -175,6 +182,5 @@ func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1
175 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync")182 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync")
176 return183 return
177 }184 }
178- enabled := v == 1185+ h.unsupported = true
179- h.change.AdaptiveSync = &enabled
180 }186 }
M internal/adapters/wayland/outputmanagement_test.go +55 -0
@@ -233,6 +233,61 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
233233 waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
234234 }
235235
236+// Unsupported but well-formed settings fail the configuration, not the client,
237+// and must never be sent across the app port.
238+func TestOutputManagementUnsupportedSettings(t *testing.T) {
239+ for _, tc := range []struct {
240+ name string
241+ send func(*testing.T, *wlturbo.Display, uint32)
242+ }{
243+ {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {
244+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))
245+ }},
246+ {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) {
247+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1))
248+ }},
249+ {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {
250+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
251+ }},
252+ } {
253+ t.Run(tc.name, func(t *testing.T) {
254+ s, _, apply, _, dir := outputTestServer(t)
255+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
256+ t.Fatal("display stopped")
257+ }
258+ c := protocolClient(t, s, dir)
259+ managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
260+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
261+ manager.SetID(managerID)
262+ c.Context().Register(manager)
263+ if err := c.Roundtrip(); err != nil {
264+ t.Fatal(err)
265+ }
266+ head := recvManagement(t, manager.events)
267+ var done [2]uint32
268+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
269+ done = recvManagement(t, manager.events)
270+ }
271+ cfg := c.AllocateID()
272+ proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
273+ proxy.SetID(cfg)
274+ c.Context().Register(proxy)
275+ requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
276+ ch := c.AllocateID()
277+ registerProtocol(t, c, ch)
278+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head[1])
279+ tc.send(t, c, ch)
280+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
281+ waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventFailed))
282+ select {
283+ case req := <-apply:
284+ t.Fatalf("unsupported setting reached app: %+v", req)
285+ default:
286+ }
287+ })
288+ }
289+}
290+
236291 // Each case uses a new connection because wl_display.error terminates that client.
237292 func TestOutputManagementProtocolErrors(t *testing.T) {
238293 const (
@@ -233,6 +233,61 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
233 waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))233 waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
234 }234 }
235 235
236+// Unsupported but well-formed settings fail the configuration, not the client,
237+// and must never be sent across the app port.
238+func TestOutputManagementUnsupportedSettings(t *testing.T) {
239+ for _, tc := range []struct {
240+ name string
241+ send func(*testing.T, *wlturbo.Display, uint32)
242+ }{
243+ {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {
244+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))
245+ }},
246+ {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) {
247+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1))
248+ }},
249+ {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {
250+ requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
251+ }},
252+ } {
253+ t.Run(tc.name, func(t *testing.T) {
254+ s, _, apply, _, dir := outputTestServer(t)
255+ if !s.display.Do(func() { s.setOutputHeads(testHead()) }) {
256+ t.Fatal("display stopped")
257+ }
258+ c := protocolClient(t, s, dir)
259+ managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
260+ manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
261+ manager.SetID(managerID)
262+ c.Context().Register(manager)
263+ if err := c.Roundtrip(); err != nil {
264+ t.Fatal(err)
265+ }
266+ head := recvManagement(t, manager.events)
267+ var done [2]uint32
268+ for done[0] != wlr.ZwlrOutputManagerV1EventDone {
269+ done = recvManagement(t, manager.events)
270+ }
271+ cfg := c.AllocateID()
272+ proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
273+ proxy.SetID(cfg)
274+ c.Context().Register(proxy)
275+ requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
276+ ch := c.AllocateID()
277+ registerProtocol(t, c, ch)
278+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head[1])
279+ tc.send(t, c, ch)
280+ requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
281+ waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventFailed))
282+ select {
283+ case req := <-apply:
284+ t.Fatalf("unsupported setting reached app: %+v", req)
285+ default:
286+ }
287+ })
288+ }
289+}
290+
236 // Each case uses a new connection because wl_display.error terminates that client.291 // Each case uses a new connection because wl_display.error terminates that client.
237 func TestOutputManagementProtocolErrors(t *testing.T) {292 func TestOutputManagementProtocolErrors(t *testing.T) {
238 const (293 const (
M internal/adapters/wayland/scale.go +23 -22
@@ -167,15 +167,22 @@ func (h viewporterHandler) GetViewport(r *viewporter.WpViewporter, id uint32, w
167167 }
168168 }
169169
170-// viewport holds double-buffered crop and destination state.
170+// viewport holds requests for the next commit; the committed crop and
171+// destination live by value on the surface so applying cannot allocate.
171172 type viewport struct {
172- surface *surface
173- resource *viewporter.WpViewport
174- pendingW, destW int32
175- pendingH, destH int32
176- pendingSet, dest bool
177- pendingSrc, src [4]server.Fixed
178- pendingCrop, crop bool
173+ surface *surface
174+ resource *viewporter.WpViewport
175+ pendingW, pendingH int32
176+ pendingSet bool
177+ pendingSrc [4]server.Fixed
178+ pendingCrop bool
179+}
180+
181+type viewportState struct {
182+ destW, destH int32
183+ dest bool
184+ src [4]server.Fixed
185+ crop bool
179186 }
180187
181188 func (*viewport) Destroy(*viewporter.WpViewport) {}
@@ -202,14 +209,10 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) {
202209 }
203210 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1
204211 }
205-func (v *viewport) commit() {
206- v.destW, v.destH, v.dest = v.pendingW, v.pendingH, v.pendingSet
207- v.src, v.crop = v.pendingSrc, v.pendingCrop
208-}
209212
210213 // source returns buffer-pixel crop coordinates, precomputed at commit.
211214 func (s *surface) source(bw, bh int) ([4]float32, bool) {
212- if s.committedViewport == nil || !s.committedViewport.crop {
215+ if !s.committedViewport.crop {
213216 return [4]float32{}, false
214217 }
215218 v := s.committedViewport.src
@@ -222,8 +225,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
222225 }
223226
224227 func (s *surface) validateViewport(bw, bh int) bool {
225- v := s.committedViewport
226- if v == nil || !v.crop {
228+ v := &s.committedViewport
229+ if !v.crop {
227230 return true
228231 }
229232 if !v.dest && (v.src[2]%256 != 0 || v.src[3]%256 != 0) {
@@ -275,13 +278,11 @@ func (s *surface) viewportError(code viewporter.WpViewportError, message string)
275278 // logicalSize is the surface size in logical pixels: the viewport
276279 // destination, else the buffer divided by its integer buffer scale.
277280 func (s *surface) logicalSize(bw, bh int) (int, int) {
278- if s.committedViewport != nil {
279- if s.committedViewport.dest {
280- return int(s.committedViewport.destW), int(s.committedViewport.destH)
281- }
282- if s.committedViewport.crop {
283- return int(s.committedViewport.src[2]) / 256, int(s.committedViewport.src[3]) / 256
284- }
281+ if s.committedViewport.dest {
282+ return int(s.committedViewport.destW), int(s.committedViewport.destH)
283+ }
284+ if s.committedViewport.crop {
285+ return int(s.committedViewport.src[2]) / 256, int(s.committedViewport.src[3]) / 256
285286 }
286287 scale := max(s.bufferScale, 1)
287288 return bw / scale, bh / scale
@@ -167,15 +167,22 @@ func (h viewporterHandler) GetViewport(r *viewporter.WpViewporter, id uint32, w
167 }167 }
168 }168 }
169 169
170-// viewport holds double-buffered crop and destination state.170+// viewport holds requests for the next commit; the committed crop and
171+// destination live by value on the surface so applying cannot allocate.
171 type viewport struct {172 type viewport struct {
172- surface *surface173+ surface *surface
173- resource *viewporter.WpViewport174+ resource *viewporter.WpViewport
174- pendingW, destW int32175+ pendingW, pendingH int32
175- pendingH, destH int32176+ pendingSet bool
176- pendingSet, dest bool177+ pendingSrc [4]server.Fixed
177- pendingSrc, src [4]server.Fixed178+ pendingCrop bool
178- pendingCrop, crop bool179+}
180+
181+type viewportState struct {
182+ destW, destH int32
183+ dest bool
184+ src [4]server.Fixed
185+ crop bool
179 }186 }
180 187
181 func (*viewport) Destroy(*viewporter.WpViewport) {}188 func (*viewport) Destroy(*viewporter.WpViewport) {}
@@ -202,14 +209,10 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) {
202 }209 }
203 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1210 v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1
204 }211 }
205-func (v *viewport) commit() {
206- v.destW, v.destH, v.dest = v.pendingW, v.pendingH, v.pendingSet
207- v.src, v.crop = v.pendingSrc, v.pendingCrop
208-}
209 212
210 // source returns buffer-pixel crop coordinates, precomputed at commit.213 // source returns buffer-pixel crop coordinates, precomputed at commit.
211 func (s *surface) source(bw, bh int) ([4]float32, bool) {214 func (s *surface) source(bw, bh int) ([4]float32, bool) {
212- if s.committedViewport == nil || !s.committedViewport.crop {215+ if !s.committedViewport.crop {
213 return [4]float32{}, false216 return [4]float32{}, false
214 }217 }
215 v := s.committedViewport.src218 v := s.committedViewport.src
@@ -222,8 +225,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
222 }225 }
223 226
224 func (s *surface) validateViewport(bw, bh int) bool {227 func (s *surface) validateViewport(bw, bh int) bool {
225- v := s.committedViewport228+ v := &s.committedViewport
226- if v == nil || !v.crop {229+ if !v.crop {
227 return true230 return true
228 }231 }
229 if !v.dest && (v.src[2]%256 != 0 || v.src[3]%256 != 0) {232 if !v.dest && (v.src[2]%256 != 0 || v.src[3]%256 != 0) {
@@ -275,13 +278,11 @@ func (s *surface) viewportError(code viewporter.WpViewportError, message string)
275 // logicalSize is the surface size in logical pixels: the viewport278 // logicalSize is the surface size in logical pixels: the viewport
276 // destination, else the buffer divided by its integer buffer scale.279 // destination, else the buffer divided by its integer buffer scale.
277 func (s *surface) logicalSize(bw, bh int) (int, int) {280 func (s *surface) logicalSize(bw, bh int) (int, int) {
278- if s.committedViewport != nil {281+ if s.committedViewport.dest {
279- if s.committedViewport.dest {282+ return int(s.committedViewport.destW), int(s.committedViewport.destH)
280- return int(s.committedViewport.destW), int(s.committedViewport.destH)283+ }
281- }284+ if s.committedViewport.crop {
282- if s.committedViewport.crop {285+ return int(s.committedViewport.src[2]) / 256, int(s.committedViewport.src[3]) / 256
283- return int(s.committedViewport.src[2]) / 256, int(s.committedViewport.src[3]) / 256
284- }
285 }286 }
286 scale := max(s.bufferScale, 1)287 scale := max(s.bufferScale, 1)
287 return bw / scale, bh / scale288 return bw / scale, bh / scale
M internal/adapters/wayland/scale_test.go +1 -1
@@ -219,7 +219,7 @@ func TestBufferScaleLogicalSize(t *testing.T) {
219219 if w, h := st.logicalSize(200, 100); w != 100 || h != 50 {
220220 t.Fatal(w, h)
221221 }
222- st.committedViewport = &viewport{surface: st, destW: 7, destH: 3, dest: true}
222+ st.committedViewport = viewportState{destW: 7, destH: 3, dest: true}
223223 if w, h := st.logicalSize(200, 100); w != 7 || h != 3 {
224224 t.Fatal(w, h)
225225 }
@@ -219,7 +219,7 @@ func TestBufferScaleLogicalSize(t *testing.T) {
219 if w, h := st.logicalSize(200, 100); w != 100 || h != 50 {219 if w, h := st.logicalSize(200, 100); w != 100 || h != 50 {
220 t.Fatal(w, h)220 t.Fatal(w, h)
221 }221 }
222- st.committedViewport = &viewport{surface: st, destW: 7, destH: 3, dest: true}222+ st.committedViewport = viewportState{destW: 7, destH: 3, dest: true}
223 if w, h := st.logicalSize(200, 100); w != 7 || h != 3 {223 if w, h := st.logicalSize(200, 100); w != 7 || h != 3 {
224 t.Fatal(w, h)224 t.Fatal(w, h)
225 }225 }
M internal/adapters/wayland/surface.go +47 -53
@@ -25,7 +25,7 @@ const (
2525 type surface struct {
2626 wl *wayland.Surface
2727 viewport *viewport
28- committedViewport *viewport
28+ committedViewport viewportState // value snapshot; no allocation on viewport commits
2929 // bufferScale is committed state; pendingScale is set by set_buffer_scale.
3030 bufferScale, pendingScale int
3131 kind roleKind
@@ -312,69 +312,65 @@ func (s *surface) Commit(*wayland.Surface) {
312312 s.server.tickFifo(time.Now(), nil)
313313 }
314314
315-// applyCommit makes the pending state current.
316-func (s *surface) applyCommit() {
315+// applyCommit makes only the captured update current. Live pending requests
316+// remain untouched, even when this update waited behind another commit.
317+func (s *surface) applyCommit(u *update) {
317318 oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source
318319 oldColor, oldRepresentation := s.color, s.representation
319- fresh := s.attached && s.pending != nil
320+ fresh := u.attached && u.buffer != nil
320321 s.commitFresh = fresh
321322 if !s.commitSkipped {
322- s.queuedScale = s.pendingScale
323+ s.queuedScale = u.scale
323324 s.queuedBuffer = 0
324- if s.pending != nil {
325- s.queuedBuffer = s.pending.ID()
325+ if u.buffer != nil {
326+ s.queuedBuffer = u.buffer.ID()
326327 }
327328 }
328- if s.pendingBarrier {
329- s.pendingBarrier = false
329+ if u.barrier {
330330 s.setBarrier(time.Now())
331331 }
332- s.pendingWait, s.pendingTime = false, time.Time{}
333- if s.contentKind != s.pendingKind {
334- s.contentKind = s.pendingKind
332+ if s.contentKind != u.kind {
333+ s.contentKind = u.kind
335334 s.server.log.Info().Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")
336335 }
337- if s.pendingScale > 0 {
338- s.bufferScale = s.pendingScale
339- }
340- hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation
341- s.async = s.pendingAsync
342- s.color = s.pendingColor
343- s.representation = s.pendingRepresentation
344- if s.viewport != nil {
345- s.viewport.commit()
346- v := *s.viewport
347- s.committedViewport = &v
336+ if u.scale > 0 {
337+ s.bufferScale = u.scale
338+ }
339+ hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
340+ s.async = u.async
341+ s.color = u.color
342+ s.representation = u.representation
343+ if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
344+ s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet
345+ s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop
348346 } else {
349- s.committedViewport = nil
347+ s.committedViewport.dest, s.committedViewport.crop = false, false
350348 }
351- cs := s.pendingSync
352- s.pendingSync = nil
353- if s.attached {
354- if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {
349+ cs := u.sync
350+ if u.attached {
351+ if s.current != nil && (u.buffer == nil || s.current.Resource != u.buffer.Resource) {
355352 s.server.releaseBuffer(s, s.current, s.hold)
356- } else if cs != nil || s.pending == nil {
353+ } else if cs != nil || u.buffer == nil {
357354 // Same buffer again, or detached: the old points are done
358355 // with once the new ones take over.
359356 s.server.releaseBufferSync(s, s.hold)
360357 }
361358 s.hold = syncHold{}
362- s.current = s.pending
363- s.pending = nil
364- s.attached = false
359+ s.current = u.buffer
365360 }
366361 s.applySync(cs)
367- if s.pendingInputSet {
368- s.inputAll, s.inputRects = s.pendingInputAll, s.pendingInputRects
369- s.pendingInputRects = nil
370- s.pendingInputSet = false
362+ if u.inputSet {
363+ s.inputAll, s.inputRects = u.inputAll, u.inputRects
371364 }
372- if len(s.callbacks) > 0 {
373- s.server.queueFrames(s.server.frameOutput(s), s.callbacks)
365+ if len(u.callbacks) > 0 {
366+ s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
374367 }
375- s.callbacks = nil
376368 if s.role != nil {
377- s.role(s.current != nil)
369+ if u.layer != nil && s.layer == u.layer {
370+ u.layer.commitState(u.layerNext, s.current != nil)
371+ } else {
372+ s.role(s.current != nil)
373+ }
378374 }
379375 if fresh {
380376 if state, ok := s.server.buffers[s.current.Resource]; ok {
@@ -385,7 +381,7 @@ func (s *surface) applyCommit() {
385381 c.Source, _ = s.source(c.Width, c.Height)
386382 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
387383 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
388- s.commitDamage(true, resized, c.Width, c.Height)
384+ s.commitDamage(u, true, resized, c.Width, c.Height)
389385 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
390386 s.lastW, s.lastH = c.Width, c.Height
391387 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")
@@ -399,9 +395,9 @@ func (s *surface) applyCommit() {
399395 // replaces them.
400396 }
401397 }
402- damaged := !fresh && s.current != nil && (len(s.pendingDamage) > 0 || len(s.pendingBufDamage) > 0)
398+ damaged := !fresh && s.current != nil && (len(u.damage) > 0 || len(u.bufDamage) > 0)
403399 if !fresh {
404- s.commitDamage(false, false, 0, 0)
400+ s.commitDamage(u, false, false, 0, 0)
405401 // A NULL attach is exempt from out_of_buffer: its content is
406402 // cleared below, so the retained buffer is not validated.
407403 if s.has && s.current != nil {
@@ -420,19 +416,19 @@ func (s *surface) applyCommit() {
420416 s.root().treeDirty = true
421417 }
422418 // The layout belongs to this update, not to the latest requests.
423- moved := !sameLayout(s.sub.layout, s.sub.pendingLayout)
419+ moved := !sameLayout(s.sub.layout, u.layout)
424420 if moved {
425421 s.root().treeDirty = true
426422 }
427423 // Queued updates can still reference the previous layout. Never mutate it.
428424 if moved {
429- s.sub.layout = s.sub.pendingLayout
425+ s.sub.layout = u.layout
430426 }
431427 geometry := false
432- if s.xdg != nil && s.xdg.pendingGeometry != s.xdg.geometry {
433- s.xdg.geometry, geometry = s.xdg.pendingGeometry, true
428+ if s.xdg != nil && s.xdg == u.xdg && u.geometry != s.xdg.geometry {
429+ s.xdg.geometry, geometry = u.geometry, true
434430 }
435- s.commitConstraint(geometry)
431+ s.commitConstraint(u.cons, u.region, geometry)
436432 s.emitInput()
437433 if s.xdg != nil && s.xdg.window != nil {
438434 s.xdg.window.afterCommit()
@@ -448,8 +444,7 @@ func (s *surface) applyCommit() {
448444 s.redraw()
449445 }
450446 }
451- fb := s.pendingFeedback
452- s.pendingFeedback = nil
447+ fb := u.feedback
453448 if s.server.applyingGraph {
454449 s.server.graphFeedback = append(s.server.graphFeedback, graphFeedback{s, fb, fresh})
455450 } else {
@@ -471,9 +466,8 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) {
471466 // buffer. Surface damage scales by the buffer/logical ratio, rounded out.
472467 // A new size, too many rects or none at all with a new buffer (clients
473468 // that do not report damage) is a full change.
474-func (s *surface) commitDamage(fresh, resized bool, bw, bh int) {
475- surf, buf := s.pendingDamage, s.pendingBufDamage
476- s.pendingDamage, s.pendingBufDamage = nil, nil
469+func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
470+ surf, buf := u.damage, u.bufDamage
477471 if !fresh {
478472 s.committed = damage{}
479473 return
@@ -25,7 +25,7 @@ const (
25 type surface struct {25 type surface struct {
26 wl *wayland.Surface26 wl *wayland.Surface
27 viewport *viewport27 viewport *viewport
28- committedViewport *viewport28+ committedViewport viewportState // value snapshot; no allocation on viewport commits
29 // bufferScale is committed state; pendingScale is set by set_buffer_scale.29 // bufferScale is committed state; pendingScale is set by set_buffer_scale.
30 bufferScale, pendingScale int30 bufferScale, pendingScale int
31 kind roleKind31 kind roleKind
@@ -312,69 +312,65 @@ func (s *surface) Commit(*wayland.Surface) {
312 s.server.tickFifo(time.Now(), nil)312 s.server.tickFifo(time.Now(), nil)
313 }313 }
314 314
315-// applyCommit makes the pending state current.315+// applyCommit makes only the captured update current. Live pending requests
316-func (s *surface) applyCommit() {316+// remain untouched, even when this update waited behind another commit.
317+func (s *surface) applyCommit(u *update) {
317 oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source318 oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source
318 oldColor, oldRepresentation := s.color, s.representation319 oldColor, oldRepresentation := s.color, s.representation
319- fresh := s.attached && s.pending != nil320+ fresh := u.attached && u.buffer != nil
320 s.commitFresh = fresh321 s.commitFresh = fresh
321 if !s.commitSkipped {322 if !s.commitSkipped {
322- s.queuedScale = s.pendingScale323+ s.queuedScale = u.scale
323 s.queuedBuffer = 0324 s.queuedBuffer = 0
324- if s.pending != nil {325+ if u.buffer != nil {
325- s.queuedBuffer = s.pending.ID()326+ s.queuedBuffer = u.buffer.ID()
326 }327 }
327 }328 }
328- if s.pendingBarrier {329+ if u.barrier {
329- s.pendingBarrier = false
330 s.setBarrier(time.Now())330 s.setBarrier(time.Now())
331 }331 }
332- s.pendingWait, s.pendingTime = false, time.Time{}332+ if s.contentKind != u.kind {
333- if s.contentKind != s.pendingKind {333+ s.contentKind = u.kind
334- s.contentKind = s.pendingKind
335 s.server.log.Info().Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")334 s.server.log.Info().Uint64("id", uint64(s.root().windowID())).Uint32("content_type", s.contentKind).Msg("content type")
336 }335 }
337- if s.pendingScale > 0 {336+ if u.scale > 0 {
338- s.bufferScale = s.pendingScale337+ s.bufferScale = u.scale
339- }338+ }
340- hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation339+ hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
341- s.async = s.pendingAsync340+ s.async = u.async
342- s.color = s.pendingColor341+ s.color = u.color
343- s.representation = s.pendingRepresentation342+ s.representation = u.representation
344- if s.viewport != nil {343+ if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
345- s.viewport.commit()344+ s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet
346- v := *s.viewport345+ s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop
347- s.committedViewport = &v
348 } else {346 } else {
349- s.committedViewport = nil347+ s.committedViewport.dest, s.committedViewport.crop = false, false
350 }348 }
351- cs := s.pendingSync349+ cs := u.sync
352- s.pendingSync = nil350+ if u.attached {
353- if s.attached {351+ if s.current != nil && (u.buffer == nil || s.current.Resource != u.buffer.Resource) {
354- if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {
355 s.server.releaseBuffer(s, s.current, s.hold)352 s.server.releaseBuffer(s, s.current, s.hold)
356- } else if cs != nil || s.pending == nil {353+ } else if cs != nil || u.buffer == nil {
357 // Same buffer again, or detached: the old points are done354 // Same buffer again, or detached: the old points are done
358 // with once the new ones take over.355 // with once the new ones take over.
359 s.server.releaseBufferSync(s, s.hold)356 s.server.releaseBufferSync(s, s.hold)
360 }357 }
361 s.hold = syncHold{}358 s.hold = syncHold{}
362- s.current = s.pending359+ s.current = u.buffer
363- s.pending = nil
364- s.attached = false
365 }360 }
366 s.applySync(cs)361 s.applySync(cs)
367- if s.pendingInputSet {362+ if u.inputSet {
368- s.inputAll, s.inputRects = s.pendingInputAll, s.pendingInputRects363+ s.inputAll, s.inputRects = u.inputAll, u.inputRects
369- s.pendingInputRects = nil
370- s.pendingInputSet = false
371 }364 }
372- if len(s.callbacks) > 0 {365+ if len(u.callbacks) > 0 {
373- s.server.queueFrames(s.server.frameOutput(s), s.callbacks)366+ s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
374 }367 }
375- s.callbacks = nil
376 if s.role != nil {368 if s.role != nil {
377- s.role(s.current != nil)369+ if u.layer != nil && s.layer == u.layer {
370+ u.layer.commitState(u.layerNext, s.current != nil)
371+ } else {
372+ s.role(s.current != nil)
373+ }
378 }374 }
379 if fresh {375 if fresh {
380 if state, ok := s.server.buffers[s.current.Resource]; ok {376 if state, ok := s.server.buffers[s.current.Resource]; ok {
@@ -385,7 +381,7 @@ func (s *surface) applyCommit() {
385 c.Source, _ = s.source(c.Width, c.Height)381 c.Source, _ = s.source(c.Width, c.Height)
386 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)382 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
387 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH383 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
388- s.commitDamage(true, resized, c.Width, c.Height)384+ s.commitDamage(u, true, resized, c.Width, c.Height)
389 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {385 if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) {
390 s.lastW, s.lastH = c.Width, c.Height386 s.lastW, s.lastH = c.Width, c.Height
391 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")387 s.server.log.Info().Uint64("id", uint64(s.windowID())).Int("w", c.Width).Int("h", c.Height).Msg("buffer size")
@@ -399,9 +395,9 @@ func (s *surface) applyCommit() {
399 // replaces them.395 // replaces them.
400 }396 }
401 }397 }
402- damaged := !fresh && s.current != nil && (len(s.pendingDamage) > 0 || len(s.pendingBufDamage) > 0)398+ damaged := !fresh && s.current != nil && (len(u.damage) > 0 || len(u.bufDamage) > 0)
403 if !fresh {399 if !fresh {
404- s.commitDamage(false, false, 0, 0)400+ s.commitDamage(u, false, false, 0, 0)
405 // A NULL attach is exempt from out_of_buffer: its content is401 // A NULL attach is exempt from out_of_buffer: its content is
406 // cleared below, so the retained buffer is not validated.402 // cleared below, so the retained buffer is not validated.
407 if s.has && s.current != nil {403 if s.has && s.current != nil {
@@ -420,19 +416,19 @@ func (s *surface) applyCommit() {
420 s.root().treeDirty = true416 s.root().treeDirty = true
421 }417 }
422 // The layout belongs to this update, not to the latest requests.418 // The layout belongs to this update, not to the latest requests.
423- moved := !sameLayout(s.sub.layout, s.sub.pendingLayout)419+ moved := !sameLayout(s.sub.layout, u.layout)
424 if moved {420 if moved {
425 s.root().treeDirty = true421 s.root().treeDirty = true
426 }422 }
427 // Queued updates can still reference the previous layout. Never mutate it.423 // Queued updates can still reference the previous layout. Never mutate it.
428 if moved {424 if moved {
429- s.sub.layout = s.sub.pendingLayout425+ s.sub.layout = u.layout
430 }426 }
431 geometry := false427 geometry := false
432- if s.xdg != nil && s.xdg.pendingGeometry != s.xdg.geometry {428+ if s.xdg != nil && s.xdg == u.xdg && u.geometry != s.xdg.geometry {
433- s.xdg.geometry, geometry = s.xdg.pendingGeometry, true429+ s.xdg.geometry, geometry = u.geometry, true
434 }430 }
435- s.commitConstraint(geometry)431+ s.commitConstraint(u.cons, u.region, geometry)
436 s.emitInput()432 s.emitInput()
437 if s.xdg != nil && s.xdg.window != nil {433 if s.xdg != nil && s.xdg.window != nil {
438 s.xdg.window.afterCommit()434 s.xdg.window.afterCommit()
@@ -448,8 +444,7 @@ func (s *surface) applyCommit() {
448 s.redraw()444 s.redraw()
449 }445 }
450 }446 }
451- fb := s.pendingFeedback447+ fb := u.feedback
452- s.pendingFeedback = nil
453 if s.server.applyingGraph {448 if s.server.applyingGraph {
454 s.server.graphFeedback = append(s.server.graphFeedback, graphFeedback{s, fb, fresh})449 s.server.graphFeedback = append(s.server.graphFeedback, graphFeedback{s, fb, fresh})
455 } else {450 } else {
@@ -471,9 +466,8 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) {
471 // buffer. Surface damage scales by the buffer/logical ratio, rounded out.466 // buffer. Surface damage scales by the buffer/logical ratio, rounded out.
472 // A new size, too many rects or none at all with a new buffer (clients467 // A new size, too many rects or none at all with a new buffer (clients
473 // that do not report damage) is a full change.468 // that do not report damage) is a full change.
474-func (s *surface) commitDamage(fresh, resized bool, bw, bh int) {469+func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
475- surf, buf := s.pendingDamage, s.pendingBufDamage470+ surf, buf := u.damage, u.bufDamage
476- s.pendingDamage, s.pendingBufDamage = nil, nil
477 if !fresh {471 if !fresh {
478 s.committed = damage{}472 s.committed = damage{}
479 return473 return
M internal/adapters/wayland/syncobj_test.go +60 -0
@@ -11,6 +11,8 @@ import (
1111
1212 "github.com/bnema/neferwl/internal/logging"
1313 "github.com/bnema/neferwl/internal/ports"
14+ "github.com/bnema/purego-libwayland/protocol/committiming"
15+ "github.com/bnema/purego-libwayland/protocol/fifo"
1416 "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"
1517 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
1618 "github.com/bnema/purego-libwayland/protocol/wayland"
@@ -295,6 +297,64 @@ func TestSyncobjAcquireAndRelease(t *testing.T) {
295297 }
296298 }
297299
300+// FIFO, commit timing and acquire are independent gates on the same ordered
301+// update: clearing the first two cannot publish before its acquire fires.
302+func TestQueuedCommitReadinessGatesInOrder(t *testing.T) {
303+ h := newSyncHarness(t)
304+ h.releases = map[uint32]*syncReleaseProxy{}
305+ first, second := h.dmabuf(), h.dmabuf()
306+ fm := bindProtocol(t, h.c, "wp_fifo_manager_v1")
307+ f := h.c.AllocateID()
308+ registerProtocol(t, h.c, f)
309+ requestProtocol(t, h.c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, h.surf)
310+ tm := bindProtocol(t, h.c, "wp_commit_timing_manager_v1")
311+ timer := h.c.AllocateID()
312+ registerProtocol(t, h.c, timer)
313+ requestProtocol(t, h.c, tm, committiming.WpCommitTimingManagerV1RequestGetTimer, timer, h.surf)
314+
315+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestSetBarrier)
316+ h.commit(first, 1, 2)
317+ h.fire(1)
318+ one, ok := h.content(2 * time.Second)
319+ if !ok {
320+ t.Fatal("first update did not publish")
321+ }
322+ var now unix.Timespec
323+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
324+ t.Fatal(err)
325+ }
326+ target := time.Duration(now.Nano()) + 180*time.Millisecond
327+ sec := uint64(target / time.Second)
328+ requestProtocol(t, h.c, timer, committiming.WpCommitTimerV1RequestSetTimestamp, uint32(sec>>32), uint32(sec), uint32(target%time.Second))
329+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestWaitBarrier)
330+ h.commit(second, 3, 4)
331+ // Even with an acquire fence available, timestamp and barrier hold it.
332+ var waiting bool
333+ h.s.display.Do(func() {
334+ surf := h.s.windows[h.win].xdg.surface
335+ waiting = len(surf.queue) == 1 && surf.barrier
336+ })
337+ if !waiting {
338+ t.Fatal("update did not wait behind the FIFO barrier")
339+ }
340+ if _, ok := h.content(200 * time.Millisecond); ok {
341+ t.Fatal("update published before its acquire fence")
342+ }
343+ var gatedByAcquire bool
344+ h.s.display.Do(func() {
345+ surf := h.s.windows[h.win].xdg.surface
346+ gatedByAcquire = len(surf.queue) == 1 && !surf.barrier && !surf.tooEarly(surf.queue[0].at, time.Now()) && !surf.syncReady(surf.queue[0].sync)
347+ })
348+ if !gatedByAcquire {
349+ t.Fatal("FIFO/timing did not clear while acquire still waited")
350+ }
351+ h.fire(3)
352+ two, ok := h.content(2 * time.Second)
353+ if !ok || two.Seq <= one.Seq || two.DMABuf.ID == one.DMABuf.ID {
354+ t.Fatalf("ordered publication: first=%+v second=%+v", one, two)
355+ }
356+}
357+
298358 func TestSyncobjProtocolErrors(t *testing.T) {
299359 for _, tc := range []struct {
300360 name string
@@ -11,6 +11,8 @@ import (
11 11
12 "github.com/bnema/neferwl/internal/logging"12 "github.com/bnema/neferwl/internal/logging"
13 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
14+ "github.com/bnema/purego-libwayland/protocol/committiming"
15+ "github.com/bnema/purego-libwayland/protocol/fifo"
14 "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"16 "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"
15 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"17 "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
16 "github.com/bnema/purego-libwayland/protocol/wayland"18 "github.com/bnema/purego-libwayland/protocol/wayland"
@@ -295,6 +297,64 @@ func TestSyncobjAcquireAndRelease(t *testing.T) {
295 }297 }
296 }298 }
297 299
300+// FIFO, commit timing and acquire are independent gates on the same ordered
301+// update: clearing the first two cannot publish before its acquire fires.
302+func TestQueuedCommitReadinessGatesInOrder(t *testing.T) {
303+ h := newSyncHarness(t)
304+ h.releases = map[uint32]*syncReleaseProxy{}
305+ first, second := h.dmabuf(), h.dmabuf()
306+ fm := bindProtocol(t, h.c, "wp_fifo_manager_v1")
307+ f := h.c.AllocateID()
308+ registerProtocol(t, h.c, f)
309+ requestProtocol(t, h.c, fm, fifo.WpFifoManagerV1RequestGetFifo, f, h.surf)
310+ tm := bindProtocol(t, h.c, "wp_commit_timing_manager_v1")
311+ timer := h.c.AllocateID()
312+ registerProtocol(t, h.c, timer)
313+ requestProtocol(t, h.c, tm, committiming.WpCommitTimingManagerV1RequestGetTimer, timer, h.surf)
314+
315+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestSetBarrier)
316+ h.commit(first, 1, 2)
317+ h.fire(1)
318+ one, ok := h.content(2 * time.Second)
319+ if !ok {
320+ t.Fatal("first update did not publish")
321+ }
322+ var now unix.Timespec
323+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
324+ t.Fatal(err)
325+ }
326+ target := time.Duration(now.Nano()) + 180*time.Millisecond
327+ sec := uint64(target / time.Second)
328+ requestProtocol(t, h.c, timer, committiming.WpCommitTimerV1RequestSetTimestamp, uint32(sec>>32), uint32(sec), uint32(target%time.Second))
329+ requestProtocol(t, h.c, f, fifo.WpFifoV1RequestWaitBarrier)
330+ h.commit(second, 3, 4)
331+ // Even with an acquire fence available, timestamp and barrier hold it.
332+ var waiting bool
333+ h.s.display.Do(func() {
334+ surf := h.s.windows[h.win].xdg.surface
335+ waiting = len(surf.queue) == 1 && surf.barrier
336+ })
337+ if !waiting {
338+ t.Fatal("update did not wait behind the FIFO barrier")
339+ }
340+ if _, ok := h.content(200 * time.Millisecond); ok {
341+ t.Fatal("update published before its acquire fence")
342+ }
343+ var gatedByAcquire bool
344+ h.s.display.Do(func() {
345+ surf := h.s.windows[h.win].xdg.surface
346+ gatedByAcquire = len(surf.queue) == 1 && !surf.barrier && !surf.tooEarly(surf.queue[0].at, time.Now()) && !surf.syncReady(surf.queue[0].sync)
347+ })
348+ if !gatedByAcquire {
349+ t.Fatal("FIFO/timing did not clear while acquire still waited")
350+ }
351+ h.fire(3)
352+ two, ok := h.content(2 * time.Second)
353+ if !ok || two.Seq <= one.Seq || two.DMABuf.ID == one.DMABuf.ID {
354+ t.Fatalf("ordered publication: first=%+v second=%+v", one, two)
355+ }
356+}
357+
298 func TestSyncobjProtocolErrors(t *testing.T) {358 func TestSyncobjProtocolErrors(t *testing.T) {
299 for _, tc := range []struct {359 for _, tc := range []struct {
300 name string360 name string
M internal/app/app.go +3 -2
@@ -238,6 +238,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
238238
239239 go func() {
240240 defer workers.Done()
241+ outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, report: inventory, configs: backendConfig, applied: backendApplied}
241242 newRenderer := func(w, h int) (ports.Renderer, error) {
242243 r, err := vulkan.New(w, h)
243244 if err != nil {
@@ -252,11 +253,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
252253 w.Sampled = dmabuf.Formats
253254 return w
254255 }
255- return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm"))
256+ return hw.runOutputs(ctx, want, opts.Config, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
256257 })
257258 return
258259 }
259- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputFormats, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
260+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputIO, curs, newRenderer, logging.For(ctx, "render"))
260261 }()
261262
262263 if hw != nil {
@@ -238,6 +238,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
238 238
239 go func() {239 go func() {
240 defer workers.Done()240 defer workers.Done()
241+ outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, report: inventory, configs: backendConfig, applied: backendApplied}
241 newRenderer := func(w, h int) (ports.Renderer, error) {242 newRenderer := func(w, h int) (ports.Renderer, error) {
242 r, err := vulkan.New(w, h)243 r, err := vulkan.New(w, h)
243 if err != nil {244 if err != nil {
@@ -252,11 +253,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
252 w.Sampled = dmabuf.Formats253 w.Sampled = dmabuf.Formats
253 return w254 return w
254 }255 }
255- return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm"))256+ return hw.runOutputs(ctx, want, opts.Config, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
256 })257 })
257 return258 return
258 }259 }
259- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputFormats, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))260+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputIO, curs, newRenderer, logging.For(ctx, "render"))
260 }()261 }()
261 262
262 if hw != nil {263 if hw != nil {
M internal/app/drm.go +12 -19
@@ -135,13 +135,13 @@ func (b *drmBackend) close() {
135135 // one flip reader per card, and a udev watcher that rescans connectors on
136136 // hotplug. Core learns about outputs through events. It returns when ctx
137137 // ends, after every output is closed.
138-func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
138+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
139139 ctx, cancel := context.WithCancel(ctx)
140140 defer cancel()
141141 var readers sync.WaitGroup
142142 readerErr := make(chan error, len(b.cards))
143143 for _, c := range b.cards {
144- c.SetFormats(formats)
144+ c.SetFormats(ch.formats)
145145 readers.Go(func() {
146146 if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {
147147 readerErr <- fmt.Errorf("%s: %w", c.Path(), err)
@@ -155,11 +155,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
155155 log.Warn().Err(err).Msg("hotplug watch disabled")
156156 }
157157 }()
158- set := newOutputSet(ctx, captured)
158+ set := newOutputSet(ctx, ch.captured)
159159 cards := map[string]*drmCard{}
160160 send := func(ev ports.OutputEvent) {
161161 select {
162- case events <- ev:
162+ case ch.events <- ev:
163163 case <-ctx.Done():
164164 }
165165 }
@@ -190,7 +190,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
190190 timer = nil
191191 }
192192 select {
193- case applied <- d.err:
193+ case ch.applied <- d.err:
194194 case <-ctx.Done():
195195 }
196196 }
@@ -205,14 +205,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
205205 inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206206 }
207207 currentHeads = inventory
208- select {
209- case heads <- inventory:
210- case <-ctx.Done():
211- }
212- select {
213- case report <- inventory:
214- case <-ctx.Done():
215- }
208+ ch.sendInventory(ctx, inventory)
216209 }
217210 scan := func() {
218211 var scanErrors []error
@@ -280,7 +273,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
280273 defer b.seat.Unsubscribe(active)
281274 return safe("output "+name, func() error {
282275 defer o.Close()
283- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented, cap, captured)
276+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
284277 })
285278 })
286279 send(ports.OutputAdded{Info: o.Info()})
@@ -310,7 +303,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
310303 }
311304 complete(progress.scanned(running))
312305 }
313- case currentConfig = <-configs:
306+ case currentConfig = <-ch.configs:
314307 op++
315308 if timer != nil {
316309 timer.Stop()
@@ -350,13 +343,13 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
350343 complete(progress.readyEvent(result.name, result.source, result.err))
351344 case id := <-deadline:
352345 complete(progress.timeout(id))
353- case s := <-scenes:
346+ case s := <-ch.scenes:
354347 set.scenes(s)
355- case c := <-contents:
348+ case c := <-ch.contents:
356349 set.content(c)
357- case q := <-captures:
350+ case q := <-ch.captures:
358351 set.routeCapture(q)
359- case c := <-cursorChanges:
352+ case c := <-ch.cursorChanges:
360353 set.setCursor(c)
361354 case name := <-set.stopped:
362355 err := set.finish(name)
@@ -135,13 +135,13 @@ func (b *drmBackend) close() {
135 // one flip reader per card, and a udev watcher that rescans connectors on135 // one flip reader per card, and a udev watcher that rescans connectors on
136 // hotplug. Core learns about outputs through events. It returns when ctx136 // hotplug. Core learns about outputs through events. It returns when ctx
137 // ends, after every output is closed.137 // ends, after every output is closed.
138-func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {138+func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
139 ctx, cancel := context.WithCancel(ctx)139 ctx, cancel := context.WithCancel(ctx)
140 defer cancel()140 defer cancel()
141 var readers sync.WaitGroup141 var readers sync.WaitGroup
142 readerErr := make(chan error, len(b.cards))142 readerErr := make(chan error, len(b.cards))
143 for _, c := range b.cards {143 for _, c := range b.cards {
144- c.SetFormats(formats)144+ c.SetFormats(ch.formats)
145 readers.Go(func() {145 readers.Go(func() {
146 if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {146 if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {
147 readerErr <- fmt.Errorf("%s: %w", c.Path(), err)147 readerErr <- fmt.Errorf("%s: %w", c.Path(), err)
@@ -155,11 +155,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
155 log.Warn().Err(err).Msg("hotplug watch disabled")155 log.Warn().Err(err).Msg("hotplug watch disabled")
156 }156 }
157 }()157 }()
158- set := newOutputSet(ctx, captured)158+ set := newOutputSet(ctx, ch.captured)
159 cards := map[string]*drmCard{}159 cards := map[string]*drmCard{}
160 send := func(ev ports.OutputEvent) {160 send := func(ev ports.OutputEvent) {
161 select {161 select {
162- case events <- ev:162+ case ch.events <- ev:
163 case <-ctx.Done():163 case <-ctx.Done():
164 }164 }
165 }165 }
@@ -190,7 +190,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
190 timer = nil190 timer = nil
191 }191 }
192 select {192 select {
193- case applied <- d.err:193+ case ch.applied <- d.err:
194 case <-ctx.Done():194 case <-ctx.Done():
195 }195 }
196 }196 }
@@ -205,14 +205,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
205 inventory.Heads = append(inventory.Heads, lastHeads[card]...)205 inventory.Heads = append(inventory.Heads, lastHeads[card]...)
206 }206 }
207 currentHeads = inventory207 currentHeads = inventory
208- select {208+ ch.sendInventory(ctx, inventory)
209- case heads <- inventory:
210- case <-ctx.Done():
211- }
212- select {
213- case report <- inventory:
214- case <-ctx.Done():
215- }
216 }209 }
217 scan := func() {210 scan := func() {
218 var scanErrors []error211 var scanErrors []error
@@ -280,7 +273,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
280 defer b.seat.Unsubscribe(active)273 defer b.seat.Unsubscribe(active)
281 return safe("output "+name, func() error {274 return safe("output "+name, func() error {
282 defer o.Close()275 defer o.Close()
283- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented, cap, captured)276+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
284 })277 })
285 })278 })
286 send(ports.OutputAdded{Info: o.Info()})279 send(ports.OutputAdded{Info: o.Info()})
@@ -310,7 +303,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
310 }303 }
311 complete(progress.scanned(running))304 complete(progress.scanned(running))
312 }305 }
313- case currentConfig = <-configs:306+ case currentConfig = <-ch.configs:
314 op++307 op++
315 if timer != nil {308 if timer != nil {
316 timer.Stop()309 timer.Stop()
@@ -350,13 +343,13 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
350 complete(progress.readyEvent(result.name, result.source, result.err))343 complete(progress.readyEvent(result.name, result.source, result.err))
351 case id := <-deadline:344 case id := <-deadline:
352 complete(progress.timeout(id))345 complete(progress.timeout(id))
353- case s := <-scenes:346+ case s := <-ch.scenes:
354 set.scenes(s)347 set.scenes(s)
355- case c := <-contents:348+ case c := <-ch.contents:
356 set.content(c)349 set.content(c)
357- case q := <-captures:350+ case q := <-ch.captures:
358 set.routeCapture(q)351 set.routeCapture(q)
359- case c := <-cursorChanges:352+ case c := <-ch.cursorChanges:
360 set.setCursor(c)353 set.setCursor(c)
361 case name := <-set.stopped:354 case name := <-set.stopped:
362 err := set.finish(name)355 err := set.finish(name)
M internal/app/headless.go +9 -16
@@ -13,8 +13,8 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, formats chan<- ports.OutputFormats, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17- set := newOutputSet(ctx, captured)
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17+ set := newOutputSet(ctx, ch.captured)
1818 inventory := ports.OutputHeads{}
1919 for i, size := range sizes {
2020 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -30,24 +30,17 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, fo
3030 }
3131 cur := &headless.Cursor{}
3232 curs.set(name, cur)
33- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
33+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
3434 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
3535 return headless.Run(octx, opts, sc, cc, cu, cap)
3636 })
3737 select {
38- case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
38+ case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
3939 case <-ctx.Done():
4040 return set.wait()
4141 }
4242 }
43- select {
44- case heads <- inventory:
45- case <-ctx.Done():
46- return set.wait()
47- }
48- select {
49- case report <- inventory:
50- case <-ctx.Done():
43+ if !ch.sendInventory(ctx, inventory) {
5144 return set.wait()
5245 }
5346 // The pointer starts centred on the first output, like libinput's.
@@ -56,13 +49,13 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, fo
5649 select {
5750 case <-ctx.Done():
5851 return set.wait()
59- case s := <-scenes:
52+ case s := <-ch.scenes:
6053 set.scenes(s)
61- case c := <-contents:
54+ case c := <-ch.contents:
6255 set.content(c)
63- case q := <-captures:
56+ case q := <-ch.captures:
6457 set.routeCapture(q)
65- case c := <-cursorChanges:
58+ case c := <-ch.cursorChanges:
6659 set.setCursor(c)
6760 case name := <-set.stopped:
6861 // A headless output only stops on error: the run ends.
@@ -13,8 +13,8 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, formats chan<- ports.OutputFormats, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17- set := newOutputSet(ctx, captured)17+ set := newOutputSet(ctx, ch.captured)
18 inventory := ports.OutputHeads{}18 inventory := ports.OutputHeads{}
19 for i, size := range sizes {19 for i, size := range sizes {
20 name := fmt.Sprintf("HEADLESS-%d", i+1)20 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -30,24 +30,17 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, fo
30 }30 }
31 cur := &headless.Cursor{}31 cur := &headless.Cursor{}
32 curs.set(name, cur)32 curs.set(name, cur)
33- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}33+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
34 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {34 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
35 return headless.Run(octx, opts, sc, cc, cu, cap)35 return headless.Run(octx, opts, sc, cc, cu, cap)
36 })36 })
37 select {37 select {
38- case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:38+ case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
39 case <-ctx.Done():39 case <-ctx.Done():
40 return set.wait()40 return set.wait()
41 }41 }
42 }42 }
43- select {43+ if !ch.sendInventory(ctx, inventory) {
44- case heads <- inventory:
45- case <-ctx.Done():
46- return set.wait()
47- }
48- select {
49- case report <- inventory:
50- case <-ctx.Done():
51 return set.wait()44 return set.wait()
52 }45 }
53 // The pointer starts centred on the first output, like libinput's.46 // The pointer starts centred on the first output, like libinput's.
@@ -56,13 +49,13 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, fo
56 select {49 select {
57 case <-ctx.Done():50 case <-ctx.Done():
58 return set.wait()51 return set.wait()
59- case s := <-scenes:52+ case s := <-ch.scenes:
60 set.scenes(s)53 set.scenes(s)
61- case c := <-contents:54+ case c := <-ch.contents:
62 set.content(c)55 set.content(c)
63- case q := <-captures:56+ case q := <-ch.captures:
64 set.routeCapture(q)57 set.routeCapture(q)
65- case c := <-cursorChanges:58+ case c := <-ch.cursorChanges:
66 set.setCursor(c)59 set.setCursor(c)
67 case name := <-set.stopped:60 case name := <-set.stopped:
68 // A headless output only stops on error: the run ends.61 // A headless output only stops on error: the run ends.
M internal/app/outputmanagement.go +0 -3
@@ -111,9 +111,6 @@ func (o *outputOverrides) validate(req ports.OutputApply) error {
111111 return fmt.Errorf("unknown head %s", change.Name)
112112 }
113113 current := o.heads.Heads[index]
114- if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode {
115- return fmt.Errorf("unsupported output setting on %s", change.Name)
116- }
117114 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
118115 return fmt.Errorf("invalid scale on %s", change.Name)
119116 }
@@ -111,9 +111,6 @@ func (o *outputOverrides) validate(req ports.OutputApply) error {
111 return fmt.Errorf("unknown head %s", change.Name)111 return fmt.Errorf("unknown head %s", change.Name)
112 }112 }
113 current := o.heads.Heads[index]113 current := o.heads.Heads[index]
114- if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode {
115- return fmt.Errorf("unsupported output setting on %s", change.Name)
116- }
117 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {114 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
118 return fmt.Errorf("invalid scale on %s", change.Name)115 return fmt.Errorf("invalid scale on %s", change.Name)
119 }116 }
A internal/app/outputrouting.go +40 -0
@@ -0,0 +1,40 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// outputChannels is the shared app-to-backend wiring. The backend loop remains
10+// the sole owner of outputSet; routing does not introduce a forwarding queue.
11+type outputChannels struct {
12+ events chan<- ports.OutputEvent
13+ scenes <-chan []ports.Scene
14+ contents <-chan ports.SurfaceContent
15+ cursorChanges <-chan ports.CursorChange
16+ presented chan<- ports.OutputPresented
17+ captures <-chan ports.CaptureRequest
18+ captured chan<- ports.CaptureDone
19+ formats chan<- ports.OutputFormats
20+ heads chan<- ports.OutputHeads
21+ report chan<- ports.OutputHeads
22+ configs <-chan ports.Config
23+ applied chan<- error
24+}
25+
26+// sendInventory preserves heads-before-report delivery; false means the
27+// context interrupted either send.
28+func (ch outputChannels) sendInventory(ctx context.Context, inventory ports.OutputHeads) bool {
29+ select {
30+ case ch.heads <- inventory:
31+ case <-ctx.Done():
32+ return false
33+ }
34+ select {
35+ case ch.report <- inventory:
36+ return true
37+ case <-ctx.Done():
38+ return false
39+ }
40+}
@@ -0,0 +1,40 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// outputChannels is the shared app-to-backend wiring. The backend loop remains
10+// the sole owner of outputSet; routing does not introduce a forwarding queue.
11+type outputChannels struct {
12+ events chan<- ports.OutputEvent
13+ scenes <-chan []ports.Scene
14+ contents <-chan ports.SurfaceContent
15+ cursorChanges <-chan ports.CursorChange
16+ presented chan<- ports.OutputPresented
17+ captures <-chan ports.CaptureRequest
18+ captured chan<- ports.CaptureDone
19+ formats chan<- ports.OutputFormats
20+ heads chan<- ports.OutputHeads
21+ report chan<- ports.OutputHeads
22+ configs <-chan ports.Config
23+ applied chan<- error
24+}
25+
26+// sendInventory preserves heads-before-report delivery; false means the
27+// context interrupted either send.
28+func (ch outputChannels) sendInventory(ctx context.Context, inventory ports.OutputHeads) bool {
29+ select {
30+ case ch.heads <- inventory:
31+ case <-ctx.Done():
32+ return false
33+ }
34+ select {
35+ case ch.report <- inventory:
36+ return true
37+ case <-ctx.Done():
38+ return false
39+ }
40+}
A internal/app/outputrouting_test.go +101 -0
@@ -0,0 +1,101 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestOutputRouting(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ defer cancel()
14+ replies := make(chan ports.CaptureDone, 2)
15+ set := newOutputSet(ctx, replies)
16+ for _, name := range []string{"A", "B"} {
17+ set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
18+ <-ctx.Done()
19+ return nil
20+ })
21+ <-set.outs[name].cursor // initial cursor
22+ }
23+ set.scenes([]ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}})
24+ set.scenes([]ports.Scene{{Output: "A", Seq: 3}})
25+ if got := <-set.outs["A"].scenes; got.Seq != 3 {
26+ t.Fatalf("A scene: %+v", got)
27+ }
28+ if got := <-set.outs["B"].scenes; got.Seq != 2 {
29+ t.Fatalf("B scene: %+v", got)
30+ }
31+ content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
32+ set.content(content)
33+ for _, name := range []string{"A", "B"} {
34+ if got := <-set.outs[name].contents; got.ID != content.ID {
35+ t.Fatalf("%s content: %+v", name, got)
36+ }
37+ }
38+ set.setCursor(ports.CursorChange{Shape: "text"})
39+ set.setCursor(ports.CursorChange{Shape: "pointer"})
40+ for _, name := range []string{"A", "B"} {
41+ if got := <-set.outs[name].cursor; got.Shape != "pointer" {
42+ t.Fatalf("%s cursor: %+v", name, got)
43+ }
44+ }
45+ f, err := os.CreateTemp(t.TempDir(), "capture")
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ set.routeCapture(ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}})
50+ if got := <-set.outs["B"].captures; got.ID != 7 {
51+ t.Fatalf("B capture: %+v", got)
52+ }
53+ if err := f.Close(); err != nil {
54+ t.Fatal(err)
55+ }
56+ set.outs["A"].stop()
57+ if err := set.finish("A"); err != nil {
58+ t.Fatal(err)
59+ }
60+ set.scenes([]ports.Scene{{Output: "A", Seq: 4}})
61+ if _, ok := set.outs["A"]; ok {
62+ t.Fatal("removed output retained")
63+ }
64+ set.routeCapture(ports.CaptureRequest{ID: 8, Output: "A"})
65+ if got := <-replies; got.ID != 8 || got.Err == nil {
66+ t.Fatalf("removed output capture: %+v", got)
67+ }
68+ if err := set.wait(); err != nil {
69+ t.Fatal(err)
70+ }
71+}
72+
73+func TestOutputRoutingAllocations(t *testing.T) {
74+ set := newOutputSet(context.Background(), nil)
75+ // Populate the existing content slot before measuring, so the guard
76+ // measures routing rather than first-use map growth.
77+ content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
78+ set.content(content)
79+ ctx, cancel := context.WithCancel(context.Background())
80+ defer cancel()
81+ set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
82+ <-ctx.Done()
83+ return nil
84+ })
85+ scenes := []ports.Scene{{Output: "A"}}
86+ cursor := ports.CursorChange{Shape: "pointer"}
87+ capture := ports.CaptureRequest{Output: "A"}
88+ if got := testing.AllocsPerRun(100, func() {
89+ set.scenes(scenes)
90+ <-set.outs["A"].contents
91+ set.content(content)
92+ set.setCursor(cursor)
93+ set.routeCapture(capture)
94+ <-set.outs["A"].captures
95+ }); got != 0 {
96+ t.Fatalf("routing: %g allocations per run, want 0", got)
97+ }
98+ if err := set.wait(); err != nil {
99+ t.Fatal(err)
100+ }
101+}
@@ -0,0 +1,101 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestOutputRouting(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ defer cancel()
14+ replies := make(chan ports.CaptureDone, 2)
15+ set := newOutputSet(ctx, replies)
16+ for _, name := range []string{"A", "B"} {
17+ set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
18+ <-ctx.Done()
19+ return nil
20+ })
21+ <-set.outs[name].cursor // initial cursor
22+ }
23+ set.scenes([]ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}})
24+ set.scenes([]ports.Scene{{Output: "A", Seq: 3}})
25+ if got := <-set.outs["A"].scenes; got.Seq != 3 {
26+ t.Fatalf("A scene: %+v", got)
27+ }
28+ if got := <-set.outs["B"].scenes; got.Seq != 2 {
29+ t.Fatalf("B scene: %+v", got)
30+ }
31+ content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
32+ set.content(content)
33+ for _, name := range []string{"A", "B"} {
34+ if got := <-set.outs[name].contents; got.ID != content.ID {
35+ t.Fatalf("%s content: %+v", name, got)
36+ }
37+ }
38+ set.setCursor(ports.CursorChange{Shape: "text"})
39+ set.setCursor(ports.CursorChange{Shape: "pointer"})
40+ for _, name := range []string{"A", "B"} {
41+ if got := <-set.outs[name].cursor; got.Shape != "pointer" {
42+ t.Fatalf("%s cursor: %+v", name, got)
43+ }
44+ }
45+ f, err := os.CreateTemp(t.TempDir(), "capture")
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ set.routeCapture(ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}})
50+ if got := <-set.outs["B"].captures; got.ID != 7 {
51+ t.Fatalf("B capture: %+v", got)
52+ }
53+ if err := f.Close(); err != nil {
54+ t.Fatal(err)
55+ }
56+ set.outs["A"].stop()
57+ if err := set.finish("A"); err != nil {
58+ t.Fatal(err)
59+ }
60+ set.scenes([]ports.Scene{{Output: "A", Seq: 4}})
61+ if _, ok := set.outs["A"]; ok {
62+ t.Fatal("removed output retained")
63+ }
64+ set.routeCapture(ports.CaptureRequest{ID: 8, Output: "A"})
65+ if got := <-replies; got.ID != 8 || got.Err == nil {
66+ t.Fatalf("removed output capture: %+v", got)
67+ }
68+ if err := set.wait(); err != nil {
69+ t.Fatal(err)
70+ }
71+}
72+
73+func TestOutputRoutingAllocations(t *testing.T) {
74+ set := newOutputSet(context.Background(), nil)
75+ // Populate the existing content slot before measuring, so the guard
76+ // measures routing rather than first-use map growth.
77+ content := ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{}}
78+ set.content(content)
79+ ctx, cancel := context.WithCancel(context.Background())
80+ defer cancel()
81+ set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
82+ <-ctx.Done()
83+ return nil
84+ })
85+ scenes := []ports.Scene{{Output: "A"}}
86+ cursor := ports.CursorChange{Shape: "pointer"}
87+ capture := ports.CaptureRequest{Output: "A"}
88+ if got := testing.AllocsPerRun(100, func() {
89+ set.scenes(scenes)
90+ <-set.outs["A"].contents
91+ set.content(content)
92+ set.setCursor(cursor)
93+ set.routeCapture(capture)
94+ <-set.outs["A"].captures
95+ }); got != 0 {
96+ t.Fatalf("routing: %g allocations per run, want 0", got)
97+ }
98+ if err := set.wait(); err != nil {
99+ t.Fatal(err)
100+ }
101+}
M internal/core/core.go +42 -33
@@ -74,12 +74,10 @@ type Core struct {
7474 specs []NamedWorkspace
7575 presets []Width
7676 slots map[slotKey]*slotState
77+ placement spawnPlacement
7778 // toSpawn holds slots to start; Run sends them (apply has no context).
78- toSpawn []slotKey
79- sentPending bool
80- // terms are the terminals spawned for empty workspaces, by SlotEnv
81- // token, until their window maps.
82- terms map[string]*termSpawn
79+ toSpawn []slotKey
80+ sentPending bool
8381 firstTerminalResolved bool
8482 // clients holds the app ID and PID of mapped windows, for State.
8583 clients map[WindowID]ports.WindowMapped
@@ -243,7 +241,6 @@ func (c *Core) apply(cfg ports.Config) error {
243241 for _, s := range c.screens {
244242 c.settings(s.mon)
245243 }
246- c.settleGuests()
247244 c.applyConfigScales()
248245 return nil
249246 }
@@ -251,7 +248,7 @@ func (c *Core) apply(cfg ports.Config) error {
251248 // publishPending tells wayland at once when slots start waiting, before
252249 // their windows can map.
253250 func (c *Core) publishPending(ctx context.Context) error {
254- if p := c.anyPending(); p != c.sentPending {
251+ if p := c.placement.anyPending(); p != c.sentPending {
255252 if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
256253 return err
257254 }
@@ -295,7 +292,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
295292 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
296293 }
297294 // A placeholder screen holds windows until the first output arrives.
298- c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
295+ c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
299296 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
300297 if err := c.apply(cfg); err != nil {
301298 return nil, err
@@ -406,16 +403,12 @@ func (c *Core) publish(ctx context.Context) error {
406403 }
407404 c.sentOutputs = v
408405 }
409- terms := c.fillEmpty()
410- if err := c.publishPending(ctx); err != nil {
406+ // Output layout must be known before an automatic terminal can map.
407+ if err := c.spawnEmpty(ctx); err != nil {
411408 return err
412409 }
413- for _, req := range terms {
414- select {
415- case <-ctx.Done():
416- return ctx.Err()
417- case c.ch.Spawn <- req:
418- }
410+ if err := c.publishPending(ctx); err != nil {
411+ return err
419412 }
420413 alive := map[WindowID]bool{}
421414 focus := c.keyboardFocus()
@@ -675,15 +668,7 @@ func (c *Core) Run(ctx context.Context) error {
675668 c.inputRegions[v.ID] = v
676669 case ports.WindowMapped:
677670 c.clients[v.ID] = v
678- if v.Floating {
679- if s, _ := c.screenOf(v.ID); s == nil {
680- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
681- }
682- } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
683- if s, _ := c.screenOf(v.ID); s == nil {
684- c.cur().mon.AddWindow(v.ID)
685- }
686- }
671+ c.placement.place(c, v)
687672 case ports.WindowResized:
688673 if _, w := c.screenOf(v.ID); w != nil {
689674 w.ResizeFloating(v.ID, v.Width, v.Height)
@@ -753,6 +738,7 @@ func (c *Core) Run(ctx context.Context) error {
753738 s.mon.SetFullscreen(v.ID, v.Fullscreen)
754739 }
755740 case ports.WorkspaceActivate:
741+ before := c.cur().mon.Current()
756742 for _, id := range v.IDs {
757743 for i, sc := range c.screens {
758744 if sc.name() == "" {
@@ -767,6 +753,9 @@ func (c *Core) Run(ctx context.Context) error {
767753 }
768754 }
769755 }
756+ if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
757+ return nil
758+ }
770759 case ports.WindowActivate:
771760 if c.activate(ctx, v.ID) != nil {
772761 return nil
@@ -793,6 +782,9 @@ func (c *Core) Run(ctx context.Context) error {
793782 }
794783 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
795784 }
785+ if c.workspaceVisible(ctx, false) != nil {
786+ return nil
787+ }
796788 if err := c.publish(ctx); err != nil {
797789 return nil
798790 }
@@ -808,7 +800,7 @@ func (c *Core) Run(ctx context.Context) error {
808800 }
809801 continue
810802 }
811- if c.spawnSlots(ctx, false) != nil {
803+ if c.workspaceVisible(ctx, false) != nil {
812804 return nil
813805 }
814806 if err := c.publish(ctx); err != nil {
@@ -995,7 +987,7 @@ func (c *Core) Run(ctx context.Context) error {
995987 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
996988 }
997989 }
998- if c.afterShow(ctx, before) != nil {
990+ if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
999991 return nil
1000992 }
1001993 if effect.Close != 0 {
@@ -1019,13 +1011,30 @@ func (c *Core) Run(ctx context.Context) error {
10191011 }
10201012 }
10211013
1022-// afterShow keeps slots and guests right once a workspace came on screen;
1023-// before is the focused screen's workspace before the change.
1024-func (c *Core) afterShow(ctx context.Context, before *Workspace) error {
1025- // Showing a declared workspace refills its empty slots.
1014+// workspaceVisible settles guests and refills slots on a show. Publication
1015+// subsequently decides whether visible empty workspaces need terminals.
1016+func (c *Core) workspaceVisible(ctx context.Context, shown bool) error {
10261017 c.releaseSlots()
10271018 c.settleGuests()
1028- return c.spawnSlots(ctx, c.cur().mon.Current() != before)
1019+ return c.spawnSlots(ctx, shown)
1020+}
1021+
1022+// spawnEmpty requests terminals for visible empty workspaces. publish calls
1023+// it after the output layout and before scenes, so a terminal never maps
1024+// before wayland knows its output.
1025+func (c *Core) spawnEmpty(ctx context.Context) error {
1026+ terms := c.fillEmpty()
1027+ if err := c.publishPending(ctx); err != nil {
1028+ return err
1029+ }
1030+ for _, req := range terms {
1031+ select {
1032+ case <-ctx.Done():
1033+ return ctx.Err()
1034+ case c.ch.Spawn <- req:
1035+ }
1036+ }
1037+ return nil
10291038 }
10301039
10311040 // activate brings a window forward for a valid xdg-activation token. The
@@ -1043,7 +1052,7 @@ func (c *Core) activate(ctx context.Context, id WindowID) error {
10431052 }
10441053 w.Activate(id)
10451054 c.focusScreen = c.screenIndex(s.name())
1046- return c.afterShow(ctx, before)
1055+ return c.workspaceVisible(ctx, c.cur().mon.Current() != before)
10471056 }
10481057
10491058 // heldKey names a key for press tracking: by physical key, since Shift
@@ -74,12 +74,10 @@ type Core struct {
74 specs []NamedWorkspace74 specs []NamedWorkspace
75 presets []Width75 presets []Width
76 slots map[slotKey]*slotState76 slots map[slotKey]*slotState
77+ placement spawnPlacement
77 // toSpawn holds slots to start; Run sends them (apply has no context).78 // toSpawn holds slots to start; Run sends them (apply has no context).
78- toSpawn []slotKey79+ toSpawn []slotKey
79- sentPending bool80+ sentPending bool
80- // terms are the terminals spawned for empty workspaces, by SlotEnv
81- // token, until their window maps.
82- terms map[string]*termSpawn
83 firstTerminalResolved bool81 firstTerminalResolved bool
84 // clients holds the app ID and PID of mapped windows, for State.82 // clients holds the app ID and PID of mapped windows, for State.
85 clients map[WindowID]ports.WindowMapped83 clients map[WindowID]ports.WindowMapped
@@ -243,7 +241,6 @@ func (c *Core) apply(cfg ports.Config) error {
243 for _, s := range c.screens {241 for _, s := range c.screens {
244 c.settings(s.mon)242 c.settings(s.mon)
245 }243 }
246- c.settleGuests()
247 c.applyConfigScales()244 c.applyConfigScales()
248 return nil245 return nil
249 }246 }
@@ -251,7 +248,7 @@ func (c *Core) apply(cfg ports.Config) error {
251 // publishPending tells wayland at once when slots start waiting, before248 // publishPending tells wayland at once when slots start waiting, before
252 // their windows can map.249 // their windows can map.
253 func (c *Core) publishPending(ctx context.Context) error {250 func (c *Core) publishPending(ctx context.Context) error {
254- if p := c.anyPending(); p != c.sentPending {251+ if p := c.placement.anyPending(); p != c.sentPending {
255 if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {252 if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
256 return err253 return err
257 }254 }
@@ -295,7 +292,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
295 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")292 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
296 }293 }
297 // A placeholder screen holds windows until the first output arrives.294 // A placeholder screen holds windows until the first output arrives.
298- c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}295+ c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
299 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}296 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
300 if err := c.apply(cfg); err != nil {297 if err := c.apply(cfg); err != nil {
301 return nil, err298 return nil, err
@@ -406,16 +403,12 @@ func (c *Core) publish(ctx context.Context) error {
406 }403 }
407 c.sentOutputs = v404 c.sentOutputs = v
408 }405 }
409- terms := c.fillEmpty()406+ // Output layout must be known before an automatic terminal can map.
410- if err := c.publishPending(ctx); err != nil {407+ if err := c.spawnEmpty(ctx); err != nil {
411 return err408 return err
412 }409 }
413- for _, req := range terms {410+ if err := c.publishPending(ctx); err != nil {
414- select {411+ return err
415- case <-ctx.Done():
416- return ctx.Err()
417- case c.ch.Spawn <- req:
418- }
419 }412 }
420 alive := map[WindowID]bool{}413 alive := map[WindowID]bool{}
421 focus := c.keyboardFocus()414 focus := c.keyboardFocus()
@@ -675,15 +668,7 @@ func (c *Core) Run(ctx context.Context) error {
675 c.inputRegions[v.ID] = v668 c.inputRegions[v.ID] = v
676 case ports.WindowMapped:669 case ports.WindowMapped:
677 c.clients[v.ID] = v670 c.clients[v.ID] = v
678- if v.Floating {671+ c.placement.place(c, v)
679- if s, _ := c.screenOf(v.ID); s == nil {
680- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
681- }
682- } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
683- if s, _ := c.screenOf(v.ID); s == nil {
684- c.cur().mon.AddWindow(v.ID)
685- }
686- }
687 case ports.WindowResized:672 case ports.WindowResized:
688 if _, w := c.screenOf(v.ID); w != nil {673 if _, w := c.screenOf(v.ID); w != nil {
689 w.ResizeFloating(v.ID, v.Width, v.Height)674 w.ResizeFloating(v.ID, v.Width, v.Height)
@@ -753,6 +738,7 @@ func (c *Core) Run(ctx context.Context) error {
753 s.mon.SetFullscreen(v.ID, v.Fullscreen)738 s.mon.SetFullscreen(v.ID, v.Fullscreen)
754 }739 }
755 case ports.WorkspaceActivate:740 case ports.WorkspaceActivate:
741+ before := c.cur().mon.Current()
756 for _, id := range v.IDs {742 for _, id := range v.IDs {
757 for i, sc := range c.screens {743 for i, sc := range c.screens {
758 if sc.name() == "" {744 if sc.name() == "" {
@@ -767,6 +753,9 @@ func (c *Core) Run(ctx context.Context) error {
767 }753 }
768 }754 }
769 }755 }
756+ if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
757+ return nil
758+ }
770 case ports.WindowActivate:759 case ports.WindowActivate:
771 if c.activate(ctx, v.ID) != nil {760 if c.activate(ctx, v.ID) != nil {
772 return nil761 return nil
@@ -793,6 +782,9 @@ func (c *Core) Run(ctx context.Context) error {
793 }782 }
794 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)783 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
795 }784 }
785+ if c.workspaceVisible(ctx, false) != nil {
786+ return nil
787+ }
796 if err := c.publish(ctx); err != nil {788 if err := c.publish(ctx); err != nil {
797 return nil789 return nil
798 }790 }
@@ -808,7 +800,7 @@ func (c *Core) Run(ctx context.Context) error {
808 }800 }
809 continue801 continue
810 }802 }
811- if c.spawnSlots(ctx, false) != nil {803+ if c.workspaceVisible(ctx, false) != nil {
812 return nil804 return nil
813 }805 }
814 if err := c.publish(ctx); err != nil {806 if err := c.publish(ctx); err != nil {
@@ -995,7 +987,7 @@ func (c *Core) Run(ctx context.Context) error {
995 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:987 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
996 }988 }
997 }989 }
998- if c.afterShow(ctx, before) != nil {990+ if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
999 return nil991 return nil
1000 }992 }
1001 if effect.Close != 0 {993 if effect.Close != 0 {
@@ -1019,13 +1011,30 @@ func (c *Core) Run(ctx context.Context) error {
1019 }1011 }
1020 }1012 }
1021 1013
1022-// afterShow keeps slots and guests right once a workspace came on screen;1014+// workspaceVisible settles guests and refills slots on a show. Publication
1023-// before is the focused screen's workspace before the change.1015+// subsequently decides whether visible empty workspaces need terminals.
1024-func (c *Core) afterShow(ctx context.Context, before *Workspace) error {1016+func (c *Core) workspaceVisible(ctx context.Context, shown bool) error {
1025- // Showing a declared workspace refills its empty slots.
1026 c.releaseSlots()1017 c.releaseSlots()
1027 c.settleGuests()1018 c.settleGuests()
1028- return c.spawnSlots(ctx, c.cur().mon.Current() != before)1019+ return c.spawnSlots(ctx, shown)
1020+}
1021+
1022+// spawnEmpty requests terminals for visible empty workspaces. publish calls
1023+// it after the output layout and before scenes, so a terminal never maps
1024+// before wayland knows its output.
1025+func (c *Core) spawnEmpty(ctx context.Context) error {
1026+ terms := c.fillEmpty()
1027+ if err := c.publishPending(ctx); err != nil {
1028+ return err
1029+ }
1030+ for _, req := range terms {
1031+ select {
1032+ case <-ctx.Done():
1033+ return ctx.Err()
1034+ case c.ch.Spawn <- req:
1035+ }
1036+ }
1037+ return nil
1029 }1038 }
1030 1039
1031 // activate brings a window forward for a valid xdg-activation token. The1040 // activate brings a window forward for a valid xdg-activation token. The
@@ -1043,7 +1052,7 @@ func (c *Core) activate(ctx context.Context, id WindowID) error {
1043 }1052 }
1044 w.Activate(id)1053 w.Activate(id)
1045 c.focusScreen = c.screenIndex(s.name())1054 c.focusScreen = c.screenIndex(s.name())
1046- return c.afterShow(ctx, before)1055+ return c.workspaceVisible(ctx, c.cur().mon.Current() != before)
1047 }1056 }
1048 1057
1049 // heldKey names a key for press tracking: by physical key, since Shift1058 // heldKey names a key for press tracking: by physical key, since Shift
M internal/core/screens.go +0 -1
@@ -201,7 +201,6 @@ func (c *Core) addScreen(info ports.OutputInfo) {
201201 c.focusScreen = slices.Index(c.screens, s)
202202 }
203203 c.named()
204- c.settleGuests()
205204 }
206205
207206 // removeScreen moves every workspace of an unplugged output to the focused
@@ -201,7 +201,6 @@ func (c *Core) addScreen(info ports.OutputInfo) {
201 c.focusScreen = slices.Index(c.screens, s)201 c.focusScreen = slices.Index(c.screens, s)
202 }202 }
203 c.named()203 c.named()
204- c.settleGuests()
205 }204 }
206 205
207 // removeScreen moves every workspace of an unplugged output to the focused206 // removeScreen moves every workspace of an unplugged output to the focused
M internal/core/screens_test.go +68 -0
@@ -6,6 +6,7 @@ import (
66 "reflect"
77 "slices"
88 "testing"
9+ "time"
910
1011 "github.com/bnema/neferwl/internal/adapters/config"
1112 "github.com/bnema/neferwl/internal/core"
@@ -490,6 +491,43 @@ func TestPointerFocusesOutput(t *testing.T) {
490491 }
491492 }
492493
494+// The first terminal cannot launch before wayland receives its output layout.
495+func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496+ cfg := config.Defaults()
497+ commands := make(chan ports.ClientCommand)
498+ spawn := make(chan ports.SpawnRequest)
499+ output := make(chan ports.OutputEvent, 1)
500+ scenes := make(chan []ports.Scene, 1)
501+ state := make(chan ports.State, 1)
502+ c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})
503+ if err != nil {
504+ t.Fatal(err)
505+ }
506+ ctx, cancel := context.WithCancel(context.Background())
507+ done := make(chan error, 1)
508+ go func() { done <- c.Run(ctx) }()
509+ t.Cleanup(func() { cancel(); receive(t, done) })
510+ receive(t, state) // startup finished; next command belongs to the output event
511+ output <- ports.OutputAdded{Info: left}
512+ select {
513+ case v := <-commands:
514+ if o, ok := v.(ports.SetOutputs); !ok || len(o.Outputs) != 1 || o.Outputs[0].Info.Name != left.Name {
515+ t.Fatalf("first command: %v", v)
516+ }
517+ case req := <-spawn:
518+ t.Fatalf("terminal spawned before SetOutputs: %v", req)
519+ case <-time.After(time.Second):
520+ t.Fatal("no output command or spawn")
521+ }
522+ if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
523+ t.Fatalf("expected pending before spawn, got %v", v)
524+ }
525+ if req := receive(t, spawn); len(req.Argv) == 0 {
526+ t.Fatal(req)
527+ }
528+ receive(t, scenes)
529+}
530+
493531 func TestTerminalAutoOpenFirstOnly(t *testing.T) {
494532 r := startRig(t, true, nil, left)
495533 first := receive(t, r.spawn)
@@ -786,3 +824,33 @@ func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
786824 }
787825 }
788826 }
827+
828+func TestTerminalTokenClaimedOnce(t *testing.T) {
829+ // Plug DP-1 first so its request is unambiguously first. Then plug
830+ // DP-2 and focus it before either window maps.
831+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
832+ first := receive(t, r.spawn)
833+ r.plug(t, right)
834+ second := receive(t, r.spawn)
835+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
836+ r.client <- ports.WindowMapped{ID: 41, Slot: token(t, first), Floating: true, Width: 20, Height: 20}
837+ receive(t, r.scenes)
838+ r.client <- ports.WindowMapped{ID: 42, Slot: token(t, first)}
839+ set := receive(t, r.scenes)
840+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
841+ t.Fatalf("first claim: %v", got)
842+ }
843+ // Move back to DP-1: the duplicate must follow focus, not the token.
844+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
845+ r.client <- ports.WindowMapped{ID: 43, Slot: token(t, first)}
846+ set = receive(t, r.scenes)
847+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
848+ t.Fatalf("duplicate claim: %v", got)
849+ }
850+ // The other terminal's token remains available despite the first claim.
851+ r.client <- ports.WindowMapped{ID: 44, Slot: token(t, second)}
852+ set = receive(t, r.scenes)
853+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{44, 41}) {
854+ t.Fatalf("second claim: %v", got)
855+ }
856+}
@@ -6,6 +6,7 @@ import (
6 "reflect"6 "reflect"
7 "slices"7 "slices"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/bnema/neferwl/internal/adapters/config"11 "github.com/bnema/neferwl/internal/adapters/config"
11 "github.com/bnema/neferwl/internal/core"12 "github.com/bnema/neferwl/internal/core"
@@ -490,6 +491,43 @@ func TestPointerFocusesOutput(t *testing.T) {
490 }491 }
491 }492 }
492 493
494+// The first terminal cannot launch before wayland receives its output layout.
495+func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496+ cfg := config.Defaults()
497+ commands := make(chan ports.ClientCommand)
498+ spawn := make(chan ports.SpawnRequest)
499+ output := make(chan ports.OutputEvent, 1)
500+ scenes := make(chan []ports.Scene, 1)
501+ state := make(chan ports.State, 1)
502+ c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})
503+ if err != nil {
504+ t.Fatal(err)
505+ }
506+ ctx, cancel := context.WithCancel(context.Background())
507+ done := make(chan error, 1)
508+ go func() { done <- c.Run(ctx) }()
509+ t.Cleanup(func() { cancel(); receive(t, done) })
510+ receive(t, state) // startup finished; next command belongs to the output event
511+ output <- ports.OutputAdded{Info: left}
512+ select {
513+ case v := <-commands:
514+ if o, ok := v.(ports.SetOutputs); !ok || len(o.Outputs) != 1 || o.Outputs[0].Info.Name != left.Name {
515+ t.Fatalf("first command: %v", v)
516+ }
517+ case req := <-spawn:
518+ t.Fatalf("terminal spawned before SetOutputs: %v", req)
519+ case <-time.After(time.Second):
520+ t.Fatal("no output command or spawn")
521+ }
522+ if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
523+ t.Fatalf("expected pending before spawn, got %v", v)
524+ }
525+ if req := receive(t, spawn); len(req.Argv) == 0 {
526+ t.Fatal(req)
527+ }
528+ receive(t, scenes)
529+}
530+
493 func TestTerminalAutoOpenFirstOnly(t *testing.T) {531 func TestTerminalAutoOpenFirstOnly(t *testing.T) {
494 r := startRig(t, true, nil, left)532 r := startRig(t, true, nil, left)
495 first := receive(t, r.spawn)533 first := receive(t, r.spawn)
@@ -786,3 +824,33 @@ func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
786 }824 }
787 }825 }
788 }826 }
827+
828+func TestTerminalTokenClaimedOnce(t *testing.T) {
829+ // Plug DP-1 first so its request is unambiguously first. Then plug
830+ // DP-2 and focus it before either window maps.
831+ r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
832+ first := receive(t, r.spawn)
833+ r.plug(t, right)
834+ second := receive(t, r.spawn)
835+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
836+ r.client <- ports.WindowMapped{ID: 41, Slot: token(t, first), Floating: true, Width: 20, Height: 20}
837+ receive(t, r.scenes)
838+ r.client <- ports.WindowMapped{ID: 42, Slot: token(t, first)}
839+ set := receive(t, r.scenes)
840+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
841+ t.Fatalf("first claim: %v", got)
842+ }
843+ // Move back to DP-1: the duplicate must follow focus, not the token.
844+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
845+ r.client <- ports.WindowMapped{ID: 43, Slot: token(t, first)}
846+ set = receive(t, r.scenes)
847+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
848+ t.Fatalf("duplicate claim: %v", got)
849+ }
850+ // The other terminal's token remains available despite the first claim.
851+ r.client <- ports.WindowMapped{ID: 44, Slot: token(t, second)}
852+ set = receive(t, r.scenes)
853+ if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{44, 41}) {
854+ t.Fatalf("second claim: %v", got)
855+ }
856+}
M internal/core/slots.go +8 -43
@@ -1,7 +1,6 @@
11 package core
22
33 import (
4- "crypto/rand"
54 "fmt"
65 "slices"
76
@@ -28,7 +27,6 @@ type slotKey struct {
2827 type slotState struct {
2928 argv []string
3029 width Width
31- token string // SlotEnv value of the pending spawn; "" when none
3230 window WindowID // 0 while empty
3331 // stale is set when the workspace is shown while the spawn is pending.
3432 // Shown again still pending, the command is deemed failed and respawned;
@@ -36,8 +34,6 @@ type slotState struct {
3634 stale bool
3735 }
3836
39-func (s *slotState) pending() bool { return s.token != "" }
40-
4137 // slotSpec is one parsed slot of the config.
4238 type slotSpec struct {
4339 key slotKey
@@ -78,6 +74,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
7874 if _, w := c.byName(key.workspace); w != nil {
7975 w.unslot(key.index)
8076 }
77+ c.placement.dropSlot(key)
8178 delete(c.slots, key)
8279 }
8380 var spawn []slotKey
@@ -99,7 +96,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
9996 if st.window == 0 {
10097 // A pending spawn ran the old command: its window opens as
10198 // a normal one; the new command replaces it.
102- st.token = ""
99+ c.placement.dropSlot(s.key)
103100 spawn = append(spawn, s.key)
104101 }
105102 }
@@ -107,31 +104,12 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
107104 return spawn
108105 }
109106
110-// spawnSlot starts the slot's command with a fresh random token.
107+// spawnSlot starts the slot's command with a fresh token.
111108 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
112109 st := c.slots[key]
113- st.token, st.stale = rand.Text(), false
114- return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
115-}
116-
117-// placeSlotWindow puts a mapped window carrying the pending token of a slot
118-// into that slot, without focus. It returns false for any other window, which
119-// then follows the normal rules.
120-func (c *Core) placeSlotWindow(id WindowID, token string) bool {
121- for key, st := range c.slots {
122- if !st.pending() || st.token != token || st.window != 0 {
123- continue
124- }
125- sc, w := c.byName(key.workspace)
126- if w == nil {
127- return false
128- }
129- st.window, st.token = id, ""
130- w.AddSlotWindow(id, key.index, st.width)
131- sc.mon.normalize()
132- return true
133- }
134- return false
110+ c.placement.dropSlot(key)
111+ st.stale = false
112+ return c.placement.request(st.argv, spawnTarget{slot: key})
135113 }
136114
137115 // releaseSlots empties slots whose window is gone or no longer in its slot
@@ -161,26 +139,13 @@ func (c *Core) refill() []slotKey {
161139 for key, st := range c.slots {
162140 switch {
163141 case key.workspace != name || st.window != 0:
164- case st.pending() && !st.stale:
142+ case c.placement.slotPending(key) && !st.stale:
165143 st.stale = true
166144 default:
167- st.token = ""
145+ c.placement.dropSlot(key)
168146 keys = append(keys, key)
169147 }
170148 }
171149 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
172150 return keys
173151 }
174-
175-// anyPending reports whether a slot waits for its window.
176-func (c *Core) anyPending() bool {
177- if len(c.terms) > 0 {
178- return true
179- }
180- for _, st := range c.slots {
181- if st.pending() {
182- return true
183- }
184- }
185- return false
186-}
@@ -1,7 +1,6 @@
1 package core1 package core
2 2
3 import (3 import (
4- "crypto/rand"
5 "fmt"4 "fmt"
6 "slices"5 "slices"
7 6
@@ -28,7 +27,6 @@ type slotKey struct {
28 type slotState struct {27 type slotState struct {
29 argv []string28 argv []string
30 width Width29 width Width
31- token string // SlotEnv value of the pending spawn; "" when none
32 window WindowID // 0 while empty30 window WindowID // 0 while empty
33 // stale is set when the workspace is shown while the spawn is pending.31 // stale is set when the workspace is shown while the spawn is pending.
34 // Shown again still pending, the command is deemed failed and respawned;32 // Shown again still pending, the command is deemed failed and respawned;
@@ -36,8 +34,6 @@ type slotState struct {
36 stale bool34 stale bool
37 }35 }
38 36
39-func (s *slotState) pending() bool { return s.token != "" }
40-
41 // slotSpec is one parsed slot of the config.37 // slotSpec is one parsed slot of the config.
42 type slotSpec struct {38 type slotSpec struct {
43 key slotKey39 key slotKey
@@ -78,6 +74,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
78 if _, w := c.byName(key.workspace); w != nil {74 if _, w := c.byName(key.workspace); w != nil {
79 w.unslot(key.index)75 w.unslot(key.index)
80 }76 }
77+ c.placement.dropSlot(key)
81 delete(c.slots, key)78 delete(c.slots, key)
82 }79 }
83 var spawn []slotKey80 var spawn []slotKey
@@ -99,7 +96,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
99 if st.window == 0 {96 if st.window == 0 {
100 // A pending spawn ran the old command: its window opens as97 // A pending spawn ran the old command: its window opens as
101 // a normal one; the new command replaces it.98 // a normal one; the new command replaces it.
102- st.token = ""99+ c.placement.dropSlot(s.key)
103 spawn = append(spawn, s.key)100 spawn = append(spawn, s.key)
104 }101 }
105 }102 }
@@ -107,31 +104,12 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
107 return spawn104 return spawn
108 }105 }
109 106
110-// spawnSlot starts the slot's command with a fresh random token.107+// spawnSlot starts the slot's command with a fresh token.
111 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {108 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
112 st := c.slots[key]109 st := c.slots[key]
113- st.token, st.stale = rand.Text(), false110+ c.placement.dropSlot(key)
114- return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}111+ st.stale = false
115-}112+ return c.placement.request(st.argv, spawnTarget{slot: key})
116-
117-// placeSlotWindow puts a mapped window carrying the pending token of a slot
118-// into that slot, without focus. It returns false for any other window, which
119-// then follows the normal rules.
120-func (c *Core) placeSlotWindow(id WindowID, token string) bool {
121- for key, st := range c.slots {
122- if !st.pending() || st.token != token || st.window != 0 {
123- continue
124- }
125- sc, w := c.byName(key.workspace)
126- if w == nil {
127- return false
128- }
129- st.window, st.token = id, ""
130- w.AddSlotWindow(id, key.index, st.width)
131- sc.mon.normalize()
132- return true
133- }
134- return false
135 }113 }
136 114
137 // releaseSlots empties slots whose window is gone or no longer in its slot115 // releaseSlots empties slots whose window is gone or no longer in its slot
@@ -161,26 +139,13 @@ func (c *Core) refill() []slotKey {
161 for key, st := range c.slots {139 for key, st := range c.slots {
162 switch {140 switch {
163 case key.workspace != name || st.window != 0:141 case key.workspace != name || st.window != 0:
164- case st.pending() && !st.stale:142+ case c.placement.slotPending(key) && !st.stale:
165 st.stale = true143 st.stale = true
166 default:144 default:
167- st.token = ""145+ c.placement.dropSlot(key)
168 keys = append(keys, key)146 keys = append(keys, key)
169 }147 }
170 }148 }
171 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })149 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
172 return keys150 return keys
173 }151 }
174-
175-// anyPending reports whether a slot waits for its window.
176-func (c *Core) anyPending() bool {
177- if len(c.terms) > 0 {
178- return true
179- }
180- for _, st := range c.slots {
181- if st.pending() {
182- return true
183- }
184- }
185- return false
186-}
M internal/core/slots_test.go +56 -9
@@ -13,13 +13,14 @@ import (
1313 )
1414
1515 type slotRig struct {
16- client chan ports.ClientEvent
17- input chan ports.InputEvent
18- reload chan ports.ConfigChanged
19- spawn chan ports.SpawnRequest
20- scenes chan []ports.Scene
21- errs chan error
22- cfg ports.Config
16+ client chan ports.ClientEvent
17+ input chan ports.InputEvent
18+ reload chan ports.ConfigChanged
19+ spawn chan ports.SpawnRequest
20+ scenes chan []ports.Scene
21+ workspaces chan ports.Workspaces
22+ errs chan error
23+ cfg ports.Config
2324 }
2425
2526 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
@@ -41,11 +42,11 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
4142 r := &slotRig{
4243 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
4344 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
44- scenes: make(chan []ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,
45+ scenes: make(chan []ports.Scene, 1), workspaces: make(chan ports.Workspaces, 1), errs: make(chan error, 4), cfg: cfg,
4546 }
4647 output := make(chan ports.OutputEvent, 1)
4748 commands := make(chan ports.ClientCommand, 1024)
48- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs})
49+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs})
4950 if err != nil {
5051 t.Fatal(err)
5152 }
@@ -136,6 +137,33 @@ func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
136137 return m
137138 }
138139
140+// Activating a workspace through the protocol must count as showing it for
141+// pending-slot retry, just like switching to it with a key.
142+func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) {
143+ r := startSlots(t)
144+ first, second := receive(t, r.spawn), receive(t, r.spawn)
145+ initial := receive(t, r.workspaces)
146+ var dev uint64
147+ for _, w := range initial.Outputs[0].Workspaces {
148+ if w.Configured == "dev" {
149+ dev = w.ID
150+ }
151+ }
152+ if dev == 0 {
153+ t.Fatal(initial)
154+ }
155+ r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
156+ scene(t, r.scenes)
157+ noSpawn(t, r.spawn) // first show marks pending slots stale
158+ r.press(t, "d") // leave dev
159+ r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
160+ scene(t, r.scenes)
161+ again := receive(t, r.spawn)
162+ if token(t, again) == token(t, first) || token(t, again) == token(t, second) {
163+ t.Fatal("activation did not refresh the pending slot token")
164+ }
165+}
166+
139167 func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
140168 r := startSlots(t)
141169 code, foot := receive(t, r.spawn), receive(t, r.spawn)
@@ -396,3 +424,22 @@ func TestSlotFollowsExpelledWindow(t *testing.T) {
396424 t.Fatalf("slots: %v", got)
397425 }
398426 }
427+
428+// A floating child keeps the pending claim for the first ordinary window.
429+func TestFloatingSlotTokenDoesNotClaim(t *testing.T) {
430+ r := startSlots(t)
431+ code, _ := receive(t, r.spawn), receive(t, r.spawn)
432+ r.client <- ports.WindowMapped{ID: 10, Slot: token(t, code), Floating: true, Width: 20, Height: 20}
433+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
434+ r.client <- ports.WindowMapped{ID: 11, Slot: token(t, code)}
435+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
436+ s := r.press(t, "d")
437+ if got := visible(s); got[11].W != 70 {
438+ t.Fatalf("floating child consumed slot claim: %v", got)
439+ }
440+ r.client <- ports.WindowMapped{ID: 12, Slot: token(t, code)}
441+ s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
442+ if got := visible(s); got[12].W == 70 {
443+ t.Fatalf("duplicate token claimed slot: %v", got)
444+ }
445+}
@@ -13,13 +13,14 @@ import (
13 )13 )
14 14
15 type slotRig struct {15 type slotRig struct {
16- client chan ports.ClientEvent16+ client chan ports.ClientEvent
17- input chan ports.InputEvent17+ input chan ports.InputEvent
18- reload chan ports.ConfigChanged18+ reload chan ports.ConfigChanged
19- spawn chan ports.SpawnRequest19+ spawn chan ports.SpawnRequest
20- scenes chan []ports.Scene20+ scenes chan []ports.Scene
21- errs chan error21+ workspaces chan ports.Workspaces
22- cfg ports.Config22+ errs chan error
23+ cfg ports.Config
23 }24 }
24 25
25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled26 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
@@ -41,11 +42,11 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
41 r := &slotRig{42 r := &slotRig{
42 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),43 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
43 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),44 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
44- scenes: make(chan []ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,45+ scenes: make(chan []ports.Scene, 1), workspaces: make(chan ports.Workspaces, 1), errs: make(chan error, 4), cfg: cfg,
45 }46 }
46 output := make(chan ports.OutputEvent, 1)47 output := make(chan ports.OutputEvent, 1)
47 commands := make(chan ports.ClientCommand, 1024)48 commands := make(chan ports.ClientCommand, 1024)
48- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs})49+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs})
49 if err != nil {50 if err != nil {
50 t.Fatal(err)51 t.Fatal(err)
51 }52 }
@@ -136,6 +137,33 @@ func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
136 return m137 return m
137 }138 }
138 139
140+// Activating a workspace through the protocol must count as showing it for
141+// pending-slot retry, just like switching to it with a key.
142+func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) {
143+ r := startSlots(t)
144+ first, second := receive(t, r.spawn), receive(t, r.spawn)
145+ initial := receive(t, r.workspaces)
146+ var dev uint64
147+ for _, w := range initial.Outputs[0].Workspaces {
148+ if w.Configured == "dev" {
149+ dev = w.ID
150+ }
151+ }
152+ if dev == 0 {
153+ t.Fatal(initial)
154+ }
155+ r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
156+ scene(t, r.scenes)
157+ noSpawn(t, r.spawn) // first show marks pending slots stale
158+ r.press(t, "d") // leave dev
159+ r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
160+ scene(t, r.scenes)
161+ again := receive(t, r.spawn)
162+ if token(t, again) == token(t, first) || token(t, again) == token(t, second) {
163+ t.Fatal("activation did not refresh the pending slot token")
164+ }
165+}
166+
139 func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {167 func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
140 r := startSlots(t)168 r := startSlots(t)
141 code, foot := receive(t, r.spawn), receive(t, r.spawn)169 code, foot := receive(t, r.spawn), receive(t, r.spawn)
@@ -396,3 +424,22 @@ func TestSlotFollowsExpelledWindow(t *testing.T) {
396 t.Fatalf("slots: %v", got)424 t.Fatalf("slots: %v", got)
397 }425 }
398 }426 }
427+
428+// A floating child keeps the pending claim for the first ordinary window.
429+func TestFloatingSlotTokenDoesNotClaim(t *testing.T) {
430+ r := startSlots(t)
431+ code, _ := receive(t, r.spawn), receive(t, r.spawn)
432+ r.client <- ports.WindowMapped{ID: 10, Slot: token(t, code), Floating: true, Width: 20, Height: 20}
433+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
434+ r.client <- ports.WindowMapped{ID: 11, Slot: token(t, code)}
435+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
436+ s := r.press(t, "d")
437+ if got := visible(s); got[11].W != 70 {
438+ t.Fatalf("floating child consumed slot claim: %v", got)
439+ }
440+ r.client <- ports.WindowMapped{ID: 12, Slot: token(t, code)}
441+ s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
442+ if got := visible(s); got[12].W == 70 {
443+ t.Fatalf("duplicate token claimed slot: %v", got)
444+ }
445+}
A internal/core/spawn_placement.go +106 -0
@@ -0,0 +1,106 @@
1+package core
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// spawnPlacement owns pending SlotEnv claims. Slot and terminal retry decisions
12+// remain with their respective policies; only this registry mints and claims tokens.
13+type spawnPlacement struct {
14+ pending map[string]spawnTarget
15+ slots map[slotKey]string
16+}
17+
18+// spawnTarget identifies the destination of one pending spawn.
19+type spawnTarget struct {
20+ slot slotKey
21+ terminal *Workspace
22+ at time.Time
23+}
24+
25+func newSpawnPlacement() spawnPlacement {
26+ return spawnPlacement{pending: make(map[string]spawnTarget), slots: make(map[slotKey]string)}
27+}
28+
29+// request mints a token and records its pending destination.
30+func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
31+ token := rand.Text()
32+ p.pending[token] = target
33+ if target.terminal == nil {
34+ p.slots[target.slot] = token
35+ }
36+ return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
37+}
38+
39+// dropSlot invalidates the pending token of a slot.
40+func (p *spawnPlacement) dropSlot(key slotKey) {
41+ if token := p.slots[key]; token != "" {
42+ delete(p.pending, token)
43+ delete(p.slots, key)
44+ }
45+}
46+
47+// slotPending reports whether a slot still awaits a window.
48+func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] != "" }
49+
50+// anyPending reports whether any spawn still awaits a window.
51+func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
52+
53+// terminalPending reports whether a workspace awaits an automatic terminal.
54+func (p *spawnPlacement) terminalPending(w *Workspace) bool {
55+ for _, target := range p.pending {
56+ if target.terminal == w {
57+ return true
58+ }
59+ }
60+ return false
61+}
62+
63+// expireTerminals drops terminal claims older than the retry interval.
64+func (p *spawnPlacement) expireTerminals(now time.Time) {
65+ for token, target := range p.pending {
66+ if target.terminal != nil && now.Sub(target.at) > termRetry {
67+ delete(p.pending, token)
68+ }
69+ }
70+}
71+
72+// place claims a token once and puts its window in the target workspace.
73+// Untagged windows return before any map lookup or allocation.
74+func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
75+ if v.Floating {
76+ if s, _ := c.screenOf(v.ID); s == nil {
77+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
78+ }
79+ return
80+ }
81+ if v.Slot != "" {
82+ if target, ok := p.pending[v.Slot]; ok {
83+ if target.terminal != nil {
84+ delete(p.pending, v.Slot)
85+ for _, s := range c.screens {
86+ if slices.Contains(s.mon.all(), target.terminal) {
87+ target.terminal.AddWindow(v.ID)
88+ s.mon.normalize()
89+ return
90+ }
91+ }
92+ } else if st := c.slots[target.slot]; st != nil && st.window == 0 {
93+ if sc, w := c.byName(target.slot.workspace); w != nil {
94+ p.dropSlot(target.slot)
95+ st.window = v.ID
96+ w.AddSlotWindow(v.ID, target.slot.index, st.width)
97+ sc.mon.normalize()
98+ return
99+ }
100+ }
101+ }
102+ }
103+ if s, _ := c.screenOf(v.ID); s == nil {
104+ c.cur().mon.AddWindow(v.ID)
105+ }
106+}
@@ -0,0 +1,106 @@
1+package core
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// spawnPlacement owns pending SlotEnv claims. Slot and terminal retry decisions
12+// remain with their respective policies; only this registry mints and claims tokens.
13+type spawnPlacement struct {
14+ pending map[string]spawnTarget
15+ slots map[slotKey]string
16+}
17+
18+// spawnTarget identifies the destination of one pending spawn.
19+type spawnTarget struct {
20+ slot slotKey
21+ terminal *Workspace
22+ at time.Time
23+}
24+
25+func newSpawnPlacement() spawnPlacement {
26+ return spawnPlacement{pending: make(map[string]spawnTarget), slots: make(map[slotKey]string)}
27+}
28+
29+// request mints a token and records its pending destination.
30+func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
31+ token := rand.Text()
32+ p.pending[token] = target
33+ if target.terminal == nil {
34+ p.slots[target.slot] = token
35+ }
36+ return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
37+}
38+
39+// dropSlot invalidates the pending token of a slot.
40+func (p *spawnPlacement) dropSlot(key slotKey) {
41+ if token := p.slots[key]; token != "" {
42+ delete(p.pending, token)
43+ delete(p.slots, key)
44+ }
45+}
46+
47+// slotPending reports whether a slot still awaits a window.
48+func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] != "" }
49+
50+// anyPending reports whether any spawn still awaits a window.
51+func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
52+
53+// terminalPending reports whether a workspace awaits an automatic terminal.
54+func (p *spawnPlacement) terminalPending(w *Workspace) bool {
55+ for _, target := range p.pending {
56+ if target.terminal == w {
57+ return true
58+ }
59+ }
60+ return false
61+}
62+
63+// expireTerminals drops terminal claims older than the retry interval.
64+func (p *spawnPlacement) expireTerminals(now time.Time) {
65+ for token, target := range p.pending {
66+ if target.terminal != nil && now.Sub(target.at) > termRetry {
67+ delete(p.pending, token)
68+ }
69+ }
70+}
71+
72+// place claims a token once and puts its window in the target workspace.
73+// Untagged windows return before any map lookup or allocation.
74+func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
75+ if v.Floating {
76+ if s, _ := c.screenOf(v.ID); s == nil {
77+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
78+ }
79+ return
80+ }
81+ if v.Slot != "" {
82+ if target, ok := p.pending[v.Slot]; ok {
83+ if target.terminal != nil {
84+ delete(p.pending, v.Slot)
85+ for _, s := range c.screens {
86+ if slices.Contains(s.mon.all(), target.terminal) {
87+ target.terminal.AddWindow(v.ID)
88+ s.mon.normalize()
89+ return
90+ }
91+ }
92+ } else if st := c.slots[target.slot]; st != nil && st.window == 0 {
93+ if sc, w := c.byName(target.slot.workspace); w != nil {
94+ p.dropSlot(target.slot)
95+ st.window = v.ID
96+ w.AddSlotWindow(v.ID, target.slot.index, st.width)
97+ sc.mon.normalize()
98+ return
99+ }
100+ }
101+ }
102+ }
103+ if s, _ := c.screenOf(v.ID); s == nil {
104+ c.cur().mon.AddWindow(v.ID)
105+ }
106+}
A internal/core/spawn_placement_test.go +77 -0
@@ -0,0 +1,77 @@
1+package core
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A monitor can lose a named workspace while its slot is still configured.
13+// The claim must remain pending, but its window follows ordinary placement.
14+func TestSlotClaimWithoutWorkspace(t *testing.T) {
15+ // A config message would remove both the workspace and its slot.
16+ // Exercise the registry's missing-destination branch directly instead.
17+ cfg := ports.Config{}
18+ cfg.Keyboard.CmdKey = "alt"
19+ cfg.Layout.MaxColumns = 1
20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ key := slotKey{workspace: "gone", index: 1}
25+ c.slots[key] = &slotState{argv: []string{"foot"}}
26+ req := c.spawnSlot(key)
27+ token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
28+ c.placement.place(c, ports.WindowMapped{ID: 21, Slot: token})
29+ if !c.placement.slotPending(key) {
30+ t.Fatal("unplaceable slot consumed its token")
31+ }
32+ if s, _ := c.screenOf(21); s == nil {
33+ t.Fatal("window did not map normally")
34+ }
35+ // Claim status is also reflected on the client command port.
36+ commands := make(chan ports.ClientCommand, 1)
37+ c.ch.Commands = commands
38+ if err := c.publishPending(context.Background()); err != nil {
39+ t.Fatal(err)
40+ }
41+ if got := (<-commands).(ports.SlotsPending); !got.Pending {
42+ t.Fatal("pending claim was not retained")
43+ }
44+}
45+
46+func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
47+ cfg := ports.Config{}
48+ cfg.Keyboard.CmdKey = "alt"
49+ cfg.Layout.MaxColumns = 1
50+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ // Keep a different, focused workspace for the ordinary-window fallback.
55+ c.cur().mon.AddWindow(1)
56+ target := c.cur().mon.Current()
57+ c.cur().mon.show(c.cur().mon.Workspaces[1])
58+ focused := c.cur().mon.Current()
59+ if focused == target {
60+ t.Fatal("focus did not move to another workspace")
61+ }
62+ t0 := time.Unix(123, 0)
63+ req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})
64+ token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65+ c.placement.expireTerminals(t0.Add(termRetry))
66+ if _, ok := c.placement.pending[token]; !ok {
67+ t.Fatal("claim expired at retry boundary")
68+ }
69+ c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))
70+ if _, ok := c.placement.pending[token]; ok {
71+ t.Fatal("claim survived past retry boundary")
72+ }
73+ c.placement.place(c, ports.WindowMapped{ID: 51, Slot: token})
74+ if _, w := c.screenOf(51); w != focused || target.has(51) {
75+ t.Fatal("expired token did not map onto focused workspace")
76+ }
77+}
@@ -0,0 +1,77 @@
1+package core
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A monitor can lose a named workspace while its slot is still configured.
13+// The claim must remain pending, but its window follows ordinary placement.
14+func TestSlotClaimWithoutWorkspace(t *testing.T) {
15+ // A config message would remove both the workspace and its slot.
16+ // Exercise the registry's missing-destination branch directly instead.
17+ cfg := ports.Config{}
18+ cfg.Keyboard.CmdKey = "alt"
19+ cfg.Layout.MaxColumns = 1
20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ key := slotKey{workspace: "gone", index: 1}
25+ c.slots[key] = &slotState{argv: []string{"foot"}}
26+ req := c.spawnSlot(key)
27+ token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
28+ c.placement.place(c, ports.WindowMapped{ID: 21, Slot: token})
29+ if !c.placement.slotPending(key) {
30+ t.Fatal("unplaceable slot consumed its token")
31+ }
32+ if s, _ := c.screenOf(21); s == nil {
33+ t.Fatal("window did not map normally")
34+ }
35+ // Claim status is also reflected on the client command port.
36+ commands := make(chan ports.ClientCommand, 1)
37+ c.ch.Commands = commands
38+ if err := c.publishPending(context.Background()); err != nil {
39+ t.Fatal(err)
40+ }
41+ if got := (<-commands).(ports.SlotsPending); !got.Pending {
42+ t.Fatal("pending claim was not retained")
43+ }
44+}
45+
46+func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
47+ cfg := ports.Config{}
48+ cfg.Keyboard.CmdKey = "alt"
49+ cfg.Layout.MaxColumns = 1
50+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ // Keep a different, focused workspace for the ordinary-window fallback.
55+ c.cur().mon.AddWindow(1)
56+ target := c.cur().mon.Current()
57+ c.cur().mon.show(c.cur().mon.Workspaces[1])
58+ focused := c.cur().mon.Current()
59+ if focused == target {
60+ t.Fatal("focus did not move to another workspace")
61+ }
62+ t0 := time.Unix(123, 0)
63+ req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})
64+ token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65+ c.placement.expireTerminals(t0.Add(termRetry))
66+ if _, ok := c.placement.pending[token]; !ok {
67+ t.Fatal("claim expired at retry boundary")
68+ }
69+ c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))
70+ if _, ok := c.placement.pending[token]; ok {
71+ t.Fatal("claim survived past retry boundary")
72+ }
73+ c.placement.place(c, ports.WindowMapped{ID: 51, Slot: token})
74+ if _, w := c.screenOf(51); w != focused || target.has(51) {
75+ t.Fatal("expired token did not map onto focused workspace")
76+ }
77+}
M internal/core/terminal.go +3 -46
@@ -1,8 +1,6 @@
11 package core
22
33 import (
4- "crypto/rand"
5- "slices"
64 "time"
75
86 "github.com/bnema/neferwl/internal/ports"
@@ -15,11 +13,6 @@ import (
1513 // terminal that fails, or maps and exits at once, is not respawned in a loop.
1614 const termRetry = 5 * time.Second
1715
18-type termSpawn struct {
19- w *Workspace
20- at time.Time
21-}
22-
2316 // keepsTerminal reports whether empty workspaces get a terminal.
2417 func (c *Core) keepsTerminal() bool {
2518 return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
@@ -29,13 +22,7 @@ func (c *Core) keepsTerminal() bool {
2922 // Workspaces with slots are left to them.
3023 func (c *Core) fillEmpty() []ports.SpawnRequest {
3124 now := time.Now()
32- // Expired spawns go first, whatever is on screen: pending tokens keep
33- // wayland reading /proc on every map.
34- for token, t := range c.terms {
35- if now.Sub(t.at) > termRetry {
36- delete(c.terms, token)
37- }
38- }
25+ c.placement.expireTerminals(now)
3926 if !c.keepsTerminal() {
4027 return nil
4128 }
@@ -51,28 +38,16 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
5138 continue
5239 }
5340 }
54- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {
41+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {
5542 continue
5643 }
57- token := rand.Text()
5844 c.firstTerminalResolved = true
5945 w.termAt = now
60- c.terms[token] = &termSpawn{w: w, at: now}
61- reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
46+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))
6247 }
6348 return reqs
6449 }
6550
66-// termPending reports whether a terminal spawn waits for w.
67-func (c *Core) termPending(w *Workspace) bool {
68- for _, t := range c.terms {
69- if t.w == w {
70- return true
71- }
72- }
73- return false
74-}
75-
7651 func (c *Core) hasSlots(w *Workspace) bool {
7752 for key := range c.slots {
7853 if w.Name != "" && key.workspace == w.Name {
@@ -81,21 +56,3 @@ func (c *Core) hasSlots(w *Workspace) bool {
8156 }
8257 return false
8358 }
84-
85-// placeTerminal puts the window of a pending terminal on its workspace. It
86-// returns false for any other window, or when the workspace is gone.
87-func (c *Core) placeTerminal(id WindowID, token string) bool {
88- t := c.terms[token]
89- if t == nil {
90- return false
91- }
92- delete(c.terms, token)
93- for _, s := range c.screens {
94- if slices.Contains(s.mon.all(), t.w) {
95- t.w.AddWindow(id)
96- s.mon.normalize()
97- return true
98- }
99- }
100- return false
101-}
@@ -1,8 +1,6 @@
1 package core1 package core
2 2
3 import (3 import (
4- "crypto/rand"
5- "slices"
6 "time"4 "time"
7 5
8 "github.com/bnema/neferwl/internal/ports"6 "github.com/bnema/neferwl/internal/ports"
@@ -15,11 +13,6 @@ import (
15 // terminal that fails, or maps and exits at once, is not respawned in a loop.13 // terminal that fails, or maps and exits at once, is not respawned in a loop.
16 const termRetry = 5 * time.Second14 const termRetry = 5 * time.Second
17 15
18-type termSpawn struct {
19- w *Workspace
20- at time.Time
21-}
22-
23 // keepsTerminal reports whether empty workspaces get a terminal.16 // keepsTerminal reports whether empty workspaces get a terminal.
24 func (c *Core) keepsTerminal() bool {17 func (c *Core) keepsTerminal() bool {
25 return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 018 return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
@@ -29,13 +22,7 @@ func (c *Core) keepsTerminal() bool {
29 // Workspaces with slots are left to them.22 // Workspaces with slots are left to them.
30 func (c *Core) fillEmpty() []ports.SpawnRequest {23 func (c *Core) fillEmpty() []ports.SpawnRequest {
31 now := time.Now()24 now := time.Now()
32- // Expired spawns go first, whatever is on screen: pending tokens keep25+ c.placement.expireTerminals(now)
33- // wayland reading /proc on every map.
34- for token, t := range c.terms {
35- if now.Sub(t.at) > termRetry {
36- delete(c.terms, token)
37- }
38- }
39 if !c.keepsTerminal() {26 if !c.keepsTerminal() {
40 return nil27 return nil
41 }28 }
@@ -51,28 +38,16 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
51 continue38 continue
52 }39 }
53 }40 }
54- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {41+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {
55 continue42 continue
56 }43 }
57- token := rand.Text()
58 c.firstTerminalResolved = true44 c.firstTerminalResolved = true
59 w.termAt = now45 w.termAt = now
60- c.terms[token] = &termSpawn{w: w, at: now}46+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))
61- reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
62 }47 }
63 return reqs48 return reqs
64 }49 }
65 50
66-// termPending reports whether a terminal spawn waits for w.
67-func (c *Core) termPending(w *Workspace) bool {
68- for _, t := range c.terms {
69- if t.w == w {
70- return true
71- }
72- }
73- return false
74-}
75-
76 func (c *Core) hasSlots(w *Workspace) bool {51 func (c *Core) hasSlots(w *Workspace) bool {
77 for key := range c.slots {52 for key := range c.slots {
78 if w.Name != "" && key.workspace == w.Name {53 if w.Name != "" && key.workspace == w.Name {
@@ -81,21 +56,3 @@ func (c *Core) hasSlots(w *Workspace) bool {
81 }56 }
82 return false57 return false
83 }58 }
84-
85-// placeTerminal puts the window of a pending terminal on its workspace. It
86-// returns false for any other window, or when the workspace is gone.
87-func (c *Core) placeTerminal(id WindowID, token string) bool {
88- t := c.terms[token]
89- if t == nil {
90- return false
91- }
92- delete(c.terms, token)
93- for _, s := range c.screens {
94- if slices.Contains(s.mon.all(), t.w) {
95- t.w.AddWindow(id)
96- s.mon.normalize()
97- return true
98- }
99- }
100- return false
101-}
M internal/ports/messages.go +5 -8
@@ -408,14 +408,11 @@ type OutputHeads struct{ Heads []OutputHead }
408408
409409 // HeadChange is the complete desired state of one head in a configuration.
410410 type HeadChange struct {
411- Name string
412- Enabled bool
413- Mode *OutputMode
414- CustomMode bool
415- Pos *image.Point
416- Scale float64
417- Transform int
418- AdaptiveSync *bool
411+ Name string
412+ Enabled bool
413+ Mode *OutputMode
414+ Pos *image.Point
415+ Scale float64
419416 }
420417
421418 // OutputApply asks the app owner to validate or apply a runtime configuration.
@@ -408,14 +408,11 @@ type OutputHeads struct{ Heads []OutputHead }
408 408
409 // HeadChange is the complete desired state of one head in a configuration.409 // HeadChange is the complete desired state of one head in a configuration.
410 type HeadChange struct {410 type HeadChange struct {
411- Name string411+ Name string
412- Enabled bool412+ Enabled bool
413- Mode *OutputMode413+ Mode *OutputMode
414- CustomMode bool414+ Pos *image.Point
415- Pos *image.Point415+ Scale float64
416- Scale float64
417- Transform int
418- AdaptiveSync *bool
419 }416 }
420 417
421 // OutputApply asks the app owner to validate or apply a runtime configuration.418 // OutputApply asks the app owner to validate or apply a runtime configuration.

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