refactor(wayland): forward client commands through a ring, not a display round trip
The command forwarder waited in display.Do for each batch, so a display goroutine busy with client requests stopped it draining core's commands. Commands now go to a postRing applied on the display goroutine in order; only the forwarder waits when the ring is full. Adjacent pointer motions of one window, bare or in the same security envelope, merge in the ring with their deltas summed, also while the display is busy.
- commit
- 9784d35386ad8b7c79c885eda790e5f069dafcc5
- author
- brice · · 2026-10-18 04:59 UTC
- parent
- 89b78d7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +292 -69
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/wayland/alloc_hotpath_test.go +33 -0
| @@ -208,3 +208,36 @@ func TestBufferContentAllocations(t *testing.T) { | |||
| 208 | 208 | t.Fatal("content from a truncated pool") | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | + | ||
| 212 | + | // Forwarding a command allocates nothing on the push path: queueing a | |
| 213 | + | // command, folding a motion into the queued one and draining the ring. (Only | |
| 214 | + | // applying a merged motion boxes the summed value again, once per merged run, | |
| 215 | + | // as the old batch coalescing did once per merge; that is excluded here.) | |
| 216 | + | func TestCommandForwardAllocations(t *testing.T) { | |
| 217 | + | if raceEnabled { | |
| 218 | + | t.Skip("the race detector makes the purego wake callback allocate") | |
| 219 | + | } | |
| 220 | + | s, _, _, _, _, _ := presentServer(t) | |
| 221 | + | r := newCommandRing(s.display, func(queuedCommand) {}) | |
| 222 | + | var button ports.ClientCommand = ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true} | |
| 223 | + | var motion ports.ClientCommand = ports.PointerMotionTo{ID: 1, DX: 1} | |
| 224 | + | state := ports.SecurityState{Generation: 2, Protected: true} | |
| 225 | + | var wrapped ports.ClientCommand = ports.SecurityCommand{State: state, Command: ports.PointerMotionTo{ID: 1, DX: 1}} | |
| 226 | + | ran := make(chan struct{}, 1) | |
| 227 | + | marker := func() { ran <- struct{}{} } | |
| 228 | + | cycle := func() { | |
| 229 | + | r.Push(queue(button)) | |
| 230 | + | r.Push(queue(motion)) | |
| 231 | + | r.Push(queue(motion)) // folded into the previous one | |
| 232 | + | r.Push(queue(wrapped)) | |
| 233 | + | r.Push(queue(wrapped)) // enveloped, same state: folded as well | |
| 234 | + | s.display.Post(marker) | |
| 235 | + | <-ran | |
| 236 | + | } | |
| 237 | + | for range 4 { | |
| 238 | + | cycle() | |
| 239 | + | } | |
| 240 | + | if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 { | |
| 241 | + | t.Errorf("command push + merge + drain: %.1f allocs, want 0", allocs) | |
| 242 | + | } | |
| 243 | + | } | |
| @@ -208,3 +208,36 @@ func TestBufferContentAllocations(t *testing.T) { | |||||
| 208 | t.Fatal("content from a truncated pool") | 208 | t.Fatal("content from a truncated pool") | ||
| 209 | } | 209 | } | ||
| 210 | } | 210 | } | ||
| 211 | + | ||||
| 212 | + | // Forwarding a command allocates nothing on the push path: queueing a | |||
| 213 | + | // command, folding a motion into the queued one and draining the ring. (Only | |||
| 214 | + | // applying a merged motion boxes the summed value again, once per merged run, | |||
| 215 | + | // as the old batch coalescing did once per merge; that is excluded here.) | |||
| 216 | + | func TestCommandForwardAllocations(t *testing.T) { | |||
| 217 | + | if raceEnabled { | |||
| 218 | + | t.Skip("the race detector makes the purego wake callback allocate") | |||
| 219 | + | } | |||
| 220 | + | s, _, _, _, _, _ := presentServer(t) | |||
| 221 | + | r := newCommandRing(s.display, func(queuedCommand) {}) | |||
| 222 | + | var button ports.ClientCommand = ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true} | |||
| 223 | + | var motion ports.ClientCommand = ports.PointerMotionTo{ID: 1, DX: 1} | |||
| 224 | + | state := ports.SecurityState{Generation: 2, Protected: true} | |||
| 225 | + | var wrapped ports.ClientCommand = ports.SecurityCommand{State: state, Command: ports.PointerMotionTo{ID: 1, DX: 1}} | |||
| 226 | + | ran := make(chan struct{}, 1) | |||
| 227 | + | marker := func() { ran <- struct{}{} } | |||
| 228 | + | cycle := func() { | |||
| 229 | + | r.Push(queue(button)) | |||
| 230 | + | r.Push(queue(motion)) | |||
| 231 | + | r.Push(queue(motion)) // folded into the previous one | |||
| 232 | + | r.Push(queue(wrapped)) | |||
| 233 | + | r.Push(queue(wrapped)) // enveloped, same state: folded as well | |||
| 234 | + | s.display.Post(marker) | |||
| 235 | + | <-ran | |||
| 236 | + | } | |||
| 237 | + | for range 4 { | |||
| 238 | + | cycle() | |||
| 239 | + | } | |||
| 240 | + | if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 { | |||
| 241 | + | t.Errorf("command push + merge + drain: %.1f allocs, want 0", allocs) | |||
| 242 | + | } | |||
| 243 | + | } | |||
A internal/adapters/wayland/commandring.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/bnema/purego-libwayland/server" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // commandRingSize bounds the client commands waiting for the display | |
| 9 | + | // goroutine. It absorbs a few display frames of a 1 kHz mouse and a burst of | |
| 10 | + | // key/button commands while the display parses client requests; only the | |
| 11 | + | // forwarder goroutine waits when it is full, so core never blocks on the | |
| 12 | + | // display directly, only on the Commands channel (cap 32) behind the forwarder. | |
| 13 | + | const commandRingSize = 256 | |
| 14 | + | ||
| 15 | + | // queuedCommand is a ClientCommand in the command ring. Pointer motion, bare | |
| 16 | + | // or wrapped in a SecurityCommand (core wraps every one in production), keeps | |
| 17 | + | // its value and envelope state inline so folding a motion into a queued one | |
| 18 | + | // allocates nothing; the command is boxed again only if a merge happened, at | |
| 19 | + | // apply time, once per merged run. | |
| 20 | + | type queuedCommand struct { | |
| 21 | + | cmd ports.ClientCommand | |
| 22 | + | motion ports.PointerMotionTo // valid if isMotion | |
| 23 | + | state ports.SecurityState // envelope state, valid if enveloped | |
| 24 | + | // isMotion: cmd is a PointerMotionTo, bare or directly inside a | |
| 25 | + | // SecurityCommand (then enveloped is set). | |
| 26 | + | isMotion, enveloped bool | |
| 27 | + | // merged: motion holds the sum of several motions and cmd is stale. | |
| 28 | + | merged bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func queue(cmd ports.ClientCommand) queuedCommand { | |
| 32 | + | q := queuedCommand{cmd: cmd} | |
| 33 | + | if env, ok := cmd.(ports.SecurityCommand); ok { | |
| 34 | + | q.state, q.enveloped = env.State, true | |
| 35 | + | cmd = env.Command | |
| 36 | + | } | |
| 37 | + | q.motion, q.isMotion = cmd.(ports.PointerMotionTo) | |
| 38 | + | return q | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (q *queuedCommand) command() ports.ClientCommand { | |
| 42 | + | switch { | |
| 43 | + | case !q.merged: | |
| 44 | + | return q.cmd | |
| 45 | + | case q.enveloped: | |
| 46 | + | return ports.SecurityCommand{State: q.state, Command: q.motion} | |
| 47 | + | } | |
| 48 | + | return q.motion | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // mergeMotion folds v into last when both are pointer motion for the same | |
| 52 | + | // window, both bare or both enveloped with an identical security state, and | |
| 53 | + | // last is the most recent queued command, so no other command is ever | |
| 54 | + | // overtaken and a bare motion never takes the place of an enveloped one. The | |
| 55 | + | // position is the latest; relative deltas add up so locked pointers (games) | |
| 56 | + | // lose no movement. | |
| 57 | + | func mergeMotion(last *queuedCommand, v queuedCommand) bool { | |
| 58 | + | if !last.isMotion || !v.isMotion || last.motion.ID != v.motion.ID || | |
| 59 | + | last.enveloped != v.enveloped || last.state != v.state { | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | m := v.motion | |
| 63 | + | m.DX += last.motion.DX | |
| 64 | + | m.DY += last.motion.DY | |
| 65 | + | m.UnaccelDX += last.motion.UnaccelDX | |
| 66 | + | m.UnaccelDY += last.motion.UnaccelDY | |
| 67 | + | last.motion, last.merged = m, true | |
| 68 | + | return true | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // newCommandRing returns the ring whose commands apply runs on the display | |
| 72 | + | // goroutine, in arrival order. Adjacent motions for one window are coalesced | |
| 73 | + | // at push time, against the last queued command only, under the ring mutex. | |
| 74 | + | // That also coalesces while the display is busy (a drain-side batch would | |
| 75 | + | // only see what the channel held at that moment), and a merge needs no room. | |
| 76 | + | func newCommandRing(d *server.Display, apply func(queuedCommand)) *postRing[queuedCommand] { | |
| 77 | + | return newMergingPostRing(d, commandRingSize, apply, mergeMotion) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // applyQueued runs on the display goroutine; it stops applying once the | |
| 81 | + | // server context is done, like the per-command check of the old batch. | |
| 82 | + | func (s *Server) applyQueued(q queuedCommand) { | |
| 83 | + | if s.ctx != nil && s.ctx.Err() != nil { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | s.apply(q.command()) | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/bnema/purego-libwayland/server" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // commandRingSize bounds the client commands waiting for the display | |||
| 9 | + | // goroutine. It absorbs a few display frames of a 1 kHz mouse and a burst of | |||
| 10 | + | // key/button commands while the display parses client requests; only the | |||
| 11 | + | // forwarder goroutine waits when it is full, so core never blocks on the | |||
| 12 | + | // display directly, only on the Commands channel (cap 32) behind the forwarder. | |||
| 13 | + | const commandRingSize = 256 | |||
| 14 | + | ||||
| 15 | + | // queuedCommand is a ClientCommand in the command ring. Pointer motion, bare | |||
| 16 | + | // or wrapped in a SecurityCommand (core wraps every one in production), keeps | |||
| 17 | + | // its value and envelope state inline so folding a motion into a queued one | |||
| 18 | + | // allocates nothing; the command is boxed again only if a merge happened, at | |||
| 19 | + | // apply time, once per merged run. | |||
| 20 | + | type queuedCommand struct { | |||
| 21 | + | cmd ports.ClientCommand | |||
| 22 | + | motion ports.PointerMotionTo // valid if isMotion | |||
| 23 | + | state ports.SecurityState // envelope state, valid if enveloped | |||
| 24 | + | // isMotion: cmd is a PointerMotionTo, bare or directly inside a | |||
| 25 | + | // SecurityCommand (then enveloped is set). | |||
| 26 | + | isMotion, enveloped bool | |||
| 27 | + | // merged: motion holds the sum of several motions and cmd is stale. | |||
| 28 | + | merged bool | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func queue(cmd ports.ClientCommand) queuedCommand { | |||
| 32 | + | q := queuedCommand{cmd: cmd} | |||
| 33 | + | if env, ok := cmd.(ports.SecurityCommand); ok { | |||
| 34 | + | q.state, q.enveloped = env.State, true | |||
| 35 | + | cmd = env.Command | |||
| 36 | + | } | |||
| 37 | + | q.motion, q.isMotion = cmd.(ports.PointerMotionTo) | |||
| 38 | + | return q | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (q *queuedCommand) command() ports.ClientCommand { | |||
| 42 | + | switch { | |||
| 43 | + | case !q.merged: | |||
| 44 | + | return q.cmd | |||
| 45 | + | case q.enveloped: | |||
| 46 | + | return ports.SecurityCommand{State: q.state, Command: q.motion} | |||
| 47 | + | } | |||
| 48 | + | return q.motion | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // mergeMotion folds v into last when both are pointer motion for the same | |||
| 52 | + | // window, both bare or both enveloped with an identical security state, and | |||
| 53 | + | // last is the most recent queued command, so no other command is ever | |||
| 54 | + | // overtaken and a bare motion never takes the place of an enveloped one. The | |||
| 55 | + | // position is the latest; relative deltas add up so locked pointers (games) | |||
| 56 | + | // lose no movement. | |||
| 57 | + | func mergeMotion(last *queuedCommand, v queuedCommand) bool { | |||
| 58 | + | if !last.isMotion || !v.isMotion || last.motion.ID != v.motion.ID || | |||
| 59 | + | last.enveloped != v.enveloped || last.state != v.state { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | m := v.motion | |||
| 63 | + | m.DX += last.motion.DX | |||
| 64 | + | m.DY += last.motion.DY | |||
| 65 | + | m.UnaccelDX += last.motion.UnaccelDX | |||
| 66 | + | m.UnaccelDY += last.motion.UnaccelDY | |||
| 67 | + | last.motion, last.merged = m, true | |||
| 68 | + | return true | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // newCommandRing returns the ring whose commands apply runs on the display | |||
| 72 | + | // goroutine, in arrival order. Adjacent motions for one window are coalesced | |||
| 73 | + | // at push time, against the last queued command only, under the ring mutex. | |||
| 74 | + | // That also coalesces while the display is busy (a drain-side batch would | |||
| 75 | + | // only see what the channel held at that moment), and a merge needs no room. | |||
| 76 | + | func newCommandRing(d *server.Display, apply func(queuedCommand)) *postRing[queuedCommand] { | |||
| 77 | + | return newMergingPostRing(d, commandRingSize, apply, mergeMotion) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // applyQueued runs on the display goroutine; it stops applying once the | |||
| 81 | + | // server context is done, like the per-command check of the old batch. | |||
| 82 | + | func (s *Server) applyQueued(q queuedCommand) { | |||
| 83 | + | if s.ctx != nil && s.ctx.Err() != nil { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | s.apply(q.command()) | |||
| 87 | + | } | |||
M internal/adapters/wayland/commands_test.go +141 -15
| @@ -10,16 +10,46 @@ import ( | |||
| 10 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestDrainCommandsCollapsesMotion(t *testing.T) { | |
| 14 | - | cmds := make(chan ports.ClientCommand, 16) | |
| 15 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5} | |
| 16 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1} | |
| 17 | - | cmds <- ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true} | |
| 18 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 4} | |
| 19 | - | cmds <- ports.PointerFocus{ID: 2} | |
| 20 | - | cmds <- ports.PointerMotionTo{ID: 2, X: 5} | |
| 21 | - | cmds <- ports.PointerMotionTo{ID: 2, X: 6} | |
| 22 | - | batch, open := drainCommands(ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5}, cmds) | |
| 13 | + | // Adjacent motions for one window fold into the last queued command, summing | |
| 14 | + | // the relative deltas; nothing is merged across another command, and the | |
| 15 | + | // order of arrival is kept. | |
| 16 | + | func TestCommandRingCollapsesMotionInOrder(t *testing.T) { | |
| 17 | + | s, _, _, _, _, _ := presentServer(t) | |
| 18 | + | locked := ports.SecurityState{Generation: 2, Protected: true} | |
| 19 | + | unlocked := ports.SecurityState{Generation: 3} | |
| 20 | + | var got []ports.ClientCommand // display goroutine only | |
| 21 | + | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | |
| 22 | + | release := make(chan struct{}) | |
| 23 | + | stalled := make(chan struct{}) | |
| 24 | + | s.display.Post(func() { close(stalled); <-release }) | |
| 25 | + | <-stalled | |
| 26 | + | for _, c := range []ports.ClientCommand{ | |
| 27 | + | ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5}, | |
| 28 | + | ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5}, | |
| 29 | + | ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1}, | |
| 30 | + | ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}, | |
| 31 | + | ports.PointerMotionTo{ID: 1, X: 4}, | |
| 32 | + | ports.PointerFocus{ID: 2}, | |
| 33 | + | ports.PointerMotionTo{ID: 2, X: 5}, | |
| 34 | + | ports.PointerMotionTo{ID: 2, X: 6}, | |
| 35 | + | ports.PointerMotionTo{ID: 1, X: 7}, // other window: not adjacent-same | |
| 36 | + | // Enveloped, as core sends them: same state merges, a different | |
| 37 | + | // state, a bare motion, or another window does not. | |
| 38 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 8, DX: 1, UnaccelDY: 2}}, | |
| 39 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 9, DX: 2, UnaccelDY: 3}}, | |
| 40 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DY: 4}}, | |
| 41 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 11, DX: 1}}, | |
| 42 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 1}}, | |
| 43 | + | ports.PointerMotionTo{ID: 1, X: 13}, | |
| 44 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}}, | |
| 45 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}}, | |
| 46 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}}, | |
| 47 | + | } { | |
| 48 | + | if !r.Push(queue(c)) { | |
| 49 | + | t.Fatal("push refused") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | close(release) | |
| 23 | 53 | want := []ports.ClientCommand{ | |
| 24 | 54 | // Slow motions add up: none of the sub-pixel deltas is lost. | |
| 25 | 55 | ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1}, | |
| @@ -27,13 +57,109 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) { | |||
| 27 | 57 | ports.PointerMotionTo{ID: 1, X: 4}, | |
| 28 | 58 | ports.PointerFocus{ID: 2}, | |
| 29 | 59 | ports.PointerMotionTo{ID: 2, X: 6}, | |
| 60 | + | ports.PointerMotionTo{ID: 1, X: 7}, | |
| 61 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DX: 3, DY: 4, UnaccelDY: 5}}, | |
| 62 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 2}}, | |
| 63 | + | ports.PointerMotionTo{ID: 1, X: 13}, | |
| 64 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}}, | |
| 65 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}}, | |
| 66 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}}, | |
| 67 | + | } | |
| 68 | + | var final []ports.ClientCommand | |
| 69 | + | s.display.Do(func() { final = got }) // runs after the drain posted before it | |
| 70 | + | if !reflect.DeepEqual(final, want) { | |
| 71 | + | t.Fatalf("applied %#v, want %#v", final, want) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // A merge needs no room: motion keeps folding into the last queued command | |
| 76 | + | // while the ring is full of other commands, and the push does not block. | |
| 77 | + | func TestCommandRingMergesWhenFull(t *testing.T) { | |
| 78 | + | s, _, _, _, _, _ := presentServer(t) | |
| 79 | + | var got []ports.ClientCommand | |
| 80 | + | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | |
| 81 | + | release := make(chan struct{}) | |
| 82 | + | stalled := make(chan struct{}) | |
| 83 | + | s.display.Post(func() { close(stalled); <-release }) | |
| 84 | + | <-stalled | |
| 85 | + | for i := range commandRingSize - 1 { | |
| 86 | + | r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)})) | |
| 87 | + | } | |
| 88 | + | done := make(chan struct{}) | |
| 89 | + | go func() { | |
| 90 | + | defer close(done) | |
| 91 | + | for range 1000 { // fills the ring with one motion, then folds the rest | |
| 92 | + | r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) | |
| 93 | + | } | |
| 94 | + | }() | |
| 95 | + | select { | |
| 96 | + | case <-done: | |
| 97 | + | case <-time.After(2 * time.Second): | |
| 98 | + | t.Fatal("motion push blocked on a full ring") | |
| 99 | + | } | |
| 100 | + | close(release) | |
| 101 | + | var last ports.ClientCommand | |
| 102 | + | s.display.Do(func() { last = got[len(got)-1] }) | |
| 103 | + | if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 { | |
| 104 | + | t.Fatalf("last applied %#v, want one motion with DX 1000", last) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // A busy display goroutine must not stall the forwarder: it keeps draining | |
| 109 | + | // the Commands channel into the ring up to the ring capacity, and only then | |
| 110 | + | // does it wait (and core with it, through the full channel). | |
| 111 | + | func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |
| 112 | + | cmds := make(chan ports.ClientCommand, 32) | |
| 113 | + | s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland")) | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 118 | + | done := make(chan error, 1) | |
| 119 | + | go func() { done <- s.Run(ctx) }() | |
| 120 | + | defer func() { cancel(); <-done }() | |
| 121 | + | ||
| 122 | + | release := make(chan struct{}) | |
| 123 | + | stalled := make(chan struct{}) | |
| 124 | + | s.display.Post(func() { close(stalled); <-release }) | |
| 125 | + | <-stalled | |
| 126 | + | ||
| 127 | + | // Not mergeable: every command takes a slot. | |
| 128 | + | const total = commandRingSize + 100 | |
| 129 | + | sent := make(chan struct{}) | |
| 130 | + | go func() { | |
| 131 | + | defer close(sent) | |
| 132 | + | for i := range total { | |
| 133 | + | select { | |
| 134 | + | case cmds <- ports.PointerFocus{ID: ports.WindowID(i + 1)}: | |
| 135 | + | case <-ctx.Done(): | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | }() | |
| 140 | + | queued := func() int { | |
| 141 | + | s.commands.mu.Lock() | |
| 142 | + | defer s.commands.mu.Unlock() | |
| 143 | + | return len(s.commands.ring) | |
| 144 | + | } | |
| 145 | + | // Ring full, one command in the blocked forwarder, a full channel. | |
| 146 | + | deadline := time.Now().Add(5 * time.Second) | |
| 147 | + | for queued() != commandRingSize || len(cmds) != cap(cmds) { | |
| 148 | + | if time.Now().After(deadline) { | |
| 149 | + | t.Fatalf("ring holds %d (want %d), channel %d (want %d)", queued(), commandRingSize, len(cmds), cap(cmds)) | |
| 150 | + | } | |
| 151 | + | time.Sleep(time.Millisecond) | |
| 30 | 152 | } | |
| 31 | - | if !open || !reflect.DeepEqual(batch, want) { | |
| 32 | - | t.Fatalf("open=%v batch=%#v", open, batch) | |
| 153 | + | select { | |
| 154 | + | case <-sent: | |
| 155 | + | t.Fatal("sender finished although the ring is full") | |
| 156 | + | default: | |
| 33 | 157 | } | |
| 34 | - | close(cmds) | |
| 35 | - | if _, open := drainCommands(ports.PointerFocus{}, cmds); open { | |
| 36 | - | t.Fatal("closed channel reported open") | |
| 158 | + | close(release) | |
| 159 | + | select { | |
| 160 | + | case <-sent: | |
| 161 | + | case <-time.After(5 * time.Second): | |
| 162 | + | t.Fatal("forwarder did not resume after the display was released") | |
| 37 | 163 | } | |
| 38 | 164 | } | |
| 39 | 165 | ||
| @@ -10,16 +10,46 @@ import ( | |||||
| 10 | "github.com/bnema/neferwl/internal/ports" | 10 | "github.com/bnema/neferwl/internal/ports" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | func TestDrainCommandsCollapsesMotion(t *testing.T) { | 13 | + | // Adjacent motions for one window fold into the last queued command, summing |
| 14 | - | cmds := make(chan ports.ClientCommand, 16) | 14 | + | // the relative deltas; nothing is merged across another command, and the |
| 15 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5} | 15 | + | // order of arrival is kept. |
| 16 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1} | 16 | + | func TestCommandRingCollapsesMotionInOrder(t *testing.T) { |
| 17 | - | cmds <- ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true} | 17 | + | s, _, _, _, _, _ := presentServer(t) |
| 18 | - | cmds <- ports.PointerMotionTo{ID: 1, X: 4} | 18 | + | locked := ports.SecurityState{Generation: 2, Protected: true} |
| 19 | - | cmds <- ports.PointerFocus{ID: 2} | 19 | + | unlocked := ports.SecurityState{Generation: 3} |
| 20 | - | cmds <- ports.PointerMotionTo{ID: 2, X: 5} | 20 | + | var got []ports.ClientCommand // display goroutine only |
| 21 | - | cmds <- ports.PointerMotionTo{ID: 2, X: 6} | 21 | + | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) |
| 22 | - | batch, open := drainCommands(ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5}, cmds) | 22 | + | release := make(chan struct{}) |
| 23 | + | stalled := make(chan struct{}) | |||
| 24 | + | s.display.Post(func() { close(stalled); <-release }) | |||
| 25 | + | <-stalled | |||
| 26 | + | for _, c := range []ports.ClientCommand{ | |||
| 27 | + | ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5}, | |||
| 28 | + | ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5}, | |||
| 29 | + | ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1}, | |||
| 30 | + | ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}, | |||
| 31 | + | ports.PointerMotionTo{ID: 1, X: 4}, | |||
| 32 | + | ports.PointerFocus{ID: 2}, | |||
| 33 | + | ports.PointerMotionTo{ID: 2, X: 5}, | |||
| 34 | + | ports.PointerMotionTo{ID: 2, X: 6}, | |||
| 35 | + | ports.PointerMotionTo{ID: 1, X: 7}, // other window: not adjacent-same | |||
| 36 | + | // Enveloped, as core sends them: same state merges, a different | |||
| 37 | + | // state, a bare motion, or another window does not. | |||
| 38 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 8, DX: 1, UnaccelDY: 2}}, | |||
| 39 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 9, DX: 2, UnaccelDY: 3}}, | |||
| 40 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DY: 4}}, | |||
| 41 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 11, DX: 1}}, | |||
| 42 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 1}}, | |||
| 43 | + | ports.PointerMotionTo{ID: 1, X: 13}, | |||
| 44 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}}, | |||
| 45 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}}, | |||
| 46 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}}, | |||
| 47 | + | } { | |||
| 48 | + | if !r.Push(queue(c)) { | |||
| 49 | + | t.Fatal("push refused") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | close(release) | |||
| 23 | want := []ports.ClientCommand{ | 53 | want := []ports.ClientCommand{ | ||
| 24 | // Slow motions add up: none of the sub-pixel deltas is lost. | 54 | // Slow motions add up: none of the sub-pixel deltas is lost. | ||
| 25 | ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1}, | 55 | ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1}, | ||
| @@ -27,13 +57,109 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) { | |||||
| 27 | ports.PointerMotionTo{ID: 1, X: 4}, | 57 | ports.PointerMotionTo{ID: 1, X: 4}, | ||
| 28 | ports.PointerFocus{ID: 2}, | 58 | ports.PointerFocus{ID: 2}, | ||
| 29 | ports.PointerMotionTo{ID: 2, X: 6}, | 59 | ports.PointerMotionTo{ID: 2, X: 6}, | ||
| 60 | + | ports.PointerMotionTo{ID: 1, X: 7}, | |||
| 61 | + | ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DX: 3, DY: 4, UnaccelDY: 5}}, | |||
| 62 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 2}}, | |||
| 63 | + | ports.PointerMotionTo{ID: 1, X: 13}, | |||
| 64 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}}, | |||
| 65 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}}, | |||
| 66 | + | ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}}, | |||
| 67 | + | } | |||
| 68 | + | var final []ports.ClientCommand | |||
| 69 | + | s.display.Do(func() { final = got }) // runs after the drain posted before it | |||
| 70 | + | if !reflect.DeepEqual(final, want) { | |||
| 71 | + | t.Fatalf("applied %#v, want %#v", final, want) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // A merge needs no room: motion keeps folding into the last queued command | |||
| 76 | + | // while the ring is full of other commands, and the push does not block. | |||
| 77 | + | func TestCommandRingMergesWhenFull(t *testing.T) { | |||
| 78 | + | s, _, _, _, _, _ := presentServer(t) | |||
| 79 | + | var got []ports.ClientCommand | |||
| 80 | + | r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) }) | |||
| 81 | + | release := make(chan struct{}) | |||
| 82 | + | stalled := make(chan struct{}) | |||
| 83 | + | s.display.Post(func() { close(stalled); <-release }) | |||
| 84 | + | <-stalled | |||
| 85 | + | for i := range commandRingSize - 1 { | |||
| 86 | + | r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)})) | |||
| 87 | + | } | |||
| 88 | + | done := make(chan struct{}) | |||
| 89 | + | go func() { | |||
| 90 | + | defer close(done) | |||
| 91 | + | for range 1000 { // fills the ring with one motion, then folds the rest | |||
| 92 | + | r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) | |||
| 93 | + | } | |||
| 94 | + | }() | |||
| 95 | + | select { | |||
| 96 | + | case <-done: | |||
| 97 | + | case <-time.After(2 * time.Second): | |||
| 98 | + | t.Fatal("motion push blocked on a full ring") | |||
| 99 | + | } | |||
| 100 | + | close(release) | |||
| 101 | + | var last ports.ClientCommand | |||
| 102 | + | s.display.Do(func() { last = got[len(got)-1] }) | |||
| 103 | + | if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 { | |||
| 104 | + | t.Fatalf("last applied %#v, want one motion with DX 1000", last) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // A busy display goroutine must not stall the forwarder: it keeps draining | |||
| 109 | + | // the Commands channel into the ring up to the ring capacity, and only then | |||
| 110 | + | // does it wait (and core with it, through the full channel). | |||
| 111 | + | func TestForwarderDrainsWhileDisplayBusy(t *testing.T) { | |||
| 112 | + | cmds := make(chan ports.ClientCommand, 32) | |||
| 113 | + | s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland")) | |||
| 114 | + | if err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 118 | + | done := make(chan error, 1) | |||
| 119 | + | go func() { done <- s.Run(ctx) }() | |||
| 120 | + | defer func() { cancel(); <-done }() | |||
| 121 | + | ||||
| 122 | + | release := make(chan struct{}) | |||
| 123 | + | stalled := make(chan struct{}) | |||
| 124 | + | s.display.Post(func() { close(stalled); <-release }) | |||
| 125 | + | <-stalled | |||
| 126 | + | ||||
| 127 | + | // Not mergeable: every command takes a slot. | |||
| 128 | + | const total = commandRingSize + 100 | |||
| 129 | + | sent := make(chan struct{}) | |||
| 130 | + | go func() { | |||
| 131 | + | defer close(sent) | |||
| 132 | + | for i := range total { | |||
| 133 | + | select { | |||
| 134 | + | case cmds <- ports.PointerFocus{ID: ports.WindowID(i + 1)}: | |||
| 135 | + | case <-ctx.Done(): | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | }() | |||
| 140 | + | queued := func() int { | |||
| 141 | + | s.commands.mu.Lock() | |||
| 142 | + | defer s.commands.mu.Unlock() | |||
| 143 | + | return len(s.commands.ring) | |||
| 144 | + | } | |||
| 145 | + | // Ring full, one command in the blocked forwarder, a full channel. | |||
| 146 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 147 | + | for queued() != commandRingSize || len(cmds) != cap(cmds) { | |||
| 148 | + | if time.Now().After(deadline) { | |||
| 149 | + | t.Fatalf("ring holds %d (want %d), channel %d (want %d)", queued(), commandRingSize, len(cmds), cap(cmds)) | |||
| 150 | + | } | |||
| 151 | + | time.Sleep(time.Millisecond) | |||
| 30 | } | 152 | } | ||
| 31 | - | if !open || !reflect.DeepEqual(batch, want) { | 153 | + | select { |
| 32 | - | t.Fatalf("open=%v batch=%#v", open, batch) | 154 | + | case <-sent: |
| 155 | + | t.Fatal("sender finished although the ring is full") | |||
| 156 | + | default: | |||
| 33 | } | 157 | } | ||
| 34 | - | close(cmds) | 158 | + | close(release) |
| 35 | - | if _, open := drainCommands(ports.PointerFocus{}, cmds); open { | 159 | + | select { |
| 36 | - | t.Fatal("closed channel reported open") | 160 | + | case <-sent: |
| 161 | + | case <-time.After(5 * time.Second): | |||
| 162 | + | t.Fatal("forwarder did not resume after the display was released") | |||
| 37 | } | 163 | } | ||
| 38 | } | 164 | } | ||
| 39 | 165 | ||||
M internal/adapters/wayland/postring.go +20 -2
| @@ -8,8 +8,10 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // postRing carries values from producer goroutines to the display goroutine | |
| 10 | 10 | // without allocating. It is a transport queue, not window state: the mutex | |
| 11 | - | // guards only the slices and the pending flag. Values are never merged or | |
| 12 | - | // dropped, in order of arrival. | |
| 11 | + | // guards only the slices and the pending flag. Values are applied in order of | |
| 12 | + | // arrival and never dropped. A ring built with a merge function may fold a | |
| 13 | + | // pushed value into the last queued one (and only that one, under the mutex), | |
| 14 | + | // so nothing is ever reordered. | |
| 13 | 15 | // | |
| 14 | 16 | // It binds its drain once and posts it at most once per batch: Display.Post | |
| 15 | 17 | // is unbounded (a Post per value would let a fast producer grow the queue), | |
| @@ -18,6 +20,9 @@ type postRing[T any] struct { | |||
| 18 | 20 | display *server.Display | |
| 19 | 21 | apply func(T) | |
| 20 | 22 | drainFn func() | |
| 23 | + | // merge, when set, tries to fold v into the last queued value, which has | |
| 24 | + | // not been drained yet. It runs under mu and must not block or allocate. | |
| 25 | + | merge func(last *T, v T) bool | |
| 21 | 26 | // space is signalled (never blocking) after each drain and after a push | |
| 22 | 27 | // that left room, to wake blocked pushers one after the other. | |
| 23 | 28 | space chan struct{} | |
| @@ -31,6 +36,12 @@ type postRing[T any] struct { | |||
| 31 | 36 | // newPostRing returns a ring of limit values whose values apply runs on the | |
| 32 | 37 | // display goroutine, in order. limit must be at least 1. | |
| 33 | 38 | func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] { | |
| 39 | + | return newMergingPostRing(d, limit, apply, nil) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // newMergingPostRing is newPostRing with a merge function (see postRing). A | |
| 43 | + | // merged push needs no room: it succeeds on a full ring. | |
| 44 | + | func newMergingPostRing[T any](d *server.Display, limit int, apply func(T), merge func(last *T, v T) bool) *postRing[T] { | |
| 34 | 45 | r := &postRing[T]{ | |
| 35 | 46 | display: d, | |
| 36 | 47 | apply: apply, | |
| @@ -38,6 +49,7 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||
| 38 | 49 | ring: make([]T, 0, limit), | |
| 39 | 50 | spare: make([]T, 0, limit), | |
| 40 | 51 | limit: limit, | |
| 52 | + | merge: merge, | |
| 41 | 53 | } | |
| 42 | 54 | r.drainFn = r.drain | |
| 43 | 55 | return r | |
| @@ -49,6 +61,12 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||
| 49 | 61 | func (r *postRing[T]) Push(v T) bool { | |
| 50 | 62 | for { | |
| 51 | 63 | r.mu.Lock() | |
| 64 | + | // A non-empty ring always has its drain posted, so the last value is | |
| 65 | + | // still queued: merging into it needs neither room nor a new post. | |
| 66 | + | if n := len(r.ring); r.merge != nil && n > 0 && r.merge(&r.ring[n-1], v) { | |
| 67 | + | r.mu.Unlock() | |
| 68 | + | return true | |
| 69 | + | } | |
| 52 | 70 | if len(r.ring) < r.limit { | |
| 53 | 71 | r.ring = append(r.ring, v) | |
| 54 | 72 | post := !r.pending | |
| @@ -8,8 +8,10 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // postRing carries values from producer goroutines to the display goroutine | 9 | // postRing carries values from producer goroutines to the display goroutine | ||
| 10 | // without allocating. It is a transport queue, not window state: the mutex | 10 | // without allocating. It is a transport queue, not window state: the mutex | ||
| 11 | - | // guards only the slices and the pending flag. Values are never merged or | 11 | + | // guards only the slices and the pending flag. Values are applied in order of |
| 12 | - | // dropped, in order of arrival. | 12 | + | // arrival and never dropped. A ring built with a merge function may fold a |
| 13 | + | // pushed value into the last queued one (and only that one, under the mutex), | |||
| 14 | + | // so nothing is ever reordered. | |||
| 13 | // | 15 | // | ||
| 14 | // It binds its drain once and posts it at most once per batch: Display.Post | 16 | // It binds its drain once and posts it at most once per batch: Display.Post | ||
| 15 | // is unbounded (a Post per value would let a fast producer grow the queue), | 17 | // is unbounded (a Post per value would let a fast producer grow the queue), | ||
| @@ -18,6 +20,9 @@ type postRing[T any] struct { | |||||
| 18 | display *server.Display | 20 | display *server.Display | ||
| 19 | apply func(T) | 21 | apply func(T) | ||
| 20 | drainFn func() | 22 | drainFn func() | ||
| 23 | + | // merge, when set, tries to fold v into the last queued value, which has | |||
| 24 | + | // not been drained yet. It runs under mu and must not block or allocate. | |||
| 25 | + | merge func(last *T, v T) bool | |||
| 21 | // space is signalled (never blocking) after each drain and after a push | 26 | // space is signalled (never blocking) after each drain and after a push | ||
| 22 | // that left room, to wake blocked pushers one after the other. | 27 | // that left room, to wake blocked pushers one after the other. | ||
| 23 | space chan struct{} | 28 | space chan struct{} | ||
| @@ -31,6 +36,12 @@ type postRing[T any] struct { | |||||
| 31 | // newPostRing returns a ring of limit values whose values apply runs on the | 36 | // newPostRing returns a ring of limit values whose values apply runs on the | ||
| 32 | // display goroutine, in order. limit must be at least 1. | 37 | // display goroutine, in order. limit must be at least 1. | ||
| 33 | func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] { | 38 | func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] { | ||
| 39 | + | return newMergingPostRing(d, limit, apply, nil) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // newMergingPostRing is newPostRing with a merge function (see postRing). A | |||
| 43 | + | // merged push needs no room: it succeeds on a full ring. | |||
| 44 | + | func newMergingPostRing[T any](d *server.Display, limit int, apply func(T), merge func(last *T, v T) bool) *postRing[T] { | |||
| 34 | r := &postRing[T]{ | 45 | r := &postRing[T]{ | ||
| 35 | display: d, | 46 | display: d, | ||
| 36 | apply: apply, | 47 | apply: apply, | ||
| @@ -38,6 +49,7 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||||
| 38 | ring: make([]T, 0, limit), | 49 | ring: make([]T, 0, limit), | ||
| 39 | spare: make([]T, 0, limit), | 50 | spare: make([]T, 0, limit), | ||
| 40 | limit: limit, | 51 | limit: limit, | ||
| 52 | + | merge: merge, | |||
| 41 | } | 53 | } | ||
| 42 | r.drainFn = r.drain | 54 | r.drainFn = r.drain | ||
| 43 | return r | 55 | return r | ||
| @@ -49,6 +61,12 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||||
| 49 | func (r *postRing[T]) Push(v T) bool { | 61 | func (r *postRing[T]) Push(v T) bool { | ||
| 50 | for { | 62 | for { | ||
| 51 | r.mu.Lock() | 63 | r.mu.Lock() | ||
| 64 | + | // A non-empty ring always has its drain posted, so the last value is | |||
| 65 | + | // still queued: merging into it needs neither room nor a new post. | |||
| 66 | + | if n := len(r.ring); r.merge != nil && n > 0 && r.merge(&r.ring[n-1], v) { | |||
| 67 | + | r.mu.Unlock() | |||
| 68 | + | return true | |||
| 69 | + | } | |||
| 52 | if len(r.ring) < r.limit { | 70 | if len(r.ring) < r.limit { | ||
| 53 | r.ring = append(r.ring, v) | 71 | r.ring = append(r.ring, v) | ||
| 54 | post := !r.pending | 72 | post := !r.pending | ||
M internal/adapters/wayland/server.go +10 -51
| @@ -131,10 +131,12 @@ type Server struct { | |||
| 131 | 131 | // Screencast (screencast.go): casts by the adapter's ID and by the ID | |
| 132 | 132 | // core knows them by; castRing carries the adapter's commands to the | |
| 133 | 133 | // display goroutine; the castInv fields coalesce inventory publication. | |
| 134 | - | casts ports.CastSink | |
| 135 | - | castSessions map[uint64]*castSession | |
| 136 | - | castByCore map[uint64]*castSession | |
| 137 | - | castRing *postRing[ports.CastCommand] | |
| 134 | + | casts ports.CastSink | |
| 135 | + | castSessions map[uint64]*castSession | |
| 136 | + | castByCore map[uint64]*castSession | |
| 137 | + | castRing *postRing[ports.CastCommand] | |
| 138 | + | // commands carries core's client commands to the display goroutine. | |
| 139 | + | commands *postRing[queuedCommand] | |
| 138 | 140 | castInvFn func() | |
| 139 | 141 | castInvPosted bool | |
| 140 | 142 | castInvSent bool | |
| @@ -389,6 +391,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 389 | 391 | s.casts = opts.Casts | |
| 390 | 392 | s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{} | |
| 391 | 393 | s.castRing = newPostRing(d, castRingSize, s.applyCast) | |
| 394 | + | s.commands = newCommandRing(d, s.applyQueued) | |
| 392 | 395 | s.castInvFn = s.publishCastInventory | |
| 393 | 396 | s.leaseDevices = map[string]*leaseDevice{} | |
| 394 | 397 | s.pendingLeases = map[uint64]*leaseObject{} | |
| @@ -555,18 +558,9 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 555 | 558 | if ctx.Err() != nil { | |
| 556 | 559 | return | |
| 557 | 560 | } | |
| 558 | - | // One display round trip per batch: Do waits for the event | |
| 559 | - | // loop, so a round trip per motion capped pointer events at | |
| 560 | - | // ~150/s and let a backlog of stale motions build up. | |
| 561 | - | batch, open := drainCommands(cmd, s.channels.Commands) | |
| 562 | - | if !s.display.Do(func() { | |
| 563 | - | for _, c := range batch { | |
| 564 | - | if ctx.Err() != nil { | |
| 565 | - | return | |
| 566 | - | } | |
| 567 | - | s.apply(c) | |
| 568 | - | } | |
| 569 | - | }) || !open { | |
| 561 | + | // Never wait for the display goroutine: a busy display only | |
| 562 | + | // fills the ring, and only then does this goroutine wait. | |
| 563 | + | if !s.commands.Push(queue(cmd)) { | |
| 570 | 564 | return | |
| 571 | 565 | } | |
| 572 | 566 | } | |
| @@ -746,41 +740,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 746 | 740 | s.castCommitted(c.ID) | |
| 747 | 741 | } | |
| 748 | 742 | ||
| 749 | - | // maxCommandBatch bounds how many queued commands one display round trip applies. | |
| 750 | - | const maxCommandBatch = 256 | |
| 751 | - | ||
| 752 | - | // drainCommands returns first plus the commands already queued, with runs of | |
| 753 | - | // pointer motion for the same window collapsed into the latest one, their | |
| 754 | - | // relative deltas summed. open is | |
| 755 | - | // false once the channel is closed. | |
| 756 | - | func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (batch []ports.ClientCommand, open bool) { | |
| 757 | - | batch = append(batch, first) | |
| 758 | - | for len(batch) < maxCommandBatch { | |
| 759 | - | select { | |
| 760 | - | case c, ok := <-cmds: | |
| 761 | - | if !ok { | |
| 762 | - | return batch, false | |
| 763 | - | } | |
| 764 | - | if m, isMotion := c.(ports.PointerMotionTo); isMotion { | |
| 765 | - | if last, lastMotion := batch[len(batch)-1].(ports.PointerMotionTo); lastMotion && last.ID == m.ID { | |
| 766 | - | // The position is the latest; relative deltas add up so | |
| 767 | - | // locked pointers (games) lose no movement. | |
| 768 | - | m.DX += last.DX | |
| 769 | - | m.DY += last.DY | |
| 770 | - | m.UnaccelDX += last.UnaccelDX | |
| 771 | - | m.UnaccelDY += last.UnaccelDY | |
| 772 | - | batch[len(batch)-1] = m | |
| 773 | - | continue | |
| 774 | - | } | |
| 775 | - | } | |
| 776 | - | batch = append(batch, c) | |
| 777 | - | default: | |
| 778 | - | return batch, true | |
| 779 | - | } | |
| 780 | - | } | |
| 781 | - | return batch, true | |
| 782 | - | } | |
| 783 | - | ||
| 784 | 743 | func (s *Server) apply(cmd ports.ClientCommand) { | |
| 785 | 744 | if envelope, ok := cmd.(ports.SecurityCommand); ok { | |
| 786 | 745 | if s.security == nil || envelope.State != s.security.Snapshot() { | |
| @@ -131,10 +131,12 @@ type Server struct { | |||||
| 131 | // Screencast (screencast.go): casts by the adapter's ID and by the ID | 131 | // Screencast (screencast.go): casts by the adapter's ID and by the ID | ||
| 132 | // core knows them by; castRing carries the adapter's commands to the | 132 | // core knows them by; castRing carries the adapter's commands to the | ||
| 133 | // display goroutine; the castInv fields coalesce inventory publication. | 133 | // display goroutine; the castInv fields coalesce inventory publication. | ||
| 134 | - | casts ports.CastSink | 134 | + | casts ports.CastSink |
| 135 | - | castSessions map[uint64]*castSession | 135 | + | castSessions map[uint64]*castSession |
| 136 | - | castByCore map[uint64]*castSession | 136 | + | castByCore map[uint64]*castSession |
| 137 | - | castRing *postRing[ports.CastCommand] | 137 | + | castRing *postRing[ports.CastCommand] |
| 138 | + | // commands carries core's client commands to the display goroutine. | |||
| 139 | + | commands *postRing[queuedCommand] | |||
| 138 | castInvFn func() | 140 | castInvFn func() | ||
| 139 | castInvPosted bool | 141 | castInvPosted bool | ||
| 140 | castInvSent bool | 142 | castInvSent bool | ||
| @@ -389,6 +391,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 389 | s.casts = opts.Casts | 391 | s.casts = opts.Casts | ||
| 390 | s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{} | 392 | s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{} | ||
| 391 | s.castRing = newPostRing(d, castRingSize, s.applyCast) | 393 | s.castRing = newPostRing(d, castRingSize, s.applyCast) | ||
| 394 | + | s.commands = newCommandRing(d, s.applyQueued) | |||
| 392 | s.castInvFn = s.publishCastInventory | 395 | s.castInvFn = s.publishCastInventory | ||
| 393 | s.leaseDevices = map[string]*leaseDevice{} | 396 | s.leaseDevices = map[string]*leaseDevice{} | ||
| 394 | s.pendingLeases = map[uint64]*leaseObject{} | 397 | s.pendingLeases = map[uint64]*leaseObject{} | ||
| @@ -555,18 +558,9 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 555 | if ctx.Err() != nil { | 558 | if ctx.Err() != nil { | ||
| 556 | return | 559 | return | ||
| 557 | } | 560 | } | ||
| 558 | - | // One display round trip per batch: Do waits for the event | 561 | + | // Never wait for the display goroutine: a busy display only |
| 559 | - | // loop, so a round trip per motion capped pointer events at | 562 | + | // fills the ring, and only then does this goroutine wait. |
| 560 | - | // ~150/s and let a backlog of stale motions build up. | 563 | + | if !s.commands.Push(queue(cmd)) { |
| 561 | - | batch, open := drainCommands(cmd, s.channels.Commands) | |||
| 562 | - | if !s.display.Do(func() { | |||
| 563 | - | for _, c := range batch { | |||
| 564 | - | if ctx.Err() != nil { | |||
| 565 | - | return | |||
| 566 | - | } | |||
| 567 | - | s.apply(c) | |||
| 568 | - | } | |||
| 569 | - | }) || !open { | |||
| 570 | return | 564 | return | ||
| 571 | } | 565 | } | ||
| 572 | } | 566 | } | ||
| @@ -746,41 +740,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||||
| 746 | s.castCommitted(c.ID) | 740 | s.castCommitted(c.ID) | ||
| 747 | } | 741 | } | ||
| 748 | 742 | ||||
| 749 | - | // maxCommandBatch bounds how many queued commands one display round trip applies. | |||
| 750 | - | const maxCommandBatch = 256 | |||
| 751 | - | ||||
| 752 | - | // drainCommands returns first plus the commands already queued, with runs of | |||
| 753 | - | // pointer motion for the same window collapsed into the latest one, their | |||
| 754 | - | // relative deltas summed. open is | |||
| 755 | - | // false once the channel is closed. | |||
| 756 | - | func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (batch []ports.ClientCommand, open bool) { | |||
| 757 | - | batch = append(batch, first) | |||
| 758 | - | for len(batch) < maxCommandBatch { | |||
| 759 | - | select { | |||
| 760 | - | case c, ok := <-cmds: | |||
| 761 | - | if !ok { | |||
| 762 | - | return batch, false | |||
| 763 | - | } | |||
| 764 | - | if m, isMotion := c.(ports.PointerMotionTo); isMotion { | |||
| 765 | - | if last, lastMotion := batch[len(batch)-1].(ports.PointerMotionTo); lastMotion && last.ID == m.ID { | |||
| 766 | - | // The position is the latest; relative deltas add up so | |||
| 767 | - | // locked pointers (games) lose no movement. | |||
| 768 | - | m.DX += last.DX | |||
| 769 | - | m.DY += last.DY | |||
| 770 | - | m.UnaccelDX += last.UnaccelDX | |||
| 771 | - | m.UnaccelDY += last.UnaccelDY | |||
| 772 | - | batch[len(batch)-1] = m | |||
| 773 | - | continue | |||
| 774 | - | } | |||
| 775 | - | } | |||
| 776 | - | batch = append(batch, c) | |||
| 777 | - | default: | |||
| 778 | - | return batch, true | |||
| 779 | - | } | |||
| 780 | - | } | |||
| 781 | - | return batch, true | |||
| 782 | - | } | |||
| 783 | - | ||||
| 784 | func (s *Server) apply(cmd ports.ClientCommand) { | 743 | func (s *Server) apply(cmd ports.ClientCommand) { | ||
| 785 | if envelope, ok := cmd.(ports.SecurityCommand); ok { | 744 | if envelope, ok := cmd.(ports.SecurityCommand); ok { | ||
| 786 | if s.security == nil || envelope.State != s.security.Snapshot() { | 745 | if s.security == nil || envelope.State != s.security.Snapshot() { | ||