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 Unverified
B5690EEEBB952194The key is unknown
38 files · +1825 -594
M Makefile +1 -1
| @@ -50,7 +50,7 @@ arch: | |||
| 50 | 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 51 | 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 52 | 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=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 | |
| 54 | 54 | check: vet test arch fakes-check perf-check | |
| 55 | 55 | ||
| 56 | 56 | # 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=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 |
| 54 | check: vet test arch fakes-check perf-check | 54 | check: vet test arch fakes-check perf-check | ||
| 55 | 55 | ||||
| 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 56 | # 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 | |||
| 10 | 10 | ||
| 11 | 11 | ## 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 | 15 | ## Measuring | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen | |||||
| 10 | 10 | ||||
| 11 | ## Guards | 11 | ## 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 | ## Measuring | 15 | ## Measuring | ||
| 16 | 16 | ||||
M internal/adapters/drm/commit_test.go +5 -5
| @@ -20,14 +20,14 @@ func TestStaleEventDoesNotCompleteNewCommit(t *testing.T) { | |||
| 20 | 20 | t.Fatal(err) | |
| 21 | 21 | } | |
| 22 | 22 | 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 | |
| 24 | 24 | if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil { | |
| 25 | 25 | t.Fatal(err) | |
| 26 | 26 | } | |
| 27 | - | if o.completed(old, nil) || !o.pending { | |
| 27 | + | if o.completed(old, nil) || !o.frame.pendingCommit() { | |
| 28 | 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 | 31 | t.Fatal("own event did not complete the commit") | |
| 32 | 32 | } | |
| 33 | 33 | } | |
| @@ -40,10 +40,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) { | |||
| 40 | 40 | if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) { | |
| 41 | 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 | 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 | 47 | t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits)) | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| @@ -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 queued | 23 | + | 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 ( | |||
| 7 | 7 | "maps" | |
| 8 | 8 | "os" | |
| 9 | 9 | "slices" | |
| 10 | - | "sync/atomic" | |
| 11 | 10 | "time" | |
| 12 | 11 | "unsafe" | |
| 13 | 12 | ||
| @@ -60,28 +59,10 @@ type Output struct { | |||
| 60 | 59 | overlayReason string | |
| 61 | 60 | // fbs are the two renderer images frames alternate between, back the | |
| 62 | 61 | // 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 | |
| 85 | 66 | // readFences are fences of frames rendered but not committed: the | |
| 86 | 67 | // GPU may still read client buffers until they signal, so what was | |
| 87 | 68 | // seen is reported only then (or after a later frame flipped). | |
| @@ -131,28 +112,6 @@ type Output struct { | |||
| 131 | 112 | // Ready reports the result of the first modeset exactly once. | |
| 132 | 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 | 115 | // dupFences duplicates the non-nil fences. | |
| 157 | 116 | func dupFences(fs ...*os.File) []*os.File { | |
| 158 | 117 | var out []*os.File | |
| @@ -214,81 +173,28 @@ func (o *Output) dropRead() { | |||
| 214 | 173 | o.readFences = nil | |
| 215 | 174 | } | |
| 216 | 175 | ||
| 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. | |
| 266 | 179 | func (o *Output) expire() bool { | |
| 267 | 180 | 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: | |
| 287 | 187 | o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset") | |
| 288 | 188 | 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") | |
| 291 | - | return true | |
| 197 | + | return false | |
| 292 | 198 | } | |
| 293 | 199 | ||
| 294 | 200 | // 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 | 205 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| 300 | 206 | log := card.log | |
| 301 | 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 | 209 | var err error | |
| 304 | 210 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | |
| 305 | 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 | 358 | // Only now is nothing of ours pending or on screen: on failure the | |
| 453 | 359 | // previous buffers may still show. | |
| 454 | - | o.endPending() | |
| 455 | - | o.busySince = time.Time{} | |
| 360 | + | o.frame.resetAfterModeset() | |
| 456 | 361 | o.shown, o.queued = 0, 0 | |
| 457 | 362 | if o.modeBlob != 0 { | |
| 458 | 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 | 575 | req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd())) | |
| 671 | 576 | } | |
| 672 | 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 | 579 | if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() { | |
| 675 | 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 | 607 | f.frame, f.async = true, async | |
| 703 | 608 | f.fences = dupFences(fence, ov.acquire) | |
| 704 | 609 | o.setAsync(async) | |
| 705 | - | o.begin(f) | |
| 610 | + | o.frame.begin(f, time.Now()) | |
| 706 | 611 | return nil | |
| 707 | 612 | } | |
| 708 | 613 | ||
| @@ -730,7 +635,7 @@ func (o *Output) commitState(vrr bool) error { | |||
| 730 | 635 | if len(req.objs) == 0 { | |
| 731 | 636 | 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 | 639 | if o.overlayConflict(err, o.overlayOn) { | |
| 735 | 640 | // The next frame is composed without the overlay. | |
| 736 | 641 | return errOverlayDropped | |
| @@ -747,7 +652,7 @@ func (o *Output) commitState(vrr bool) error { | |||
| 747 | 652 | o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr") | |
| 748 | 653 | } | |
| 749 | 654 | o.vrrOn = vrr | |
| 750 | - | o.begin(pendingFrame{}) | |
| 655 | + | o.frame.begin(pendingFrame{}, time.Now()) | |
| 751 | 656 | return nil | |
| 752 | 657 | } | |
| 753 | 658 | ||
| @@ -758,41 +663,6 @@ func boolValue(b bool) uint64 { | |||
| 758 | 663 | 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 | 666 | // commitScanout flips a client buffer. It reports false when KMS refused | |
| 797 | 667 | // the buffer: the frame must be composed. | |
| 798 | 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 | 690 | if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) { | |
| 821 | 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") | |
| 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") | |
| 825 | 695 | return false, nil | |
| 826 | 696 | } | |
| 827 | 697 | if err == nil { | |
| @@ -911,7 +781,7 @@ func (o *Output) Close() { | |||
| 911 | 781 | o.cursor.free(o.k) | |
| 912 | 782 | } | |
| 913 | 783 | o.freeImages() | |
| 914 | - | o.endPending() | |
| 784 | + | o.frame.endPending() | |
| 915 | 785 | o.dropRead() | |
| 916 | 786 | o.shown, o.queued = 0, 0 | |
| 917 | 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 | 857 | // guarantees a stopped/reset timer cannot deliver its old value. | |
| 988 | 858 | retryTimer := time.NewTimer(time.Hour) | |
| 989 | 859 | vrrTimer := time.NewTimer(time.Hour) | |
| 990 | - | stuckTimer := time.NewTimer(time.Hour) | |
| 860 | + | o.frame.startTimer() | |
| 991 | 861 | retryTimer.Stop() | |
| 992 | 862 | vrrTimer.Stop() | |
| 993 | - | stuckTimer.Stop() | |
| 994 | 863 | defer retryTimer.Stop() | |
| 995 | 864 | defer vrrTimer.Stop() | |
| 996 | - | defer stuckTimer.Stop() | |
| 865 | + | defer o.frame.stopTimer() | |
| 997 | 866 | // seen is the latest content Seq per window. It is reported only while | |
| 998 | 867 | // no frame is in flight: then the GPU has finished every frame that | |
| 999 | 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 | 875 | for { | |
| 1007 | 876 | readWait := false | |
| 1008 | - | if reportDirty && !o.pending { | |
| 877 | + | if reportDirty && !o.frame.pendingCommit() { | |
| 1009 | 878 | if readWait = !o.readDone(); !readWait { | |
| 1010 | 879 | o.report(nil, seen) | |
| 1011 | 880 | reportDirty = false | |
| @@ -1023,26 +892,20 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1023 | 892 | } | |
| 1024 | 893 | // A composed screen that stopped changing still drops VRR on time. | |
| 1025 | 894 | 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 | 896 | vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince))) | |
| 1028 | 897 | vrrOff = vrrTimer.C | |
| 1029 | 898 | } else { | |
| 1030 | 899 | vrrTimer.Stop() | |
| 1031 | 900 | } | |
| 1032 | 901 | // 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) | |
| 1040 | 903 | stateDirty := false | |
| 1041 | 904 | select { | |
| 1042 | 905 | case <-retry: | |
| 1043 | 906 | continue | |
| 1044 | 907 | case <-stuck: | |
| 1045 | - | if o.pending && o.stuckAt.After(time.Now()) { | |
| 908 | + | if !o.frame.deadlineReached(time.Now()) { | |
| 1046 | 909 | continue // waits for a fence | |
| 1047 | 910 | } | |
| 1048 | 911 | if o.expire() { | |
| @@ -1132,7 +995,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1132 | 995 | dirty = dirty || scene.Shows(c.ID) | |
| 1133 | 996 | case <-stats.C: | |
| 1134 | 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 | 999 | if o.cursor != nil { | |
| 1137 | 1000 | cs := o.cursor.TakeStats() | |
| 1138 | 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 | 1009 | requests = nil | |
| 1147 | 1010 | } | |
| 1148 | - | if !enabled || o.pending { | |
| 1011 | + | if !enabled || o.frame.pendingCommit() { | |
| 1149 | 1012 | continue | |
| 1150 | 1013 | } | |
| 1151 | 1014 | // 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 | 1044 | // shows is what the frame puts on screen: presentation feedback | |
| 1182 | 1045 | // of windows it does not draw is discarded, not presented. | |
| 1183 | 1046 | 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) | |
| 1190 | 1048 | 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])}) | |
| 1193 | 1052 | } | |
| 1194 | 1053 | 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 | |
| 1199 | 1058 | } | |
| 1200 | 1059 | r.UseTarget(o.back) | |
| 1201 | 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 | 1126 | func (o *Output) commitFailed(err error, enabled *bool) bool { | |
| 1268 | 1127 | switch { | |
| 1269 | 1128 | 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()) { | |
| 1280 | 1131 | o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy") | |
| 1281 | 1132 | } | |
| 1282 | - | o.lastBusy = now | |
| 1283 | 1133 | return true | |
| 1284 | 1134 | case lostMaster(err): | |
| 1285 | 1135 | o.log.Warn().Err(err).Msg("commit refused") | |
| @@ -1294,14 +1144,12 @@ func (o *Output) commitFailed(err error, enabled *bool) bool { | |||
| 1294 | 1144 | // reported with its kernel timestamp. It reports false for an event of | |
| 1295 | 1145 | // another commit, which is dropped. | |
| 1296 | 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 | 1149 | return false | |
| 1299 | 1150 | } | |
| 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") | |
| 1305 | 1153 | } | |
| 1306 | 1154 | if o.cursor != nil { | |
| 1307 | 1155 | 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 string | 59 | overlayReason string | ||
| 61 | // fbs are the two renderer images frames alternate between, back the | 60 | // 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]uint32 | 62 | + | fbs [2]uint32 |
| 64 | - | back int | 63 | + | back int |
| 65 | - | // pending: a commit waits for its event; pendingFrame when it flips a | 64 | + | 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: the | 66 | // readFences are fences of frames rendered but not committed: the | ||
| 86 | // GPU may still read client buffers until they signal, so what was | 67 | // 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.File | 117 | var out []*os.File | ||
| @@ -214,81 +173,28 @@ func (o *Output) dropRead() { | |||||
| 214 | o.readFences = nil | 173 | 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 = true | 184 | + | 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 true | 188 | 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 most | 200 | // 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.log | 206 | 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 error | 209 | 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 the | 358 | // 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, 0 | 361 | 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, async | 607 | 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 nil | 611 | 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 nil | 636 | 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 errOverlayDropped | 641 | 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 = vrr | 654 | o.vrrOn = vrr | ||
| 750 | - | o.begin(pendingFrame{}) | 655 | + | o.frame.begin(pendingFrame{}, time.Now()) |
| 751 | return nil | 656 | return nil | ||
| 752 | } | 657 | } | ||
| 753 | 658 | ||||
| @@ -758,41 +663,6 @@ func boolValue(b bool) uint64 { | |||||
| 758 | return 0 | 663 | 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 refused | 666 | // 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, nil | 695 | 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, 0 | 786 | 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 while | 866 | // 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 that | 867 | // 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 := false | 876 | 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 = false | 880 | 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.Time | 894 | 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.C | 897 | 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.Time | 902 | + | 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 := false | 903 | stateDirty := false | ||
| 1041 | select { | 904 | select { | ||
| 1042 | case <-retry: | 905 | case <-retry: | ||
| 1043 | continue | 906 | 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 fence | 909 | 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 = nil | 1009 | requests = nil | ||
| 1147 | } | 1010 | } | ||
| 1148 | - | if !enabled || o.pending { | 1011 | + | if !enabled || o.frame.pendingCommit() { |
| 1149 | continue | 1012 | continue | ||
| 1150 | } | 1013 | } | ||
| 1151 | // Output power: off waits for no commit in flight; on is a modeset | 1014 | // 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 feedback | 1044 | // 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 uint32 | 1047 | + | 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 := false | 1048 | 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 overlayWin | 1054 | + | ov, composed := decision.overlay, decision.composed |
| 1196 | - | composed := scene | 1055 | + | 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 VT | 1129 | + | // Any CRTC event can unblock a refusal; absent one, retry on time. |
| 1271 | - | // switch, or one whose event already came): wait for any event | 1130 | + | 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 true | 1133 | 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 of | 1144 | // 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 false | 1149 | return false | ||
| 1299 | } | 1150 | } | ||
| 1300 | - | f := o.pendingFrame | 1151 | + | 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, | |||
| 64 | 64 | primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps} | |
| 65 | 65 | cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64) | |
| 66 | 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 | 68 | crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR, | |
| 69 | 69 | primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true} | |
| 70 | 70 | return o, k, &commits, commitMu | |
| @@ -89,7 +89,7 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) { | |||
| 89 | 89 | t.Fatal(err) | |
| 90 | 90 | } | |
| 91 | 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 | 93 | t.Fatalf("flags %#x user %d", c.flags, c.user) | |
| 94 | 94 | } | |
| 95 | 95 | if v, _ := c.req.value(tPrimary, pFB); v != 70 { | |
| @@ -104,8 +104,8 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) { | |||
| 104 | 104 | if v, _ := c.req.value(tCrtc, pVRR); v != 1 { | |
| 105 | 105 | t.Fatalf("vrr %d", v) | |
| 106 | 106 | } | |
| 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) | |
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| @@ -177,14 +177,14 @@ func TestCursorCommitWaitsForEvent(t *testing.T) { | |||
| 177 | 177 | t.Fatal(err) | |
| 178 | 178 | } | |
| 179 | 179 | 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()) | |
| 182 | 182 | } | |
| 183 | 183 | // Its event is not a frame: nothing is reported. | |
| 184 | 184 | seen := map[ports.WindowID]uint64{} | |
| 185 | 185 | 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) | |
| 188 | 188 | } | |
| 189 | 189 | // Nothing changed: no commit. | |
| 190 | 190 | if err := o.commitState(false); err != nil || len(*commits) != 1 { | |
| @@ -196,8 +196,8 @@ func TestBusyCommitStaysPending(t *testing.T) { | |||
| 196 | 196 | o, _, _ := testOutput(t, unix.EBUSY) | |
| 197 | 197 | err := o.commitFrame(70, nil, false, false, pendingFrame{}) | |
| 198 | 198 | 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) | |
| 201 | 201 | } | |
| 202 | 202 | } | |
| 203 | 203 | ||
| @@ -423,7 +423,7 @@ func TestVRRRefusedIsDisabled(t *testing.T) { | |||
| 423 | 423 | if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil { | |
| 424 | 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 | 427 | t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp) | |
| 428 | 428 | } | |
| 429 | 429 | 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, commitMu | 70 | 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 := true | 198 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "os" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 8 | "golang.org/x/sys/unix" | |
| @@ -107,49 +106,6 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 107 | 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 | 109 | // overlayProps puts ov on the overlay plane, or turns it off. | |
| 154 | 110 | func (o *Output) overlayProps(req *atomicReq, ov overlayWin) { | |
| 155 | 111 | p := o.overlay | |
| @@ -201,6 +157,7 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool { | |||
| 201 | 157 | if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) { | |
| 202 | 158 | cfb.overlayFailed = "overlay_refused" | |
| 203 | 159 | } | |
| 160 | + | o.setOverlayReason("overlay_refused") | |
| 204 | 161 | o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("overlay refused") | |
| 205 | 162 | return false | |
| 206 | 163 | } | |
| @@ -219,7 +176,6 @@ func (o *Output) overlayConflict(err error, buf uint64) bool { | |||
| 219 | 176 | if cfb := o.clientFBs[buf]; cfb != nil { | |
| 220 | 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") | |
| 223 | - | o.overlayReason = "cursor_conflict" | |
| 179 | + | o.setOverlayReason("cursor_conflict") | |
| 224 | 180 | return true | |
| 225 | 181 | } | |
| @@ -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.overlay | 111 | 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 false | 162 | 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 true | 180 | return true | ||
| 225 | } | 181 | } | ||
M internal/adapters/drm/overlay_test.go +4 -1
| @@ -105,6 +105,9 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) { | |||
| 105 | 105 | if o.testOverlay(70, ov) { | |
| 106 | 106 | t.Fatal("refusal taken") | |
| 107 | 107 | } | |
| 108 | + | if o.overlayReason != "overlay_refused" { | |
| 109 | + | t.Fatalf("refusal reason %q", o.overlayReason) | |
| 110 | + | } | |
| 108 | 111 | if ov2, rest := o.overlayFrame(s, c); ov2.fb != 0 || len(rest.Windows) != 2 { | |
| 109 | 112 | t.Fatal("refused buffer tried again") | |
| 110 | 113 | } | |
| @@ -141,7 +144,7 @@ func TestOverlayCursorConflictDropsOverlay(t *testing.T) { | |||
| 141 | 144 | if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil { | |
| 142 | 145 | t.Fatal(err) | |
| 143 | 146 | } | |
| 144 | - | o.pending = false | |
| 147 | + | o.frame.endPending() | |
| 145 | 148 | o.cursor.image = true | |
| 146 | 149 | o.cursor.Move(150, 50) | |
| 147 | 150 | 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 = false | 147 | + | o.frame.endPending() |
| 145 | o.cursor.image = true | 148 | 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 | |||
| 99 | 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 | 223 | // clientFB is a client buffer imported as a KMS framebuffer. | |
| 103 | 224 | type clientFB struct { | |
| 104 | 225 | fbID uint32 | |
| @@ -151,7 +272,7 @@ func (o *Output) planeFB(p *plane, b *ports.DMABuf, now time.Time, reason func(* | |||
| 151 | 272 | } | |
| 152 | 273 | id, err := o.k.addFB(b, format) | |
| 153 | 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 | 276 | fb.importErr, *why = true, "import_failed" | |
| 156 | 277 | return 0, *why | |
| 157 | 278 | } | |
| @@ -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 uint32 | 225 | 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, *why | 277 | return 0, *why | ||
| 157 | } | 278 | } | ||
M internal/adapters/drm/scanout_test.go +58 -0
| @@ -279,3 +279,61 @@ func TestShownByScene(t *testing.T) { | |||
| 279 | 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 | + | } | |
| @@ -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 { | |||
| 43 | 43 | t.Helper() | |
| 44 | 44 | o, k, commits, commitMu := testOutputMu(t, append([]error{nil, nil}, errs...)...) | |
| 45 | 45 | o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{} | |
| 46 | - | o.stuckAfter = 200 * time.Millisecond | |
| 46 | + | o.frame.stuckAfter = 200 * time.Millisecond | |
| 47 | 47 | o.flipped = make(chan flipEvent) // no event ever | |
| 48 | 48 | r := portsmocks.NewMockRenderer(t) | |
| 49 | 49 | buf := func() ports.DMABuf { | |
| @@ -214,14 +214,14 @@ func TestSeenWaitsForRefusedFrameFence(t *testing.T) { | |||
| 214 | 214 | // retried, not taken as the same run lasting past the limit. | |
| 215 | 215 | func TestLaterBusyStartsNewRun(t *testing.T) { | |
| 216 | 216 | o, _, _ := testOutput(t, unix.EBUSY, unix.EBUSY) | |
| 217 | - | o.stuckAfter = 100 * time.Millisecond | |
| 217 | + | o.frame.stuckAfter = 100 * time.Millisecond | |
| 218 | 218 | enabled := true | |
| 219 | 219 | o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) | |
| 220 | 220 | time.Sleep(busyRetry) | |
| 221 | 221 | if o.expire() { | |
| 222 | 222 | t.Fatal("first EBUSY retry modesets") | |
| 223 | 223 | } | |
| 224 | - | time.Sleep(2 * o.stuckAfter) | |
| 224 | + | time.Sleep(2 * o.frame.stuckAfter) | |
| 225 | 225 | o.commitFailed(o.commitFrame(70, nil, false, false, pendingFrame{}), &enabled) | |
| 226 | 226 | time.Sleep(busyRetry) | |
| 227 | 227 | 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.Millisecond | 46 | + | o.frame.stuckAfter = 200 * time.Millisecond |
| 47 | o.flipped = make(chan flipEvent) // no event ever | 47 | 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.Millisecond | 217 | + | o.frame.stuckAfter = 100 * time.Millisecond |
| 218 | enabled := true | 218 | 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 | |||
| 169 | 169 | h.c.pending = &r | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 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) { | |
| 177 | 183 | return | |
| 178 | 184 | } | |
| 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 | |
| 181 | 187 | } | |
| 182 | 188 | if c.active { | |
| 183 | 189 | s.server.emitConstraint(c) | |
| @@ -169,15 +169,21 @@ func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, re | |||||
| 169 | h.c.pending = &r | 169 | h.c.pending = &r | ||
| 170 | } | 170 | } | ||
| 171 | 171 | ||||
| 172 | - | // commitConstraint applies a pending region on the surface commit, after | 172 | + | // 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 | return | 183 | return | ||
| 178 | } | 184 | } | ||
| 179 | - | if c.pending != nil { | 185 | + | if region != nil && s.server.constraints[s] == c { |
| 180 | - | c.region, c.pending = *c.pending, nil | 186 | + | 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 ( | |||
| 22 | 22 | // - wp_commit_timing_v1: a commit waits until the refresh that shows it | |
| 23 | 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 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. | |
| 28 | 28 | ||
| 29 | 29 | func registerPresentationConstraints(d *server.Display, s *Server) error { | |
| 30 | 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 | 198 | // 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 | 201 | type update struct { | |
| 202 | 202 | inputSet, inputAll bool | |
| 203 | 203 | inputRects []ports.Rect | |
| @@ -331,36 +331,6 @@ func (s *surface) takePending() update { | |||
| 331 | 331 | 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 | 334 | // tooEarly reports whether content applied now would show before at: | |
| 365 | 335 | // it reaches the screen one refresh later. | |
| 366 | 336 | func (s *surface) tooEarly(at, now time.Time) bool { | |
| @@ -415,7 +385,7 @@ func (s *surface) queueUpdate() { | |||
| 415 | 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 | 389 | func (s *surface) applyUpdate(u *update) { | |
| 420 | 390 | // A buffer destroyed while queued (a swapchain resize) is skipped: the | |
| 421 | 391 | // surface keeps its content, and is not unmapped behind the client. | |
| @@ -433,25 +403,11 @@ func (s *surface) applyUpdate(u *update) { | |||
| 433 | 403 | // The viewport change was made for that buffer: keep the crop that | |
| 434 | 404 | // matches the retained content instead of validating a mismatch. | |
| 435 | 405 | 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 | |
| 441 | 408 | } | |
| 442 | 409 | } | |
| 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) | |
| 455 | 411 | s.commitSkipped = false | |
| 456 | 412 | } | |
| 457 | 413 | ||
| @@ -22,9 +22,9 @@ import ( | |||||
| 22 | // - wp_commit_timing_v1: a commit waits until the refresh that shows it | 22 | // - 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 and | 25 | + | // 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 they | 26 | + | // 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 queue | 198 | // 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 bool | 202 | inputSet, inputAll bool | ||
| 203 | inputRects []ports.Rect | 203 | inputRects []ports.Rect | ||
| @@ -331,36 +331,6 @@ func (s *surface) takePending() update { | |||||
| 331 | return u | 331 | 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: the | 390 | // 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 that | 403 | // 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.crop | 407 | + | 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 = false | 411 | s.commitSkipped = false | ||
| 456 | } | 412 | } | ||
| 457 | 413 | ||||
M internal/adapters/wayland/layer.go +6 -1
| @@ -224,6 +224,12 @@ func (l *layerSurface) sendConfigure() { | |||
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | 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 | 233 | if l.surface.destroyed { | |
| 228 | 234 | l.unmap() | |
| 229 | 235 | delete(l.shell.server.layers, l.id) | |
| @@ -232,7 +238,6 @@ func (l *layerSurface) commit(buffer bool) { | |||
| 232 | 238 | if l.closed { | |
| 233 | 239 | return | |
| 234 | 240 | } | |
| 235 | - | p := l.pending | |
| 236 | 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 | 242 | l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors") | |
| 238 | 243 | 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 | return | 239 | 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 | return | 243 | return | ||
M internal/adapters/wayland/outputconfiguration.go +10 -4
| @@ -20,6 +20,7 @@ type headConfiguration struct { | |||
| 20 | 20 | parent *outputConfiguration | |
| 21 | 21 | res *wlr.ZwlrOutputConfigurationHeadV1 | |
| 22 | 22 | change ports.HeadChange | |
| 23 | + | unsupported bool | |
| 23 | 24 | modeSet, positionSet, transformSet, scaleSet, syncSet bool | |
| 24 | 25 | } | |
| 25 | 26 | ||
| @@ -92,6 +93,10 @@ func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool | |||
| 92 | 93 | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured") | |
| 93 | 94 | return | |
| 94 | 95 | } | |
| 96 | + | if change.unsupported { | |
| 97 | + | r.SendFailed() | |
| 98 | + | return | |
| 99 | + | } | |
| 95 | 100 | request.Heads = append(request.Heads, change.change) | |
| 96 | 101 | } | |
| 97 | 102 | if c.manager.s.channels.OutputApply == nil { | |
| @@ -138,7 +143,7 @@ func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1, | |||
| 138 | 143 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode") | |
| 139 | 144 | return | |
| 140 | 145 | } | |
| 141 | - | h.change.CustomMode = true | |
| 146 | + | h.unsupported = true | |
| 142 | 147 | h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)} | |
| 143 | 148 | } | |
| 144 | 149 | func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) { | |
| @@ -154,7 +159,9 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v | |||
| 154 | 159 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | |
| 155 | 160 | return | |
| 156 | 161 | } | |
| 157 | - | h.change.Transform = int(v) | |
| 162 | + | if v != 0 { | |
| 163 | + | h.unsupported = true | |
| 164 | + | } | |
| 158 | 165 | } | |
| 159 | 166 | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | |
| 160 | 167 | if !h.once(&h.scaleSet, r) { | |
| @@ -175,6 +182,5 @@ func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1 | |||
| 175 | 182 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync") | |
| 176 | 183 | return | |
| 177 | 184 | } | |
| 178 | - | enabled := v == 1 | |
| 179 | - | h.change.AdaptiveSync = &enabled | |
| 185 | + | h.unsupported = true | |
| 180 | 186 | } | |
| @@ -20,6 +20,7 @@ type headConfiguration struct { | |||||
| 20 | parent *outputConfiguration | 20 | parent *outputConfiguration | ||
| 21 | res *wlr.ZwlrOutputConfigurationHeadV1 | 21 | res *wlr.ZwlrOutputConfigurationHeadV1 | ||
| 22 | change ports.HeadChange | 22 | change ports.HeadChange | ||
| 23 | + | unsupported bool | |||
| 23 | modeSet, positionSet, transformSet, scaleSet, syncSet bool | 24 | 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 | return | 94 | 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 | return | 144 | return | ||
| 140 | } | 145 | } | ||
| 141 | - | h.change.CustomMode = true | 146 | + | 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 | return | 160 | 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 | return | 183 | return | ||
| 177 | } | 184 | } | ||
| 178 | - | enabled := v == 1 | 185 | + | 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) { | |||
| 233 | 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 | 291 | // Each case uses a new connection because wl_display.error terminates that client. | |
| 237 | 292 | func TestOutputManagementProtocolErrors(t *testing.T) { | |
| 238 | 293 | 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 | |||
| 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 | 172 | 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 | |
| 179 | 186 | } | |
| 180 | 187 | ||
| 181 | 188 | func (*viewport) Destroy(*viewporter.WpViewport) {} | |
| @@ -202,14 +209,10 @@ func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) { | |||
| 202 | 209 | } | |
| 203 | 210 | 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 | 213 | // source returns buffer-pixel crop coordinates, precomputed at commit. | |
| 211 | 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 | 216 | return [4]float32{}, false | |
| 214 | 217 | } | |
| 215 | 218 | v := s.committedViewport.src | |
| @@ -222,8 +225,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) { | |||
| 222 | 225 | } | |
| 223 | 226 | ||
| 224 | 227 | 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 { | |
| 227 | 230 | return true | |
| 228 | 231 | } | |
| 229 | 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 | 278 | // logicalSize is the surface size in logical pixels: the viewport | |
| 276 | 279 | // destination, else the buffer divided by its integer buffer scale. | |
| 277 | 280 | 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 | |
| 285 | 286 | } | |
| 286 | 287 | scale := max(s.bufferScale, 1) | |
| 287 | 288 | 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 *surface | 173 | + | surface *surface |
| 173 | - | resource *viewporter.WpViewport | 174 | + | resource *viewporter.WpViewport |
| 174 | - | pendingW, destW int32 | 175 | + | pendingW, pendingH int32 |
| 175 | - | pendingH, destH int32 | 176 | + | pendingSet bool |
| 176 | - | pendingSet, dest bool | 177 | + | pendingSrc [4]server.Fixed |
| 177 | - | pendingSrc, src [4]server.Fixed | 178 | + | pendingCrop bool |
| 178 | - | pendingCrop, crop bool | 179 | + | } |
| 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 != -1 | 210 | 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{}, false | 216 | return [4]float32{}, false | ||
| 214 | } | 217 | } | ||
| 215 | v := s.committedViewport.src | 218 | 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.committedViewport | 228 | + | v := &s.committedViewport |
| 226 | - | if v == nil || !v.crop { | 229 | + | if !v.crop { |
| 227 | return true | 230 | 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 viewport | 278 | // 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 / scale | 288 | return bw / scale, bh / scale | ||
M internal/adapters/wayland/scale_test.go +1 -1
| @@ -219,7 +219,7 @@ func TestBufferScaleLogicalSize(t *testing.T) { | |||
| 219 | 219 | if w, h := st.logicalSize(200, 100); w != 100 || h != 50 { | |
| 220 | 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 | 223 | if w, h := st.logicalSize(200, 100); w != 7 || h != 3 { | |
| 224 | 224 | t.Fatal(w, h) | |
| 225 | 225 | } | |
| @@ -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 ( | |||
| 25 | 25 | type surface struct { | |
| 26 | 26 | wl *wayland.Surface | |
| 27 | 27 | viewport *viewport | |
| 28 | - | committedViewport *viewport | |
| 28 | + | committedViewport viewportState // value snapshot; no allocation on viewport commits | |
| 29 | 29 | // bufferScale is committed state; pendingScale is set by set_buffer_scale. | |
| 30 | 30 | bufferScale, pendingScale int | |
| 31 | 31 | kind roleKind | |
| @@ -312,69 +312,65 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 312 | 312 | s.server.tickFifo(time.Now(), nil) | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 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) { | |
| 317 | 318 | oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source | |
| 318 | 319 | oldColor, oldRepresentation := s.color, s.representation | |
| 319 | - | fresh := s.attached && s.pending != nil | |
| 320 | + | fresh := u.attached && u.buffer != nil | |
| 320 | 321 | s.commitFresh = fresh | |
| 321 | 322 | if !s.commitSkipped { | |
| 322 | - | s.queuedScale = s.pendingScale | |
| 323 | + | s.queuedScale = u.scale | |
| 323 | 324 | 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() | |
| 326 | 327 | } | |
| 327 | 328 | } | |
| 328 | - | if s.pendingBarrier { | |
| 329 | - | s.pendingBarrier = false | |
| 329 | + | if u.barrier { | |
| 330 | 330 | s.setBarrier(time.Now()) | |
| 331 | 331 | } | |
| 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 | |
| 335 | 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 { | |
| 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 | |
| 348 | 346 | } else { | |
| 349 | - | s.committedViewport = nil | |
| 347 | + | s.committedViewport.dest, s.committedViewport.crop = false, false | |
| 350 | 348 | } | |
| 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) { | |
| 355 | 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 | 354 | // Same buffer again, or detached: the old points are done | |
| 358 | 355 | // with once the new ones take over. | |
| 359 | 356 | s.server.releaseBufferSync(s, s.hold) | |
| 360 | 357 | } | |
| 361 | 358 | s.hold = syncHold{} | |
| 362 | - | s.current = s.pending | |
| 363 | - | s.pending = nil | |
| 364 | - | s.attached = false | |
| 359 | + | s.current = u.buffer | |
| 365 | 360 | } | |
| 366 | 361 | 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 | |
| 371 | 364 | } | |
| 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) | |
| 374 | 367 | } | |
| 375 | - | s.callbacks = nil | |
| 376 | 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 | 375 | if fresh { | |
| 380 | 376 | if state, ok := s.server.buffers[s.current.Resource]; ok { | |
| @@ -385,7 +381,7 @@ func (s *surface) applyCommit() { | |||
| 385 | 381 | c.Source, _ = s.source(c.Width, c.Height) | |
| 386 | 382 | c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height) | |
| 387 | 383 | 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 | 385 | if s.sub.parent == nil && (c.Width != s.lastW || c.Height != s.lastH) { | |
| 390 | 386 | s.lastW, s.lastH = c.Width, c.Height | |
| 391 | 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 | 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 | 399 | if !fresh { | |
| 404 | - | s.commitDamage(false, false, 0, 0) | |
| 400 | + | s.commitDamage(u, false, false, 0, 0) | |
| 405 | 401 | // A NULL attach is exempt from out_of_buffer: its content is | |
| 406 | 402 | // cleared below, so the retained buffer is not validated. | |
| 407 | 403 | if s.has && s.current != nil { | |
| @@ -420,19 +416,19 @@ func (s *surface) applyCommit() { | |||
| 420 | 416 | s.root().treeDirty = true | |
| 421 | 417 | } | |
| 422 | 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 | 420 | if moved { | |
| 425 | 421 | s.root().treeDirty = true | |
| 426 | 422 | } | |
| 427 | 423 | // Queued updates can still reference the previous layout. Never mutate it. | |
| 428 | 424 | if moved { | |
| 429 | - | s.sub.layout = s.sub.pendingLayout | |
| 425 | + | s.sub.layout = u.layout | |
| 430 | 426 | } | |
| 431 | 427 | 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 | |
| 434 | 430 | } | |
| 435 | - | s.commitConstraint(geometry) | |
| 431 | + | s.commitConstraint(u.cons, u.region, geometry) | |
| 436 | 432 | s.emitInput() | |
| 437 | 433 | if s.xdg != nil && s.xdg.window != nil { | |
| 438 | 434 | s.xdg.window.afterCommit() | |
| @@ -448,8 +444,7 @@ func (s *surface) applyCommit() { | |||
| 448 | 444 | s.redraw() | |
| 449 | 445 | } | |
| 450 | 446 | } | |
| 451 | - | fb := s.pendingFeedback | |
| 452 | - | s.pendingFeedback = nil | |
| 447 | + | fb := u.feedback | |
| 453 | 448 | if s.server.applyingGraph { | |
| 454 | 449 | s.server.graphFeedback = append(s.server.graphFeedback, graphFeedback{s, fb, fresh}) | |
| 455 | 450 | } else { | |
| @@ -471,9 +466,8 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) { | |||
| 471 | 466 | // buffer. Surface damage scales by the buffer/logical ratio, rounded out. | |
| 472 | 467 | // A new size, too many rects or none at all with a new buffer (clients | |
| 473 | 468 | // 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 | |
| 477 | 471 | if !fresh { | |
| 478 | 472 | s.committed = damage{} | |
| 479 | 473 | return | |
| @@ -25,7 +25,7 @@ const ( | |||||
| 25 | type surface struct { | 25 | type surface struct { | ||
| 26 | wl *wayland.Surface | 26 | wl *wayland.Surface | ||
| 27 | viewport *viewport | 27 | viewport *viewport | ||
| 28 | - | committedViewport *viewport | 28 | + | 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 int | 30 | bufferScale, pendingScale int | ||
| 31 | kind roleKind | 31 | 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.Source | 318 | oldW, oldH, oldSource := s.content.LogicalW, s.content.LogicalH, s.content.Source | ||
| 318 | oldColor, oldRepresentation := s.color, s.representation | 319 | oldColor, oldRepresentation := s.color, s.representation | ||
| 319 | - | fresh := s.attached && s.pending != nil | 320 | + | fresh := u.attached && u.buffer != nil |
| 320 | s.commitFresh = fresh | 321 | s.commitFresh = fresh | ||
| 321 | if !s.commitSkipped { | 322 | if !s.commitSkipped { | ||
| 322 | - | s.queuedScale = s.pendingScale | 323 | + | s.queuedScale = u.scale |
| 323 | s.queuedBuffer = 0 | 324 | 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.pendingScale | 337 | + | s.bufferScale = u.scale |
| 339 | - | } | 338 | + | } |
| 340 | - | hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation | 339 | + | hinted := s.async != u.async || s.color != u.color || s.representation != u.representation |
| 341 | - | s.async = s.pendingAsync | 340 | + | s.async = u.async |
| 342 | - | s.color = s.pendingColor | 341 | + | s.color = u.color |
| 343 | - | s.representation = s.pendingRepresentation | 342 | + | 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.viewport | 345 | + | s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop |
| 347 | - | s.committedViewport = &v | |||
| 348 | } else { | 346 | } else { | ||
| 349 | - | s.committedViewport = nil | 347 | + | s.committedViewport.dest, s.committedViewport.crop = false, false |
| 350 | } | 348 | } | ||
| 351 | - | cs := s.pendingSync | 349 | + | cs := u.sync |
| 352 | - | s.pendingSync = nil | 350 | + | 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 done | 354 | // 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.pending | 359 | + | 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.pendingInputRects | 363 | + | 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.LogicalH | 383 | 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.Height | 386 | 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 is | 401 | // 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 = true | 416 | 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 = true | 421 | 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.pendingLayout | 425 | + | s.sub.layout = u.layout |
| 430 | } | 426 | } | ||
| 431 | geometry := false | 427 | 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, true | 429 | + | 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.pendingFeedback | 447 | + | 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 (clients | 467 | // 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.pendingBufDamage | 470 | + | 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 | return | 473 | return | ||
M internal/adapters/wayland/syncobj_test.go +60 -0
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/neferwl/internal/logging" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | "github.com/bnema/purego-libwayland/protocol/committiming" | |
| 15 | + | "github.com/bnema/purego-libwayland/protocol/fifo" | |
| 14 | 16 | "github.com/bnema/purego-libwayland/protocol/linuxdmabuf" | |
| 15 | 17 | "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj" | |
| 16 | 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 | 358 | func TestSyncobjProtocolErrors(t *testing.T) { | |
| 299 | 359 | for _, tc := range []struct { | |
| 300 | 360 | 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 string | 360 | 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) | |||
| 238 | 238 | ||
| 239 | 239 | go func() { | |
| 240 | 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 | 242 | newRenderer := func(w, h int) (ports.Renderer, error) { | |
| 242 | 243 | r, err := vulkan.New(w, h) | |
| 243 | 244 | if err != nil { | |
| @@ -252,11 +253,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 252 | 253 | w.Sampled = dmabuf.Formats | |
| 253 | 254 | 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 | 258 | 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 | 263 | 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.Formats | 253 | w.Sampled = dmabuf.Formats | ||
| 253 | return w | 254 | 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 | return | 258 | 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() { | |||
| 135 | 135 | // one flip reader per card, and a udev watcher that rescans connectors on | |
| 136 | 136 | // hotplug. Core learns about outputs through events. It returns when ctx | |
| 137 | 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 | 139 | ctx, cancel := context.WithCancel(ctx) | |
| 140 | 140 | defer cancel() | |
| 141 | 141 | var readers sync.WaitGroup | |
| 142 | 142 | readerErr := make(chan error, len(b.cards)) | |
| 143 | 143 | for _, c := range b.cards { | |
| 144 | - | c.SetFormats(formats) | |
| 144 | + | c.SetFormats(ch.formats) | |
| 145 | 145 | readers.Go(func() { | |
| 146 | 146 | if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil { | |
| 147 | 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 | 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 | 159 | cards := map[string]*drmCard{} | |
| 160 | 160 | send := func(ev ports.OutputEvent) { | |
| 161 | 161 | select { | |
| 162 | - | case events <- ev: | |
| 162 | + | case ch.events <- ev: | |
| 163 | 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 | 190 | timer = nil | |
| 191 | 191 | } | |
| 192 | 192 | select { | |
| 193 | - | case applied <- d.err: | |
| 193 | + | case ch.applied <- d.err: | |
| 194 | 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 | 205 | inventory.Heads = append(inventory.Heads, lastHeads[card]...) | |
| 206 | 206 | } | |
| 207 | 207 | 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) | |
| 216 | 209 | } | |
| 217 | 210 | scan := func() { | |
| 218 | 211 | var scanErrors []error | |
| @@ -280,7 +273,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 280 | 273 | defer b.seat.Unsubscribe(active) | |
| 281 | 274 | return safe("output "+name, func() error { | |
| 282 | 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 | 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 | 304 | complete(progress.scanned(running)) | |
| 312 | 305 | } | |
| 313 | - | case currentConfig = <-configs: | |
| 306 | + | case currentConfig = <-ch.configs: | |
| 314 | 307 | op++ | |
| 315 | 308 | if timer != nil { | |
| 316 | 309 | timer.Stop() | |
| @@ -350,13 +343,13 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 350 | 343 | complete(progress.readyEvent(result.name, result.source, result.err)) | |
| 351 | 344 | case id := <-deadline: | |
| 352 | 345 | complete(progress.timeout(id)) | |
| 353 | - | case s := <-scenes: | |
| 346 | + | case s := <-ch.scenes: | |
| 354 | 347 | set.scenes(s) | |
| 355 | - | case c := <-contents: | |
| 348 | + | case c := <-ch.contents: | |
| 356 | 349 | set.content(c) | |
| 357 | - | case q := <-captures: | |
| 350 | + | case q := <-ch.captures: | |
| 358 | 351 | set.routeCapture(q) | |
| 359 | - | case c := <-cursorChanges: | |
| 352 | + | case c := <-ch.cursorChanges: | |
| 360 | 353 | set.setCursor(c) | |
| 361 | 354 | case name := <-set.stopped: | |
| 362 | 355 | 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 on | 135 | // one flip reader per card, and a udev watcher that rescans connectors on | ||
| 136 | // hotplug. Core learns about outputs through events. It returns when ctx | 136 | // 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.WaitGroup | 141 | 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 = nil | 190 | 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 = inventory | 207 | 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 []error | 211 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 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 { | |
| 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) | |
| 18 | 18 | inventory := ports.OutputHeads{} | |
| 19 | 19 | for i, size := range sizes { | |
| 20 | 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 | 31 | cur := &headless.Cursor{} | |
| 32 | 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 | 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 | 35 | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| 36 | 36 | }) | |
| 37 | 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 | 39 | case <-ctx.Done(): | |
| 40 | 40 | return set.wait() | |
| 41 | 41 | } | |
| 42 | 42 | } | |
| 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) { | |
| 51 | 44 | return set.wait() | |
| 52 | 45 | } | |
| 53 | 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 | 49 | select { | |
| 57 | 50 | case <-ctx.Done(): | |
| 58 | 51 | return set.wait() | |
| 59 | - | case s := <-scenes: | |
| 52 | + | case s := <-ch.scenes: | |
| 60 | 53 | set.scenes(s) | |
| 61 | - | case c := <-contents: | |
| 54 | + | case c := <-ch.contents: | |
| 62 | 55 | set.content(c) | |
| 63 | - | case q := <-captures: | |
| 56 | + | case q := <-ch.captures: | |
| 64 | 57 | set.routeCapture(q) | |
| 65 | - | case c := <-cursorChanges: | |
| 58 | + | case c := <-ch.cursorChanges: | |
| 66 | 59 | set.setCursor(c) | |
| 67 | 60 | case name := <-set.stopped: | |
| 68 | 61 | // 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 { | |||
| 111 | 111 | return fmt.Errorf("unknown head %s", change.Name) | |
| 112 | 112 | } | |
| 113 | 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 | 114 | if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) { | |
| 118 | 115 | return fmt.Errorf("invalid scale on %s", change.Name) | |
| 119 | 116 | } | |
| @@ -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 { | |||
| 74 | 74 | specs []NamedWorkspace | |
| 75 | 75 | presets []Width | |
| 76 | 76 | slots map[slotKey]*slotState | |
| 77 | + | placement spawnPlacement | |
| 77 | 78 | // 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 | |
| 83 | 81 | firstTerminalResolved bool | |
| 84 | 82 | // clients holds the app ID and PID of mapped windows, for State. | |
| 85 | 83 | clients map[WindowID]ports.WindowMapped | |
| @@ -243,7 +241,6 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 243 | 241 | for _, s := range c.screens { | |
| 244 | 242 | c.settings(s.mon) | |
| 245 | 243 | } | |
| 246 | - | c.settleGuests() | |
| 247 | 244 | c.applyConfigScales() | |
| 248 | 245 | return nil | |
| 249 | 246 | } | |
| @@ -251,7 +248,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 251 | 248 | // publishPending tells wayland at once when slots start waiting, before | |
| 252 | 249 | // their windows can map. | |
| 253 | 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 | 252 | if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil { | |
| 256 | 253 | return err | |
| 257 | 254 | } | |
| @@ -295,7 +292,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 295 | 292 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | |
| 296 | 293 | } | |
| 297 | 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 | 296 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | |
| 300 | 297 | if err := c.apply(cfg); err != nil { | |
| 301 | 298 | return nil, err | |
| @@ -406,16 +403,12 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 406 | 403 | } | |
| 407 | 404 | c.sentOutputs = v | |
| 408 | 405 | } | |
| 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 { | |
| 411 | 408 | return err | |
| 412 | 409 | } | |
| 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 | |
| 419 | 412 | } | |
| 420 | 413 | alive := map[WindowID]bool{} | |
| 421 | 414 | focus := c.keyboardFocus() | |
| @@ -675,15 +668,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 675 | 668 | c.inputRegions[v.ID] = v | |
| 676 | 669 | case ports.WindowMapped: | |
| 677 | 670 | 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) | |
| 687 | 672 | case ports.WindowResized: | |
| 688 | 673 | if _, w := c.screenOf(v.ID); w != nil { | |
| 689 | 674 | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| @@ -753,6 +738,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 753 | 738 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 754 | 739 | } | |
| 755 | 740 | case ports.WorkspaceActivate: | |
| 741 | + | before := c.cur().mon.Current() | |
| 756 | 742 | for _, id := range v.IDs { | |
| 757 | 743 | for i, sc := range c.screens { | |
| 758 | 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 | 759 | case ports.WindowActivate: | |
| 771 | 760 | if c.activate(ctx, v.ID) != nil { | |
| 772 | 761 | return nil | |
| @@ -793,6 +782,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 793 | 782 | } | |
| 794 | 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 | 788 | if err := c.publish(ctx); err != nil { | |
| 797 | 789 | return nil | |
| 798 | 790 | } | |
| @@ -808,7 +800,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 808 | 800 | } | |
| 809 | 801 | continue | |
| 810 | 802 | } | |
| 811 | - | if c.spawnSlots(ctx, false) != nil { | |
| 803 | + | if c.workspaceVisible(ctx, false) != nil { | |
| 812 | 804 | return nil | |
| 813 | 805 | } | |
| 814 | 806 | if err := c.publish(ctx); err != nil { | |
| @@ -995,7 +987,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 995 | 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 | 991 | return nil | |
| 1000 | 992 | } | |
| 1001 | 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; | |
| 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 { | |
| 1026 | 1017 | c.releaseSlots() | |
| 1027 | 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 | 1040 | // 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 | 1053 | w.Activate(id) | |
| 1045 | 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 | 1058 | // heldKey names a key for press tracking: by physical key, since Shift | |
| @@ -74,12 +74,10 @@ type Core struct { | |||||
| 74 | specs []NamedWorkspace | 74 | specs []NamedWorkspace | ||
| 75 | presets []Width | 75 | presets []Width | ||
| 76 | slots map[slotKey]*slotState | 76 | 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 []slotKey | 79 | + | toSpawn []slotKey |
| 79 | - | sentPending bool | 80 | + | 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 bool | 81 | 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.WindowMapped | 83 | 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 nil | 245 | 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, before | 248 | // 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 err | 253 | 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, err | 298 | return nil, err | ||
| @@ -406,16 +403,12 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 406 | } | 403 | } | ||
| 407 | c.sentOutputs = v | 404 | 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 err | 408 | 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] = v | 668 | c.inputRegions[v.ID] = v | ||
| 676 | case ports.WindowMapped: | 669 | case ports.WindowMapped: | ||
| 677 | c.clients[v.ID] = v | 670 | 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 nil | 761 | 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 nil | 789 | return nil | ||
| 798 | } | 790 | } | ||
| @@ -808,7 +800,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 808 | } | 800 | } | ||
| 809 | continue | 801 | continue | ||
| 810 | } | 802 | } | ||
| 811 | - | if c.spawnSlots(ctx, false) != nil { | 803 | + | if c.workspaceVisible(ctx, false) != nil { |
| 812 | return nil | 804 | 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 nil | 991 | 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. The | 1040 | // 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 Shift | 1058 | // 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) { | |||
| 201 | 201 | c.focusScreen = slices.Index(c.screens, s) | |
| 202 | 202 | } | |
| 203 | 203 | c.named() | |
| 204 | - | c.settleGuests() | |
| 205 | 204 | } | |
| 206 | 205 | ||
| 207 | 206 | // 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 focused | 206 | // removeScreen moves every workspace of an unplugged output to the focused | ||
M internal/core/screens_test.go +68 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "reflect" | |
| 7 | 7 | "slices" | |
| 8 | 8 | "testing" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 11 | 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 | 531 | func TestTerminalAutoOpenFirstOnly(t *testing.T) { | |
| 494 | 532 | r := startRig(t, true, nil, left) | |
| 495 | 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 | + | } | |
| @@ -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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "crypto/rand" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "slices" | |
| 7 | 6 | ||
| @@ -28,7 +27,6 @@ type slotKey struct { | |||
| 28 | 27 | type slotState struct { | |
| 29 | 28 | argv []string | |
| 30 | 29 | width Width | |
| 31 | - | token string // SlotEnv value of the pending spawn; "" when none | |
| 32 | 30 | window WindowID // 0 while empty | |
| 33 | 31 | // stale is set when the workspace is shown while the spawn is pending. | |
| 34 | 32 | // Shown again still pending, the command is deemed failed and respawned; | |
| @@ -36,8 +34,6 @@ type slotState struct { | |||
| 36 | 34 | stale bool | |
| 37 | 35 | } | |
| 38 | 36 | ||
| 39 | - | func (s *slotState) pending() bool { return s.token != "" } | |
| 40 | - | ||
| 41 | 37 | // slotSpec is one parsed slot of the config. | |
| 42 | 38 | type slotSpec struct { | |
| 43 | 39 | key slotKey | |
| @@ -78,6 +74,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 78 | 74 | if _, w := c.byName(key.workspace); w != nil { | |
| 79 | 75 | w.unslot(key.index) | |
| 80 | 76 | } | |
| 77 | + | c.placement.dropSlot(key) | |
| 81 | 78 | delete(c.slots, key) | |
| 82 | 79 | } | |
| 83 | 80 | var spawn []slotKey | |
| @@ -99,7 +96,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 99 | 96 | if st.window == 0 { | |
| 100 | 97 | // A pending spawn ran the old command: its window opens as | |
| 101 | 98 | // a normal one; the new command replaces it. | |
| 102 | - | st.token = "" | |
| 99 | + | c.placement.dropSlot(s.key) | |
| 103 | 100 | spawn = append(spawn, s.key) | |
| 104 | 101 | } | |
| 105 | 102 | } | |
| @@ -107,31 +104,12 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 107 | 104 | 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 | 108 | func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest { | |
| 112 | 109 | 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}) | |
| 135 | 113 | } | |
| 136 | 114 | ||
| 137 | 115 | // releaseSlots empties slots whose window is gone or no longer in its slot | |
| @@ -161,26 +139,13 @@ func (c *Core) refill() []slotKey { | |||
| 161 | 139 | for key, st := range c.slots { | |
| 162 | 140 | switch { | |
| 163 | 141 | case key.workspace != name || st.window != 0: | |
| 164 | - | case st.pending() && !st.stale: | |
| 142 | + | case c.placement.slotPending(key) && !st.stale: | |
| 165 | 143 | st.stale = true | |
| 166 | 144 | default: | |
| 167 | - | st.token = "" | |
| 145 | + | c.placement.dropSlot(key) | |
| 168 | 146 | keys = append(keys, key) | |
| 169 | 147 | } | |
| 170 | 148 | } | |
| 171 | 149 | slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index }) | |
| 172 | 150 | 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 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package core | 1 | 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 []string | 28 | argv []string | ||
| 30 | width Width | 29 | width Width | ||
| 31 | - | token string // SlotEnv value of the pending spawn; "" when none | |||
| 32 | window WindowID // 0 while empty | 30 | 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 bool | 34 | 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 slotKey | 39 | 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 []slotKey | 80 | 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 as | 97 | // 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 spawn | 104 | 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(), false | 110 | + | 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 slot | 115 | // 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 = true | 143 | 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 keys | 150 | 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 ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | 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 | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // 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 | 42 | r := &slotRig{ | |
| 42 | 43 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 43 | 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 | 47 | output := make(chan ports.OutputEvent, 1) | |
| 47 | 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 | 50 | if err != nil { | |
| 50 | 51 | t.Fatal(err) | |
| 51 | 52 | } | |
| @@ -136,6 +137,33 @@ func visible(s ports.Scene) map[ports.WindowID]ports.Rect { | |||
| 136 | 137 | 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 | 167 | func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |
| 140 | 168 | r := startSlots(t) | |
| 141 | 169 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| @@ -396,3 +424,22 @@ func TestSlotFollowsExpelledWindow(t *testing.T) { | |||
| 396 | 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 | + | } | |
| @@ -13,13 +13,14 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | type slotRig struct { | 15 | type slotRig struct { | ||
| 16 | - | client chan ports.ClientEvent | 16 | + | client chan ports.ClientEvent |
| 17 | - | input chan ports.InputEvent | 17 | + | input chan ports.InputEvent |
| 18 | - | reload chan ports.ConfigChanged | 18 | + | reload chan ports.ConfigChanged |
| 19 | - | spawn chan ports.SpawnRequest | 19 | + | spawn chan ports.SpawnRequest |
| 20 | - | scenes chan []ports.Scene | 20 | + | scenes chan []ports.Scene |
| 21 | - | errs chan error | 21 | + | workspaces chan ports.Workspaces |
| 22 | - | cfg ports.Config | 22 | + | errs chan error |
| 23 | + | cfg ports.Config | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | 26 | // 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 m | 137 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "crypto/rand" | |
| 5 | - | "slices" | |
| 6 | 4 | "time" | |
| 7 | 5 | ||
| 8 | 6 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -15,11 +13,6 @@ import ( | |||
| 15 | 13 | // terminal that fails, or maps and exits at once, is not respawned in a loop. | |
| 16 | 14 | const termRetry = 5 * time.Second | |
| 17 | 15 | ||
| 18 | - | type termSpawn struct { | |
| 19 | - | w *Workspace | |
| 20 | - | at time.Time | |
| 21 | - | } | |
| 22 | - | ||
| 23 | 16 | // keepsTerminal reports whether empty workspaces get a terminal. | |
| 24 | 17 | func (c *Core) keepsTerminal() bool { | |
| 25 | 18 | 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 | 22 | // Workspaces with slots are left to them. | |
| 30 | 23 | func (c *Core) fillEmpty() []ports.SpawnRequest { | |
| 31 | 24 | 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) | |
| 39 | 26 | if !c.keepsTerminal() { | |
| 40 | 27 | return nil | |
| 41 | 28 | } | |
| @@ -51,28 +38,16 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 51 | 38 | 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 | 42 | continue | |
| 56 | 43 | } | |
| 57 | - | token := rand.Text() | |
| 58 | 44 | c.firstTerminalResolved = true | |
| 59 | 45 | 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})) | |
| 62 | 47 | } | |
| 63 | 48 | 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 | 51 | func (c *Core) hasSlots(w *Workspace) bool { | |
| 77 | 52 | for key := range c.slots { | |
| 78 | 53 | if w.Name != "" && key.workspace == w.Name { | |
| @@ -81,21 +56,3 @@ func (c *Core) hasSlots(w *Workspace) bool { | |||
| 81 | 56 | } | |
| 82 | 57 | 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 | - | } | |
| @@ -1,8 +1,6 @@ | |||||
| 1 | package core | 1 | 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.Second | 14 | 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) > 0 | 18 | 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 keep | 25 | + | 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 nil | 27 | return nil | ||
| 41 | } | 28 | } | ||
| @@ -51,28 +38,16 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 51 | continue | 38 | 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 | continue | 42 | continue | ||
| 56 | } | 43 | } | ||
| 57 | - | token := rand.Text() | |||
| 58 | c.firstTerminalResolved = true | 44 | c.firstTerminalResolved = true | ||
| 59 | w.termAt = now | 45 | 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 reqs | 48 | 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 false | 57 | 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 } | |||
| 408 | 408 | ||
| 409 | 409 | // HeadChange is the complete desired state of one head in a configuration. | |
| 410 | 410 | 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 | |
| 419 | 416 | } | |
| 420 | 417 | ||
| 421 | 418 | // 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 string | 411 | + | Name string |
| 412 | - | Enabled bool | 412 | + | Enabled bool |
| 413 | - | Mode *OutputMode | 413 | + | Mode *OutputMode |
| 414 | - | CustomMode bool | 414 | + | Pos *image.Point |
| 415 | - | Pos *image.Point | 415 | + | 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. | ||