detached dc5dff6

Merge pull request 'fix(drm): stop pointer moves from stuttering video; decouple output, command and input streams' (#62) from refactor/output-commit-scheduling into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/62
commit
dc5dff61334a51096a0e2c54e3d44b3b0aa6540c
author
bnema · · 2026-10-18 06:23 UTC
merge of
71aa990 + 7c51167 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
33 files · +2369 -304
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 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|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # 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=172+ 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|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/drm/flip_stats.go +4 -0
@@ -26,6 +26,10 @@ type flipStats struct {
2626 // vblank the frame targeted (never under VRR). Fence times are only
2727 // read while flip tracing is on.
2828 lateFences int
29+ // stateCommits counts state-only commits (cursor, VRR, content type);
30+ // stateCarried counts deferred state changes a frame carried instead.
31+ // Many state commits while a video plays point at a lost vblank each.
32+ stateCommits, stateCarried int
2933 }
3034
3135 // nominalPeriod is the time between two vblanks at the mode's refresh
@@ -26,6 +26,10 @@ type flipStats struct {
26 // vblank the frame targeted (never under VRR). Fence times are only26 // vblank the frame targeted (never under VRR). Fence times are only
27 // read while flip tracing is on.27 // read while flip tracing is on.
28 lateFences int28 lateFences int
29+ // stateCommits counts state-only commits (cursor, VRR, content type);
30+ // stateCarried counts deferred state changes a frame carried instead.
31+ // Many state commits while a video plays point at a lost vblank each.
32+ stateCommits, stateCarried int
29 }33 }
30 34
31 // nominalPeriod is the time between two vblanks at the mode's refresh35 // nominalPeriod is the time between two vblanks at the mode's refresh
M internal/adapters/drm/output.go +39 -0
@@ -157,6 +157,11 @@ type Output struct {
157157 // move waits for the next frame (see cursorWaits).
158158 lastFrame time.Time
159159 cursorHeld bool
160+ // stateAt is when a deferred state commit is due (zero: none);
161+ // lastVblankAt is the kernel timestamp of the last vsynced flip, frame
162+ // or state (CLOCK_MONOTONIC, 0: unknown). See state_schedule.go.
163+ stateAt time.Time
164+ lastVblankAt time.Duration
160165 // traceFlips logs every completion with its timing; lastFlipAt is
161166 // the previous frame flip's kernel timestamp (always kept).
162167 // flipStats are reported and reset with the periodic stats. wantedAt
@@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
455460 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
456461 // The gap and the flip interval belonged to the old state.
457462 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0
463+ o.stateAt, o.lastVblankAt = time.Time{}, 0
458464 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
459465 o.planeRect = fullPlaneRect(o.Width(), o.Height())
460466 if o.cursor != nil {
@@ -494,6 +500,8 @@ func (o *Output) powerOff() error {
494500 }
495501 o.colorBypassed()
496502 o.off, o.vrrGame = true, false
503+ // No vblank to aim at: the modeset that turns it on commits the state.
504+ o.stateAt, o.lastVblankAt = time.Time{}, 0
497505 o.setVRR(false)
498506 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
499507 o.log.Info().Str("connector", o.conn.name).Msg("power off")
@@ -721,6 +729,11 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
721729 o.contentValue = o.contentWanted
722730 o.planeRect = rect
723731 o.lastFrame, o.cursorHeld = time.Now(), false
732+ // The frame carries every deferred state change.
733+ if !o.stateAt.IsZero() {
734+ o.flipStats.stateCarried++
735+ }
736+ o.stateAt = time.Time{}
724737 f.frame, f.async = true, async
725738 f.wantedAt, o.wantedAt = o.wantedAt, 0
726739 f.gapEnd, o.flipGapAt = o.flipGapAt, 0
@@ -774,6 +787,14 @@ func (o *Output) commitState(vrr bool) error {
774787 if o.protected {
775788 return errSecurityScene
776789 }
790+ if !o.stateChanged(vrr) {
791+ // A change undone before its commit holds nothing.
792+ o.stateAt, o.cursorHeld = time.Time{}, false
793+ return nil
794+ }
795+ if !o.stateDue(time.Now(), monotonic()) {
796+ return nil // a frame before then carries it; else the state timer
797+ }
777798 req := &o.stateReq
778799 req.reset()
779800 cur := cursorState{}
@@ -839,6 +860,7 @@ func (o *Output) commitState(vrr bool) error {
839860 }
840861 o.setVRR(vrr)
841862 o.contentValue = o.contentWanted
863+ o.flipStats.stateCommits++
842864 o.frame.begin(pendingFrame{}, time.Now())
843865 return nil
844866 }
@@ -1097,6 +1119,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10971119 retryTimer := time.NewTimer(time.Hour)
10981120 vrrTimer := time.NewTimer(time.Hour)
10991121 cursorTimer := time.NewTimer(time.Hour)
1122+ stateTimer := time.NewTimer(time.Hour)
1123+ stateTimer.Stop()
1124+ defer stateTimer.Stop()
11001125 protectTimer := time.NewTimer(time.Hour)
11011126 o.frame.startTimer()
11021127 gapTimer := time.NewTimer(time.Hour)
@@ -1266,6 +1291,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12661291 } else {
12671292 cursorTimer.Stop()
12681293 }
1294+ // A deferred state change commits alone if no frame carried it.
1295+ var stateDue <-chan time.Time
1296+ if enabled && !o.protected && !o.off && !o.stateAt.IsZero() && !o.frame.pendingCommit() {
1297+ stateTimer.Reset(max(0, time.Until(o.stateAt)))
1298+ stateDue = stateTimer.C
1299+ } else {
1300+ stateTimer.Stop()
1301+ }
12691302 // A game frame waiting out the VRR flip gap commits when it ends.
12701303 var gapDue <-chan time.Time
12711304 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
@@ -1338,6 +1371,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13381371 stateDirty = true
13391372 case <-cursorDue:
13401373 stateDirty = true
1374+ case <-stateDue:
1375+ stateDirty = true
13411376 case <-cursorWake:
13421377 stateDirty = true
13431378 case <-gapDue:
@@ -1462,6 +1497,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14621497 fs := o.takeFlipStats()
14631498 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
14641499 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
1500+ ev = ev.Int("state_commits", fs.stateCommits).Int("state_carried", fs.stateCarried)
14651501 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())
14661502 if o.traceFlips {
14671503 ev = ev.Int("late_fences", fs.lateFences)
@@ -1638,6 +1674,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
16381674 return false
16391675 }
16401676 o.flipDone(ev, f, start, ours, trace, fenceAt)
1677+ if ours {
1678+ o.noteVblank(f, ev.when)
1679+ }
16411680 if o.cursor != nil {
16421681 o.cursor.landed()
16431682 }
@@ -157,6 +157,11 @@ type Output struct {
157 // move waits for the next frame (see cursorWaits).157 // move waits for the next frame (see cursorWaits).
158 lastFrame time.Time158 lastFrame time.Time
159 cursorHeld bool159 cursorHeld bool
160+ // stateAt is when a deferred state commit is due (zero: none);
161+ // lastVblankAt is the kernel timestamp of the last vsynced flip, frame
162+ // or state (CLOCK_MONOTONIC, 0: unknown). See state_schedule.go.
163+ stateAt time.Time
164+ lastVblankAt time.Duration
160 // traceFlips logs every completion with its timing; lastFlipAt is165 // traceFlips logs every completion with its timing; lastFlipAt is
161 // the previous frame flip's kernel timestamp (always kept).166 // the previous frame flip's kernel timestamp (always kept).
162 // flipStats are reported and reset with the periodic stats. wantedAt167 // flipStats are reported and reset with the periodic stats. wantedAt
@@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
455 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active460 o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
456 // The gap and the flip interval belonged to the old state.461 // The gap and the flip interval belonged to the old state.
457 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0462 o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0
463+ o.stateAt, o.lastVblankAt = time.Time{}, 0
458 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]464 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
459 o.planeRect = fullPlaneRect(o.Width(), o.Height())465 o.planeRect = fullPlaneRect(o.Width(), o.Height())
460 if o.cursor != nil {466 if o.cursor != nil {
@@ -494,6 +500,8 @@ func (o *Output) powerOff() error {
494 }500 }
495 o.colorBypassed()501 o.colorBypassed()
496 o.off, o.vrrGame = true, false502 o.off, o.vrrGame = true, false
503+ // No vblank to aim at: the modeset that turns it on commits the state.
504+ o.stateAt, o.lastVblankAt = time.Time{}, 0
497 o.setVRR(false)505 o.setVRR(false)
498 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]506 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
499 o.log.Info().Str("connector", o.conn.name).Msg("power off")507 o.log.Info().Str("connector", o.conn.name).Msg("power off")
@@ -721,6 +729,11 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
721 o.contentValue = o.contentWanted729 o.contentValue = o.contentWanted
722 o.planeRect = rect730 o.planeRect = rect
723 o.lastFrame, o.cursorHeld = time.Now(), false731 o.lastFrame, o.cursorHeld = time.Now(), false
732+ // The frame carries every deferred state change.
733+ if !o.stateAt.IsZero() {
734+ o.flipStats.stateCarried++
735+ }
736+ o.stateAt = time.Time{}
724 f.frame, f.async = true, async737 f.frame, f.async = true, async
725 f.wantedAt, o.wantedAt = o.wantedAt, 0738 f.wantedAt, o.wantedAt = o.wantedAt, 0
726 f.gapEnd, o.flipGapAt = o.flipGapAt, 0739 f.gapEnd, o.flipGapAt = o.flipGapAt, 0
@@ -774,6 +787,14 @@ func (o *Output) commitState(vrr bool) error {
774 if o.protected {787 if o.protected {
775 return errSecurityScene788 return errSecurityScene
776 }789 }
790+ if !o.stateChanged(vrr) {
791+ // A change undone before its commit holds nothing.
792+ o.stateAt, o.cursorHeld = time.Time{}, false
793+ return nil
794+ }
795+ if !o.stateDue(time.Now(), monotonic()) {
796+ return nil // a frame before then carries it; else the state timer
797+ }
777 req := &o.stateReq798 req := &o.stateReq
778 req.reset()799 req.reset()
779 cur := cursorState{}800 cur := cursorState{}
@@ -839,6 +860,7 @@ func (o *Output) commitState(vrr bool) error {
839 }860 }
840 o.setVRR(vrr)861 o.setVRR(vrr)
841 o.contentValue = o.contentWanted862 o.contentValue = o.contentWanted
863+ o.flipStats.stateCommits++
842 o.frame.begin(pendingFrame{}, time.Now())864 o.frame.begin(pendingFrame{}, time.Now())
843 return nil865 return nil
844 }866 }
@@ -1097,6 +1119,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1097 retryTimer := time.NewTimer(time.Hour)1119 retryTimer := time.NewTimer(time.Hour)
1098 vrrTimer := time.NewTimer(time.Hour)1120 vrrTimer := time.NewTimer(time.Hour)
1099 cursorTimer := time.NewTimer(time.Hour)1121 cursorTimer := time.NewTimer(time.Hour)
1122+ stateTimer := time.NewTimer(time.Hour)
1123+ stateTimer.Stop()
1124+ defer stateTimer.Stop()
1100 protectTimer := time.NewTimer(time.Hour)1125 protectTimer := time.NewTimer(time.Hour)
1101 o.frame.startTimer()1126 o.frame.startTimer()
1102 gapTimer := time.NewTimer(time.Hour)1127 gapTimer := time.NewTimer(time.Hour)
@@ -1266,6 +1291,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1266 } else {1291 } else {
1267 cursorTimer.Stop()1292 cursorTimer.Stop()
1268 }1293 }
1294+ // A deferred state change commits alone if no frame carried it.
1295+ var stateDue <-chan time.Time
1296+ if enabled && !o.protected && !o.off && !o.stateAt.IsZero() && !o.frame.pendingCommit() {
1297+ stateTimer.Reset(max(0, time.Until(o.stateAt)))
1298+ stateDue = stateTimer.C
1299+ } else {
1300+ stateTimer.Stop()
1301+ }
1269 // A game frame waiting out the VRR flip gap commits when it ends.1302 // A game frame waiting out the VRR flip gap commits when it ends.
1270 var gapDue <-chan time.Time1303 var gapDue <-chan time.Time
1271 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {1304 if enabled && dirty && !o.frame.pendingCommit() && !o.flipGapUntil.IsZero() {
@@ -1338,6 +1371,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1338 stateDirty = true1371 stateDirty = true
1339 case <-cursorDue:1372 case <-cursorDue:
1340 stateDirty = true1373 stateDirty = true
1374+ case <-stateDue:
1375+ stateDirty = true
1341 case <-cursorWake:1376 case <-cursorWake:
1342 stateDirty = true1377 stateDirty = true
1343 case <-gapDue:1378 case <-gapDue:
@@ -1462,6 +1497,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1462 fs := o.takeFlipStats()1497 fs := o.takeFlipStats()
1463 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))1498 ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval))
1464 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))1499 ev = ev.Float64("max_commit_delay_ms", ms(fs.maxCommitDelay)).Float64("max_flip_to_read_ms", ms(fs.maxFlipToRead))
1500+ ev = ev.Int("state_commits", fs.stateCommits).Int("state_carried", fs.stateCarried)
1465 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())1501 ev = ev.Int("redrawn_pixels", r.TakeRedrawn())
1466 if o.traceFlips {1502 if o.traceFlips {
1467 ev = ev.Int("late_fences", fs.lateFences)1503 ev = ev.Int("late_fences", fs.lateFences)
@@ -1638,6 +1674,9 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1638 return false1674 return false
1639 }1675 }
1640 o.flipDone(ev, f, start, ours, trace, fenceAt)1676 o.flipDone(ev, f, start, ours, trace, fenceAt)
1677+ if ours {
1678+ o.noteVblank(f, ev.when)
1679+ }
1641 if o.cursor != nil {1680 if o.cursor != nil {
1642 o.cursor.landed()1681 o.cursor.landed()
1643 }1682 }
A internal/adapters/drm/state_schedule.go +78 -0
@@ -0,0 +1,78 @@
1+package drm
2+
3+import "time"
4+
5+// State commit scheduling: a CRTC takes one atomic commit per vblank, so a
6+// commit carrying only state (cursor move, VRR toggle, content type) holds
7+// the next frame until it lands. Committed as soon as the pointer moves, it
8+// pushes a video frame that arrives later in the same refresh period to the
9+// following vblank: a visible stutter on every cursor move over the output
10+// or across to another one.
11+//
12+// So a state change waits until stateLead before the next vblank. A frame
13+// committed before then carries it (one commit, no lost vblank); otherwise
14+// the state commits alone and still lands on that vblank, so the cursor is
15+// never later than it would have been.
16+//
17+// Under VRR there is no fixed vblank to aim at: state commits at once (a
18+// game's cursor rides on its frames, see cursorWaits).
19+
20+// stateLeadMax is how long before the vblank a deferred state commit is
21+// made: enough for the kernel to program the planes for that vblank.
22+const stateLeadMax = 1500 * time.Microsecond
23+
24+// vblankStale is how old the last vblank may be to predict the next one: a
25+// longer idle period lets the nominal period drift from the panel's.
26+const vblankStale = 250 * time.Millisecond
27+
28+// stateLead is stateLeadMax, at most a quarter of period.
29+func stateLead(period time.Duration) time.Duration { return min(stateLeadMax, period/4) }
30+
31+// stateDelay is how long a state change made at now (CLOCK_MONOTONIC)
32+// waits before its own commit: until stateLead before the next vblank, 0
33+// when that point has passed or no vblank can be predicted (VRR, no recent
34+// flip).
35+func (o *Output) stateDelay(now time.Duration) time.Duration {
36+ period := o.refreshPeriod()
37+ last := o.lastVblankAt
38+ if period <= 0 || last <= 0 || now < last || now-last > vblankStale {
39+ return 0
40+ }
41+ next := last + ((now-last)/period+1)*period
42+ return max(0, next-stateLead(period)-now)
43+}
44+
45+// stateChanged reports whether a state commit would change anything:
46+// cursor, VRR or content type differ from what KMS holds.
47+func (o *Output) stateChanged(vrr bool) bool {
48+ if o.cursor != nil && o.cursor.desired() != o.cursor.applied {
49+ return true
50+ }
51+ if vrr != o.vrrOn {
52+ return true
53+ }
54+ return o.contentProp != 0 && o.contentWanted != o.contentValue
55+}
56+
57+// stateDue reports whether the state commit may go now. The first call
58+// for a change fixes its time (stateAt) and later calls keep it, so a
59+// timer that fires late never pushes the commit by another period.
60+func (o *Output) stateDue(now time.Time, mono time.Duration) bool {
61+ if o.stateAt.IsZero() {
62+ o.stateAt = now.Add(o.stateDelay(mono))
63+ }
64+ if now.Before(o.stateAt) {
65+ return false
66+ }
67+ o.stateAt = time.Time{}
68+ return true
69+}
70+
71+// noteVblank records the vblank of a completed vsynced commit (frame or
72+// state). A tearing flip's timestamp is not a vblank.
73+func (o *Output) noteVblank(f pendingFrame, when time.Duration) {
74+ if f.async || when <= 0 {
75+ return
76+ }
77+ o.lastVblankAt = when
78+}
@@ -0,0 +1,78 @@
1+package drm
2+
3+import "time"
4+
5+// State commit scheduling: a CRTC takes one atomic commit per vblank, so a
6+// commit carrying only state (cursor move, VRR toggle, content type) holds
7+// the next frame until it lands. Committed as soon as the pointer moves, it
8+// pushes a video frame that arrives later in the same refresh period to the
9+// following vblank: a visible stutter on every cursor move over the output
10+// or across to another one.
11+//
12+// So a state change waits until stateLead before the next vblank. A frame
13+// committed before then carries it (one commit, no lost vblank); otherwise
14+// the state commits alone and still lands on that vblank, so the cursor is
15+// never later than it would have been.
16+//
17+// Under VRR there is no fixed vblank to aim at: state commits at once (a
18+// game's cursor rides on its frames, see cursorWaits).
19+
20+// stateLeadMax is how long before the vblank a deferred state commit is
21+// made: enough for the kernel to program the planes for that vblank.
22+const stateLeadMax = 1500 * time.Microsecond
23+
24+// vblankStale is how old the last vblank may be to predict the next one: a
25+// longer idle period lets the nominal period drift from the panel's.
26+const vblankStale = 250 * time.Millisecond
27+
28+// stateLead is stateLeadMax, at most a quarter of period.
29+func stateLead(period time.Duration) time.Duration { return min(stateLeadMax, period/4) }
30+
31+// stateDelay is how long a state change made at now (CLOCK_MONOTONIC)
32+// waits before its own commit: until stateLead before the next vblank, 0
33+// when that point has passed or no vblank can be predicted (VRR, no recent
34+// flip).
35+func (o *Output) stateDelay(now time.Duration) time.Duration {
36+ period := o.refreshPeriod()
37+ last := o.lastVblankAt
38+ if period <= 0 || last <= 0 || now < last || now-last > vblankStale {
39+ return 0
40+ }
41+ next := last + ((now-last)/period+1)*period
42+ return max(0, next-stateLead(period)-now)
43+}
44+
45+// stateChanged reports whether a state commit would change anything:
46+// cursor, VRR or content type differ from what KMS holds.
47+func (o *Output) stateChanged(vrr bool) bool {
48+ if o.cursor != nil && o.cursor.desired() != o.cursor.applied {
49+ return true
50+ }
51+ if vrr != o.vrrOn {
52+ return true
53+ }
54+ return o.contentProp != 0 && o.contentWanted != o.contentValue
55+}
56+
57+// stateDue reports whether the state commit may go now. The first call
58+// for a change fixes its time (stateAt) and later calls keep it, so a
59+// timer that fires late never pushes the commit by another period.
60+func (o *Output) stateDue(now time.Time, mono time.Duration) bool {
61+ if o.stateAt.IsZero() {
62+ o.stateAt = now.Add(o.stateDelay(mono))
63+ }
64+ if now.Before(o.stateAt) {
65+ return false
66+ }
67+ o.stateAt = time.Time{}
68+ return true
69+}
70+
71+// noteVblank records the vblank of a completed vsynced commit (frame or
72+// state). A tearing flip's timestamp is not a vblank.
73+func (o *Output) noteVblank(f pendingFrame, when time.Duration) {
74+ if f.async || when <= 0 {
75+ return
76+ }
77+ o.lastVblankAt = when
78+}
A internal/adapters/drm/state_schedule_test.go +130 -0
@@ -0,0 +1,130 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestStateDelay(t *testing.T) {
11+ o, _, _ := testOutput(t) // 60 Hz
12+ p := o.nominalPeriod()
13+ lead := stateLead(p)
14+ last := 10 * time.Second
15+ for _, tc := range []struct {
16+ name string
17+ vrr bool
18+ last time.Duration
19+ now time.Duration
20+ want time.Duration
21+ }{
22+ {"no vblank yet", false, 0, last, 0},
23+ {"right after a vblank", false, last, last + time.Millisecond, p - lead - time.Millisecond},
24+ {"inside the lead", false, last, last + p - lead/2, 0},
25+ {"periods later", false, last, last + 3*p + time.Millisecond, p - lead - time.Millisecond},
26+ {"stale vblank", false, last, last + vblankStale + time.Millisecond, 0},
27+ {"VRR", true, last, last + time.Millisecond, 0},
28+ } {
29+ o.vrrOn, o.lastVblankAt = tc.vrr, tc.last
30+ if got := o.stateDelay(tc.now); got != tc.want {
31+ t.Errorf("%s: delay %v, want %v", tc.name, got, tc.want)
32+ }
33+ }
34+}
35+
36+// A cursor move right after a vblank waits: the frame that comes before
37+// the deadline carries it, in a single commit.
38+func TestDeferredCursorRidesOnFrame(t *testing.T) {
39+ o, _, commits := testOutput(t)
40+ o.cursor.image = true
41+ o.stateAt = time.Now().Add(time.Hour) // the vblank is far
42+ o.cursor.Move(7, 7)
43+ if err := o.commitState(false); err != nil {
44+ t.Fatal(err)
45+ }
46+ if len(*commits) != 0 || o.stateAt.IsZero() || o.frame.pendingCommit() {
47+ t.Fatalf("cursor committed at once: %d commits, due %v", len(*commits), o.stateAt)
48+ }
49+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 7 || !o.stateAt.IsZero() {
53+ t.Fatalf("frame cursor x %d, due %v", v, o.stateAt)
54+ }
55+ if s := o.takeFlipStats(); s.stateCarried != 1 || s.stateCommits != 0 {
56+ t.Fatalf("carried %d, state commits %d, want 1, 0", s.stateCarried, s.stateCommits)
57+ }
58+}
59+
60+// With no frame, the deferred move commits alone once due, and a late
61+// timer does not push it by another period.
62+func TestDeferredCursorCommitsWhenDue(t *testing.T) {
63+ o, _, commits := testOutput(t)
64+ o.cursor.image = true
65+ o.stateAt = time.Now().Add(time.Hour)
66+ o.cursor.Move(4, 4)
67+ if err := o.commitState(false); err != nil || len(*commits) != 0 {
68+ t.Fatalf("err %v, %d commits", err, len(*commits))
69+ }
70+ o.stateAt = time.Now().Add(-time.Millisecond) // the timer fired late
71+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
72+ t.Fatalf("due commit: %v, %d commits", err, len(*commits))
73+ }
74+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 4 || !o.stateAt.IsZero() {
75+ t.Fatalf("cursor x %d, due %v", v, o.stateAt)
76+ }
77+ if s := o.takeFlipStats(); s.stateCommits != 1 || s.stateCarried != 0 {
78+ t.Fatalf("state commits %d, carried %d, want 1, 0", s.stateCommits, s.stateCarried)
79+ }
80+}
81+
82+// A move undone before its deadline commits nothing and clears the wait.
83+func TestDeferredCursorMovedBack(t *testing.T) {
84+ o, _, commits := testOutput(t)
85+ o.cursor.image = true
86+ o.cursor.Move(0, 0)
87+ o.cursor.applied = o.cursor.desired()
88+ o.stateAt = time.Now().Add(time.Hour)
89+ o.cursor.Move(9, 9)
90+ if err := o.commitState(false); err != nil || o.stateAt.IsZero() {
91+ t.Fatalf("err %v, due %v", err, o.stateAt)
92+ }
93+ o.cursor.Move(0, 0)
94+ if err := o.commitState(false); err != nil || !o.stateAt.IsZero() || len(*commits) != 0 {
95+ t.Fatalf("err %v, due %v, %d commits", err, o.stateAt, len(*commits))
96+ }
97+}
98+
99+// Powering off drops a deferred state change: an off output has no vblank,
100+// and an armed deadline would wake Run forever.
101+func TestPowerOffDropsDeferredState(t *testing.T) {
102+ o, _, _ := testOutput(t)
103+ o.stateAt, o.lastVblankAt = time.Now().Add(time.Hour), time.Second
104+ if err := o.powerOff(); err != nil {
105+ t.Fatal(err)
106+ }
107+ if !o.stateAt.IsZero() || o.lastVblankAt != 0 {
108+ t.Fatalf("due %v, vblank %v after power off", o.stateAt, o.lastVblankAt)
109+ }
110+}
111+
112+// Vsynced completions, state ones included, give the vblank; a tearing
113+// flip does not.
114+func TestCompletionRecordsVblank(t *testing.T) {
115+ o, _, commits := testOutput(t)
116+ o.cursor.image = true
117+ o.cursor.Move(2, 2)
118+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
119+ t.Fatalf("err %v, %d commits", err, len(*commits))
120+ }
121+ ev := eventOf((*commits)[0])
122+ ev.when = 5 * time.Second
123+ if !o.completed(ev, map[ports.WindowID]uint64{}) || o.lastVblankAt != ev.when {
124+ t.Fatalf("vblank %v, want %v", o.lastVblankAt, ev.when)
125+ }
126+ o.noteVblank(pendingFrame{async: true}, 6*time.Second)
127+ if o.lastVblankAt != ev.when {
128+ t.Fatalf("tearing flip taken as a vblank: %v", o.lastVblankAt)
129+ }
130+}
@@ -0,0 +1,130 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestStateDelay(t *testing.T) {
11+ o, _, _ := testOutput(t) // 60 Hz
12+ p := o.nominalPeriod()
13+ lead := stateLead(p)
14+ last := 10 * time.Second
15+ for _, tc := range []struct {
16+ name string
17+ vrr bool
18+ last time.Duration
19+ now time.Duration
20+ want time.Duration
21+ }{
22+ {"no vblank yet", false, 0, last, 0},
23+ {"right after a vblank", false, last, last + time.Millisecond, p - lead - time.Millisecond},
24+ {"inside the lead", false, last, last + p - lead/2, 0},
25+ {"periods later", false, last, last + 3*p + time.Millisecond, p - lead - time.Millisecond},
26+ {"stale vblank", false, last, last + vblankStale + time.Millisecond, 0},
27+ {"VRR", true, last, last + time.Millisecond, 0},
28+ } {
29+ o.vrrOn, o.lastVblankAt = tc.vrr, tc.last
30+ if got := o.stateDelay(tc.now); got != tc.want {
31+ t.Errorf("%s: delay %v, want %v", tc.name, got, tc.want)
32+ }
33+ }
34+}
35+
36+// A cursor move right after a vblank waits: the frame that comes before
37+// the deadline carries it, in a single commit.
38+func TestDeferredCursorRidesOnFrame(t *testing.T) {
39+ o, _, commits := testOutput(t)
40+ o.cursor.image = true
41+ o.stateAt = time.Now().Add(time.Hour) // the vblank is far
42+ o.cursor.Move(7, 7)
43+ if err := o.commitState(false); err != nil {
44+ t.Fatal(err)
45+ }
46+ if len(*commits) != 0 || o.stateAt.IsZero() || o.frame.pendingCommit() {
47+ t.Fatalf("cursor committed at once: %d commits, due %v", len(*commits), o.stateAt)
48+ }
49+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 7 || !o.stateAt.IsZero() {
53+ t.Fatalf("frame cursor x %d, due %v", v, o.stateAt)
54+ }
55+ if s := o.takeFlipStats(); s.stateCarried != 1 || s.stateCommits != 0 {
56+ t.Fatalf("carried %d, state commits %d, want 1, 0", s.stateCarried, s.stateCommits)
57+ }
58+}
59+
60+// With no frame, the deferred move commits alone once due, and a late
61+// timer does not push it by another period.
62+func TestDeferredCursorCommitsWhenDue(t *testing.T) {
63+ o, _, commits := testOutput(t)
64+ o.cursor.image = true
65+ o.stateAt = time.Now().Add(time.Hour)
66+ o.cursor.Move(4, 4)
67+ if err := o.commitState(false); err != nil || len(*commits) != 0 {
68+ t.Fatalf("err %v, %d commits", err, len(*commits))
69+ }
70+ o.stateAt = time.Now().Add(-time.Millisecond) // the timer fired late
71+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
72+ t.Fatalf("due commit: %v, %d commits", err, len(*commits))
73+ }
74+ if v, _ := (*commits)[0].req.value(tCursor, 14); v != 4 || !o.stateAt.IsZero() {
75+ t.Fatalf("cursor x %d, due %v", v, o.stateAt)
76+ }
77+ if s := o.takeFlipStats(); s.stateCommits != 1 || s.stateCarried != 0 {
78+ t.Fatalf("state commits %d, carried %d, want 1, 0", s.stateCommits, s.stateCarried)
79+ }
80+}
81+
82+// A move undone before its deadline commits nothing and clears the wait.
83+func TestDeferredCursorMovedBack(t *testing.T) {
84+ o, _, commits := testOutput(t)
85+ o.cursor.image = true
86+ o.cursor.Move(0, 0)
87+ o.cursor.applied = o.cursor.desired()
88+ o.stateAt = time.Now().Add(time.Hour)
89+ o.cursor.Move(9, 9)
90+ if err := o.commitState(false); err != nil || o.stateAt.IsZero() {
91+ t.Fatalf("err %v, due %v", err, o.stateAt)
92+ }
93+ o.cursor.Move(0, 0)
94+ if err := o.commitState(false); err != nil || !o.stateAt.IsZero() || len(*commits) != 0 {
95+ t.Fatalf("err %v, due %v, %d commits", err, o.stateAt, len(*commits))
96+ }
97+}
98+
99+// Powering off drops a deferred state change: an off output has no vblank,
100+// and an armed deadline would wake Run forever.
101+func TestPowerOffDropsDeferredState(t *testing.T) {
102+ o, _, _ := testOutput(t)
103+ o.stateAt, o.lastVblankAt = time.Now().Add(time.Hour), time.Second
104+ if err := o.powerOff(); err != nil {
105+ t.Fatal(err)
106+ }
107+ if !o.stateAt.IsZero() || o.lastVblankAt != 0 {
108+ t.Fatalf("due %v, vblank %v after power off", o.stateAt, o.lastVblankAt)
109+ }
110+}
111+
112+// Vsynced completions, state ones included, give the vblank; a tearing
113+// flip does not.
114+func TestCompletionRecordsVblank(t *testing.T) {
115+ o, _, commits := testOutput(t)
116+ o.cursor.image = true
117+ o.cursor.Move(2, 2)
118+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
119+ t.Fatalf("err %v, %d commits", err, len(*commits))
120+ }
121+ ev := eventOf((*commits)[0])
122+ ev.when = 5 * time.Second
123+ if !o.completed(ev, map[ports.WindowID]uint64{}) || o.lastVblankAt != ev.when {
124+ t.Fatalf("vblank %v, want %v", o.lastVblankAt, ev.when)
125+ }
126+ o.noteVblank(pendingFrame{async: true}, 6*time.Second)
127+ if o.lastVblankAt != ev.when {
128+ t.Fatalf("tearing flip taken as a vblank: %v", o.lastVblankAt)
129+ }
130+}
M internal/adapters/wayland/alloc_hotpath_test.go +38 -0
@@ -208,3 +208,41 @@ func TestBufferContentAllocations(t *testing.T) {
208208 t.Fatal("content from a truncated pool")
209209 }
210210 }
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+ var stats commandStats
229+ cycle := func() {
230+ r.Push(queue(button))
231+ r.Push(queue(motion))
232+ r.Push(queue(motion)) // folded into the previous one
233+ r.Push(queue(wrapped))
234+ r.Push(queue(wrapped)) // enveloped, same state: folded as well
235+ s.display.Post(marker)
236+ <-ran
237+ // The latency stats path runs per command and per report.
238+ stats.observe(time.Time{}, time.Now())
239+ stats.take()
240+ r.takeStats()
241+ }
242+ for range 4 {
243+ cycle()
244+ }
245+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
246+ t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247+ }
248+}
@@ -208,3 +208,41 @@ 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+ var stats commandStats
229+ cycle := func() {
230+ r.Push(queue(button))
231+ r.Push(queue(motion))
232+ r.Push(queue(motion)) // folded into the previous one
233+ r.Push(queue(wrapped))
234+ r.Push(queue(wrapped)) // enveloped, same state: folded as well
235+ s.display.Post(marker)
236+ <-ran
237+ // The latency stats path runs per command and per report.
238+ stats.observe(time.Time{}, time.Now())
239+ stats.take()
240+ r.takeStats()
241+ }
242+ for range 4 {
243+ cycle()
244+ }
245+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
246+ t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247+ }
248+}
A internal/adapters/wayland/commandring.go +156 -0
@@ -0,0 +1,156 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// commandRingSize bounds the client commands waiting for the display
13+// goroutine. It absorbs a few display frames of a 1 kHz mouse and a burst of
14+// key/button commands while the display parses client requests; only the
15+// forwarder goroutine waits when it is full, so core never blocks on the
16+// display directly, only on the Commands channel (cap 32) behind the forwarder.
17+const commandRingSize = 256
18+
19+// queuedCommand is a ClientCommand in the command ring. Pointer motion, bare
20+// or wrapped in a SecurityCommand (core wraps every one in production), keeps
21+// its value and envelope state inline so folding a motion into a queued one
22+// allocates nothing; the command is boxed again only if a merge happened, at
23+// apply time, once per merged run.
24+type queuedCommand struct {
25+ cmd ports.ClientCommand
26+ motion ports.PointerMotionTo // valid if isMotion
27+ state ports.SecurityState // envelope state, valid if enveloped
28+ // isMotion: cmd is a PointerMotionTo, bare or directly inside a
29+ // SecurityCommand (then enveloped is set).
30+ isMotion, enveloped bool
31+ // merged: motion holds the sum of several motions and cmd is stale.
32+ merged bool
33+ // at is when the command entered the ring path. A merge keeps the oldest
34+ // value, so a folded motion reports the wait of the first motion in it.
35+ at time.Time
36+}
37+
38+func queue(cmd ports.ClientCommand) queuedCommand {
39+ q := queuedCommand{cmd: cmd, at: time.Now()}
40+ if env, ok := cmd.(ports.SecurityCommand); ok {
41+ q.state, q.enveloped = env.State, true
42+ cmd = env.Command
43+ }
44+ q.motion, q.isMotion = cmd.(ports.PointerMotionTo)
45+ return q
46+}
47+
48+func (q *queuedCommand) command() ports.ClientCommand {
49+ switch {
50+ case !q.merged:
51+ return q.cmd
52+ case q.enveloped:
53+ return ports.SecurityCommand{State: q.state, Command: q.motion}
54+ }
55+ return q.motion
56+}
57+
58+// mergeMotion folds v into last when both are pointer motion for the same
59+// window, both bare or both enveloped with an identical security state, and
60+// last is the most recent queued command, so no other command is ever
61+// overtaken and a bare motion never takes the place of an enveloped one. The
62+// position is the latest; relative deltas add up so locked pointers (games)
63+// lose no movement. last.at stays: the merge reports its first motion's wait.
64+func mergeMotion(last *queuedCommand, v queuedCommand) bool {
65+ if !last.isMotion || !v.isMotion || last.motion.ID != v.motion.ID ||
66+ last.enveloped != v.enveloped || last.state != v.state {
67+ return false
68+ }
69+ m := v.motion
70+ m.DX += last.motion.DX
71+ m.DY += last.motion.DY
72+ m.UnaccelDX += last.motion.UnaccelDX
73+ m.UnaccelDY += last.motion.UnaccelDY
74+ last.motion, last.merged = m, true
75+ return true
76+}
77+
78+// newCommandRing returns the ring whose commands apply runs on the display
79+// goroutine, in arrival order. Adjacent motions for one window are coalesced
80+// at push time, against the last queued command only, under the ring mutex.
81+// That also coalesces while the display is busy (a drain-side batch would
82+// only see what the channel held at that moment), and a merge needs no room.
83+func newCommandRing(d *server.Display, apply func(queuedCommand)) *postRing[queuedCommand] {
84+ return newMergingPostRing(d, commandRingSize, apply, mergeMotion)
85+}
86+
87+// commandStats counts what the display goroutine applied. The display
88+// goroutine writes and the stats logger takes, so the fields are atomic.
89+type commandStats struct {
90+ applied atomic.Int64
91+ maxLatency atomic.Int64 // nanoseconds from push to apply
92+}
93+
94+// observe records one applied command pushed at at.
95+func (c *commandStats) observe(at time.Time, now time.Time) {
96+ c.applied.Add(1)
97+ d := int64(now.Sub(at))
98+ for {
99+ old := c.maxLatency.Load()
100+ if d <= old || c.maxLatency.CompareAndSwap(old, d) {
101+ return
102+ }
103+ }
104+}
105+
106+// take returns the applied count and the largest latency, and resets both.
107+func (c *commandStats) take() (applied int64, maxLatency time.Duration) {
108+ return c.applied.Swap(0), time.Duration(c.maxLatency.Swap(0))
109+}
110+
111+// commandStatsInterval is how often logCommandStats reports.
112+const commandStatsInterval = 10 * time.Second
113+
114+// logCommandStats reports command path latency every interval until ctx or the
115+// display ends, so a live log shows whether the display goroutine delays input.
116+// Only a period with every counter at zero logs nothing: a stalled display
117+// applies nothing but still fills the ring.
118+func (s *Server) logCommandStats(ctx context.Context) {
119+ t := time.NewTicker(commandStatsInterval)
120+ defer t.Stop()
121+ for {
122+ select {
123+ case <-ctx.Done():
124+ return
125+ case <-s.display.Stopped():
126+ return
127+ case <-t.C:
128+ s.reportCommandStats()
129+ }
130+ }
131+}
132+
133+func (s *Server) reportCommandStats() {
134+ rs := s.commands.takeStats()
135+ applied, latency := s.commandStats.take()
136+ if applied == 0 && latency == 0 && rs == (ringStats{}) {
137+ return
138+ }
139+ s.log.Info().
140+ Int64("commands", applied).
141+ Int("merged", rs.merged).
142+ Int("max_queued", rs.maxLen).
143+ Float64("max_push_block_ms", float64(rs.maxPushBlock)/float64(time.Millisecond)).
144+ Float64("max_latency_ms", float64(latency)/float64(time.Millisecond)).
145+ Msg("command stats")
146+}
147+
148+// applyQueued runs on the display goroutine; it stops applying once the
149+// server context is done, like the per-command check of the old batch.
150+func (s *Server) applyQueued(q queuedCommand) {
151+ if s.ctx != nil && s.ctx.Err() != nil {
152+ return
153+ }
154+ s.commandStats.observe(q.at, time.Now())
155+ s.apply(q.command())
156+}
@@ -0,0 +1,156 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// commandRingSize bounds the client commands waiting for the display
13+// goroutine. It absorbs a few display frames of a 1 kHz mouse and a burst of
14+// key/button commands while the display parses client requests; only the
15+// forwarder goroutine waits when it is full, so core never blocks on the
16+// display directly, only on the Commands channel (cap 32) behind the forwarder.
17+const commandRingSize = 256
18+
19+// queuedCommand is a ClientCommand in the command ring. Pointer motion, bare
20+// or wrapped in a SecurityCommand (core wraps every one in production), keeps
21+// its value and envelope state inline so folding a motion into a queued one
22+// allocates nothing; the command is boxed again only if a merge happened, at
23+// apply time, once per merged run.
24+type queuedCommand struct {
25+ cmd ports.ClientCommand
26+ motion ports.PointerMotionTo // valid if isMotion
27+ state ports.SecurityState // envelope state, valid if enveloped
28+ // isMotion: cmd is a PointerMotionTo, bare or directly inside a
29+ // SecurityCommand (then enveloped is set).
30+ isMotion, enveloped bool
31+ // merged: motion holds the sum of several motions and cmd is stale.
32+ merged bool
33+ // at is when the command entered the ring path. A merge keeps the oldest
34+ // value, so a folded motion reports the wait of the first motion in it.
35+ at time.Time
36+}
37+
38+func queue(cmd ports.ClientCommand) queuedCommand {
39+ q := queuedCommand{cmd: cmd, at: time.Now()}
40+ if env, ok := cmd.(ports.SecurityCommand); ok {
41+ q.state, q.enveloped = env.State, true
42+ cmd = env.Command
43+ }
44+ q.motion, q.isMotion = cmd.(ports.PointerMotionTo)
45+ return q
46+}
47+
48+func (q *queuedCommand) command() ports.ClientCommand {
49+ switch {
50+ case !q.merged:
51+ return q.cmd
52+ case q.enveloped:
53+ return ports.SecurityCommand{State: q.state, Command: q.motion}
54+ }
55+ return q.motion
56+}
57+
58+// mergeMotion folds v into last when both are pointer motion for the same
59+// window, both bare or both enveloped with an identical security state, and
60+// last is the most recent queued command, so no other command is ever
61+// overtaken and a bare motion never takes the place of an enveloped one. The
62+// position is the latest; relative deltas add up so locked pointers (games)
63+// lose no movement. last.at stays: the merge reports its first motion's wait.
64+func mergeMotion(last *queuedCommand, v queuedCommand) bool {
65+ if !last.isMotion || !v.isMotion || last.motion.ID != v.motion.ID ||
66+ last.enveloped != v.enveloped || last.state != v.state {
67+ return false
68+ }
69+ m := v.motion
70+ m.DX += last.motion.DX
71+ m.DY += last.motion.DY
72+ m.UnaccelDX += last.motion.UnaccelDX
73+ m.UnaccelDY += last.motion.UnaccelDY
74+ last.motion, last.merged = m, true
75+ return true
76+}
77+
78+// newCommandRing returns the ring whose commands apply runs on the display
79+// goroutine, in arrival order. Adjacent motions for one window are coalesced
80+// at push time, against the last queued command only, under the ring mutex.
81+// That also coalesces while the display is busy (a drain-side batch would
82+// only see what the channel held at that moment), and a merge needs no room.
83+func newCommandRing(d *server.Display, apply func(queuedCommand)) *postRing[queuedCommand] {
84+ return newMergingPostRing(d, commandRingSize, apply, mergeMotion)
85+}
86+
87+// commandStats counts what the display goroutine applied. The display
88+// goroutine writes and the stats logger takes, so the fields are atomic.
89+type commandStats struct {
90+ applied atomic.Int64
91+ maxLatency atomic.Int64 // nanoseconds from push to apply
92+}
93+
94+// observe records one applied command pushed at at.
95+func (c *commandStats) observe(at time.Time, now time.Time) {
96+ c.applied.Add(1)
97+ d := int64(now.Sub(at))
98+ for {
99+ old := c.maxLatency.Load()
100+ if d <= old || c.maxLatency.CompareAndSwap(old, d) {
101+ return
102+ }
103+ }
104+}
105+
106+// take returns the applied count and the largest latency, and resets both.
107+func (c *commandStats) take() (applied int64, maxLatency time.Duration) {
108+ return c.applied.Swap(0), time.Duration(c.maxLatency.Swap(0))
109+}
110+
111+// commandStatsInterval is how often logCommandStats reports.
112+const commandStatsInterval = 10 * time.Second
113+
114+// logCommandStats reports command path latency every interval until ctx or the
115+// display ends, so a live log shows whether the display goroutine delays input.
116+// Only a period with every counter at zero logs nothing: a stalled display
117+// applies nothing but still fills the ring.
118+func (s *Server) logCommandStats(ctx context.Context) {
119+ t := time.NewTicker(commandStatsInterval)
120+ defer t.Stop()
121+ for {
122+ select {
123+ case <-ctx.Done():
124+ return
125+ case <-s.display.Stopped():
126+ return
127+ case <-t.C:
128+ s.reportCommandStats()
129+ }
130+ }
131+}
132+
133+func (s *Server) reportCommandStats() {
134+ rs := s.commands.takeStats()
135+ applied, latency := s.commandStats.take()
136+ if applied == 0 && latency == 0 && rs == (ringStats{}) {
137+ return
138+ }
139+ s.log.Info().
140+ Int64("commands", applied).
141+ Int("merged", rs.merged).
142+ Int("max_queued", rs.maxLen).
143+ Float64("max_push_block_ms", float64(rs.maxPushBlock)/float64(time.Millisecond)).
144+ Float64("max_latency_ms", float64(latency)/float64(time.Millisecond)).
145+ Msg("command stats")
146+}
147+
148+// applyQueued runs on the display goroutine; it stops applying once the
149+// server context is done, like the per-command check of the old batch.
150+func (s *Server) applyQueued(q queuedCommand) {
151+ if s.ctx != nil && s.ctx.Err() != nil {
152+ return
153+ }
154+ s.commandStats.observe(q.at, time.Now())
155+ s.apply(q.command())
156+}
M internal/adapters/wayland/commands_test.go +266 -15
@@ -3,23 +3,56 @@ package wayland
33 import (
44 "context"
55 "reflect"
6+ "strings"
7+ "sync"
68 "testing"
79 "time"
810
911 "github.com/bnema/neferwl/internal/adapters/logging"
1012 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
1114 )
1215
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)
16+// Adjacent motions for one window fold into the last queued command, summing
17+// the relative deltas; nothing is merged across another command, and the
18+// order of arrival is kept.
19+func TestCommandRingCollapsesMotionInOrder(t *testing.T) {
20+ s, _, _, _, _, _ := presentServer(t)
21+ locked := ports.SecurityState{Generation: 2, Protected: true}
22+ unlocked := ports.SecurityState{Generation: 3}
23+ var got []ports.ClientCommand // display goroutine only
24+ r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) })
25+ release := make(chan struct{})
26+ stalled := make(chan struct{})
27+ s.display.Post(func() { close(stalled); <-release })
28+ <-stalled
29+ for _, c := range []ports.ClientCommand{
30+ ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5},
31+ ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5},
32+ ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1},
33+ ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
34+ ports.PointerMotionTo{ID: 1, X: 4},
35+ ports.PointerFocus{ID: 2},
36+ ports.PointerMotionTo{ID: 2, X: 5},
37+ ports.PointerMotionTo{ID: 2, X: 6},
38+ ports.PointerMotionTo{ID: 1, X: 7}, // other window: not adjacent-same
39+ // Enveloped, as core sends them: same state merges, a different
40+ // state, a bare motion, or another window does not.
41+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 8, DX: 1, UnaccelDY: 2}},
42+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 9, DX: 2, UnaccelDY: 3}},
43+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DY: 4}},
44+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 11, DX: 1}},
45+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 1}},
46+ ports.PointerMotionTo{ID: 1, X: 13},
47+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
48+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
49+ ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
50+ } {
51+ if !r.Push(queue(c)) {
52+ t.Fatal("push refused")
53+ }
54+ }
55+ close(release)
2356 want := []ports.ClientCommand{
2457 // Slow motions add up: none of the sub-pixel deltas is lost.
2558 ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1},
@@ -27,13 +60,112 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) {
2760 ports.PointerMotionTo{ID: 1, X: 4},
2861 ports.PointerFocus{ID: 2},
2962 ports.PointerMotionTo{ID: 2, X: 6},
63+ ports.PointerMotionTo{ID: 1, X: 7},
64+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DX: 3, DY: 4, UnaccelDY: 5}},
65+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 2}},
66+ ports.PointerMotionTo{ID: 1, X: 13},
67+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
68+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
69+ ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
70+ }
71+ var final []ports.ClientCommand
72+ s.display.Do(func() { final = got }) // runs after the drain posted before it
73+ if !reflect.DeepEqual(final, want) {
74+ t.Fatalf("applied %#v, want %#v", final, want)
75+ }
76+}
77+
78+// A merge needs no room: motion keeps folding into the last queued command
79+// while the ring is full of other commands, and the push does not block.
80+func TestCommandRingMergesWhenFull(t *testing.T) {
81+ s, _, _, _, _, _ := presentServer(t)
82+ var got []ports.ClientCommand
83+ r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) })
84+ release := releaseOnce(t)
85+ stalled := make(chan struct{})
86+ s.display.Post(func() { close(stalled); <-release.ch })
87+ <-stalled
88+ for i := range commandRingSize - 1 {
89+ r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)}))
90+ }
91+ done := make(chan struct{})
92+ go func() {
93+ defer close(done)
94+ for range 1000 { // fills the ring with one motion, then folds the rest
95+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
96+ }
97+ }()
98+ select {
99+ case <-done:
100+ case <-time.After(2 * time.Second):
101+ t.Fatal("motion push blocked on a full ring")
102+ }
103+ release.do()
104+ var last ports.ClientCommand
105+ s.display.Do(func() { last = got[len(got)-1] })
106+ if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 {
107+ t.Fatalf("last applied %#v, want one motion with DX 1000", last)
108+ }
109+}
110+
111+// A busy display goroutine must not stall the forwarder: it keeps draining
112+// the Commands channel into the ring up to the ring capacity, and only then
113+// does it wait (and core with it, through the full channel).
114+func TestForwarderDrainsWhileDisplayBusy(t *testing.T) {
115+ cmds := make(chan ports.ClientCommand, 32)
116+ s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ ctx, cancel := context.WithCancel(context.Background())
121+ done := make(chan error, 1)
122+ go func() { done <- s.Run(ctx) }()
123+ defer func() { cancel(); <-done }()
124+
125+ // Deferred after the shutdown above, so it runs first: a failing
126+ // assertion never leaves the display stalled while Run is joined.
127+ release := releaseOnce(t)
128+ defer release.do()
129+ stalled := make(chan struct{})
130+ s.display.Post(func() { close(stalled); <-release.ch })
131+ <-stalled
132+
133+ // Not mergeable: every command takes a slot.
134+ const total = commandRingSize + 100
135+ sent := make(chan struct{})
136+ go func() {
137+ defer close(sent)
138+ for i := range total {
139+ select {
140+ case cmds <- ports.PointerFocus{ID: ports.WindowID(i + 1)}:
141+ case <-ctx.Done():
142+ return
143+ }
144+ }
145+ }()
146+ queued := func() int {
147+ s.commands.mu.Lock()
148+ defer s.commands.mu.Unlock()
149+ return len(s.commands.ring)
150+ }
151+ // Ring full, one command in the blocked forwarder, a full channel.
152+ deadline := time.Now().Add(5 * time.Second)
153+ for queued() != commandRingSize || len(cmds) != cap(cmds) {
154+ if time.Now().After(deadline) {
155+ t.Fatalf("ring holds %d (want %d), channel %d (want %d)", queued(), commandRingSize, len(cmds), cap(cmds))
156+ }
157+ time.Sleep(time.Millisecond)
30158 }
31- if !open || !reflect.DeepEqual(batch, want) {
32- t.Fatalf("open=%v batch=%#v", open, batch)
159+ select {
160+ case <-sent:
161+ t.Fatal("sender finished although the ring is full")
162+ default:
33163 }
34- close(cmds)
35- if _, open := drainCommands(ports.PointerFocus{}, cmds); open {
36- t.Fatal("closed channel reported open")
164+ release.do()
165+ select {
166+ case <-sent:
167+ case <-time.After(5 * time.Second):
168+ t.Fatal("forwarder did not resume after the display was released")
37169 }
38170 }
39171
@@ -58,3 +190,122 @@ func TestCommandThroughput(t *testing.T) {
58190 t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())
59191 }
60192 }
193+
194+// release unblocks a stalled display goroutine once, from the test or its
195+// cleanup, so a failed assertion never hangs the server shutdown.
196+type release struct {
197+ ch chan struct{}
198+ once sync.Once
199+}
200+
201+func (r *release) do() { r.once.Do(func() { close(r.ch) }) }
202+
203+// releaseOnce returns a release that the test cleanup also fires, before
204+// the cleanups registered earlier (the server's).
205+func releaseOnce(t *testing.T) *release {
206+ r := &release{ch: make(chan struct{})}
207+ t.Cleanup(r.do)
208+ return r
209+}
210+
211+// A merged motion keeps the push time of the first motion folded into it, so
212+// the reported latency is the longest wait any of its motions had.
213+func TestMergeMotionKeepsOldestPushTime(t *testing.T) {
214+ t0 := time.Unix(100, 0)
215+ first := queue(ports.PointerMotionTo{ID: 1, DX: 1})
216+ first.at = t0
217+ second := queue(ports.PointerMotionTo{ID: 1, DX: 2})
218+ second.at = t0.Add(5 * time.Millisecond)
219+ if !mergeMotion(&first, second) {
220+ t.Fatal("adjacent motions did not merge")
221+ }
222+ if !first.at.Equal(t0) {
223+ t.Fatalf("merged at = %v, want the oldest %v", first.at, t0)
224+ }
225+}
226+
227+// commandStats keeps the largest push-to-apply wait and the applied count,
228+// and take resets both.
229+func TestCommandStatsObserveAndTake(t *testing.T) {
230+ var c commandStats
231+ t0 := time.Unix(100, 0)
232+ c.observe(t0, t0.Add(3*time.Millisecond))
233+ c.observe(t0, t0.Add(9*time.Millisecond))
234+ c.observe(t0, t0.Add(time.Millisecond))
235+ if n, d := c.take(); n != 3 || d != 9*time.Millisecond {
236+ t.Fatalf("take = %d, %v; want 3, 9ms", n, d)
237+ }
238+ if n, d := c.take(); n != 0 || d != 0 {
239+ t.Fatalf("second take = %d, %v; want zeros", n, d)
240+ }
241+}
242+
243+// The ring counts the largest queued length and the folded pushes, and
244+// takeStats returns then resets them.
245+func TestCommandRingStats(t *testing.T) {
246+ s, _, _, _, _, _ := presentServer(t)
247+ r := newCommandRing(s.display, func(queuedCommand) {})
248+ release := releaseOnce(t)
249+ stalled := make(chan struct{})
250+ s.display.Post(func() { close(stalled); <-release.ch })
251+ <-stalled
252+ r.Push(queue(ports.PointerFocus{ID: 1}))
253+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
254+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
255+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
256+ r.Push(queue(ports.PointerFocus{ID: 2}))
257+ st := r.takeStats()
258+ if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {
259+ t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)
260+ }
261+ if st := r.takeStats(); st != (ringStats{}) {
262+ t.Fatalf("stats after take = %+v, want zero", st)
263+ }
264+ release.do()
265+}
266+
267+// The report logs the period's figures and resets them; a period without
268+// applied commands logs nothing.
269+func TestReportCommandStats(t *testing.T) {
270+ var out lockedBuffer
271+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
272+ h := newAdmissionHarnessWith(t, 1, Options{}, log)
273+ s := h.s
274+ s.reportCommandStats()
275+ if strings.Contains(out.String(), "command stats") {
276+ t.Fatalf("idle period logged: %s", out.String())
277+ }
278+ t0 := time.Unix(100, 0)
279+ s.commandStats.observe(t0, t0.Add(1500*time.Microsecond))
280+ s.reportCommandStats()
281+ got := out.String()
282+ for _, want := range []string{`"message":"command stats"`, `"commands":1`, `"merged":0`, `"max_queued":0`, `"max_push_block_ms":0`, `"max_latency_ms":1.5`} {
283+ if !strings.Contains(got, want) {
284+ t.Fatalf("log lacks %s: %s", want, got)
285+ }
286+ }
287+ n := strings.Count(got, "command stats")
288+ s.reportCommandStats()
289+ if strings.Count(out.String(), "command stats") != n {
290+ t.Fatalf("stats not reset: %s", out.String())
291+ }
292+}
293+
294+// A stalled display applies nothing but fills its ring: the period still
295+// logs, so a long stall is not hidden.
296+func TestReportCommandStatsDuringStall(t *testing.T) {
297+ var out lockedBuffer
298+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
299+ h := newAdmissionHarnessWith(t, 1, Options{}, log)
300+ s := h.s
301+ s.commands.mu.Lock()
302+ s.commands.stats.maxLen = 5
303+ s.commands.mu.Unlock()
304+ s.reportCommandStats()
305+ got := out.String()
306+ for _, want := range []string{`"message":"command stats"`, `"commands":0`, `"max_queued":5`} {
307+ if !strings.Contains(got, want) {
308+ t.Fatalf("log lacks %s: %s", want, got)
309+ }
310+ }
311+}
@@ -3,23 +3,56 @@ package wayland
3 import (3 import (
4 "context"4 "context"
5 "reflect"5 "reflect"
6+ "strings"
7+ "sync"
6 "testing"8 "testing"
7 "time"9 "time"
8 10
9 "github.com/bnema/neferwl/internal/adapters/logging"11 "github.com/bnema/neferwl/internal/adapters/logging"
10 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
11 )14 )
12 15
13-func TestDrainCommandsCollapsesMotion(t *testing.T) {16+// Adjacent motions for one window fold into the last queued command, summing
14- cmds := make(chan ports.ClientCommand, 16)17+// 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}18+// order of arrival is kept.
16- cmds <- ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1}19+func TestCommandRingCollapsesMotionInOrder(t *testing.T) {
17- cmds <- ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}20+ s, _, _, _, _, _ := presentServer(t)
18- cmds <- ports.PointerMotionTo{ID: 1, X: 4}21+ locked := ports.SecurityState{Generation: 2, Protected: true}
19- cmds <- ports.PointerFocus{ID: 2}22+ unlocked := ports.SecurityState{Generation: 3}
20- cmds <- ports.PointerMotionTo{ID: 2, X: 5}23+ var got []ports.ClientCommand // display goroutine only
21- cmds <- ports.PointerMotionTo{ID: 2, X: 6}24+ 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)25+ release := make(chan struct{})
26+ stalled := make(chan struct{})
27+ s.display.Post(func() { close(stalled); <-release })
28+ <-stalled
29+ for _, c := range []ports.ClientCommand{
30+ ports.PointerMotionTo{ID: 1, X: 1, DX: 0.25, UnaccelDX: 0.5},
31+ ports.PointerMotionTo{ID: 1, X: 2, DX: 0.25, UnaccelDX: 0.5},
32+ ports.PointerMotionTo{ID: 1, X: 3, DX: 0.25, DY: -0.5, UnaccelDX: 0.5, UnaccelDY: -1},
33+ ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
34+ ports.PointerMotionTo{ID: 1, X: 4},
35+ ports.PointerFocus{ID: 2},
36+ ports.PointerMotionTo{ID: 2, X: 5},
37+ ports.PointerMotionTo{ID: 2, X: 6},
38+ ports.PointerMotionTo{ID: 1, X: 7}, // other window: not adjacent-same
39+ // Enveloped, as core sends them: same state merges, a different
40+ // state, a bare motion, or another window does not.
41+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 8, DX: 1, UnaccelDY: 2}},
42+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 9, DX: 2, UnaccelDY: 3}},
43+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DY: 4}},
44+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 11, DX: 1}},
45+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 1}},
46+ ports.PointerMotionTo{ID: 1, X: 13},
47+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
48+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
49+ ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
50+ } {
51+ if !r.Push(queue(c)) {
52+ t.Fatal("push refused")
53+ }
54+ }
55+ close(release)
23 want := []ports.ClientCommand{56 want := []ports.ClientCommand{
24 // Slow motions add up: none of the sub-pixel deltas is lost.57 // 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},58 ports.PointerMotionTo{ID: 1, X: 3, DX: 0.75, DY: -0.5, UnaccelDX: 1.5, UnaccelDY: -1},
@@ -27,13 +60,112 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) {
27 ports.PointerMotionTo{ID: 1, X: 4},60 ports.PointerMotionTo{ID: 1, X: 4},
28 ports.PointerFocus{ID: 2},61 ports.PointerFocus{ID: 2},
29 ports.PointerMotionTo{ID: 2, X: 6},62 ports.PointerMotionTo{ID: 2, X: 6},
63+ ports.PointerMotionTo{ID: 1, X: 7},
64+ ports.SecurityCommand{State: locked, Command: ports.PointerMotionTo{ID: 1, X: 10, DX: 3, DY: 4, UnaccelDY: 5}},
65+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 12, DX: 2}},
66+ ports.PointerMotionTo{ID: 1, X: 13},
67+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 1, X: 14}},
68+ ports.SecurityCommand{State: unlocked, Command: ports.PointerMotionTo{ID: 2, X: 15}},
69+ ports.SecurityCommand{State: unlocked, Command: ports.PointerButtonTo{ID: 2}},
70+ }
71+ var final []ports.ClientCommand
72+ s.display.Do(func() { final = got }) // runs after the drain posted before it
73+ if !reflect.DeepEqual(final, want) {
74+ t.Fatalf("applied %#v, want %#v", final, want)
75+ }
76+}
77+
78+// A merge needs no room: motion keeps folding into the last queued command
79+// while the ring is full of other commands, and the push does not block.
80+func TestCommandRingMergesWhenFull(t *testing.T) {
81+ s, _, _, _, _, _ := presentServer(t)
82+ var got []ports.ClientCommand
83+ r := newCommandRing(s.display, func(q queuedCommand) { got = append(got, q.command()) })
84+ release := releaseOnce(t)
85+ stalled := make(chan struct{})
86+ s.display.Post(func() { close(stalled); <-release.ch })
87+ <-stalled
88+ for i := range commandRingSize - 1 {
89+ r.Push(queue(ports.PointerFocus{ID: ports.WindowID(i)}))
90+ }
91+ done := make(chan struct{})
92+ go func() {
93+ defer close(done)
94+ for range 1000 { // fills the ring with one motion, then folds the rest
95+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
96+ }
97+ }()
98+ select {
99+ case <-done:
100+ case <-time.After(2 * time.Second):
101+ t.Fatal("motion push blocked on a full ring")
102+ }
103+ release.do()
104+ var last ports.ClientCommand
105+ s.display.Do(func() { last = got[len(got)-1] })
106+ if m, ok := last.(ports.PointerMotionTo); !ok || m.DX != 1000 {
107+ t.Fatalf("last applied %#v, want one motion with DX 1000", last)
108+ }
109+}
110+
111+// A busy display goroutine must not stall the forwarder: it keeps draining
112+// the Commands channel into the ring up to the ring capacity, and only then
113+// does it wait (and core with it, through the full channel).
114+func TestForwarderDrainsWhileDisplayBusy(t *testing.T) {
115+ cmds := make(chan ports.ClientCommand, 32)
116+ s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ ctx, cancel := context.WithCancel(context.Background())
121+ done := make(chan error, 1)
122+ go func() { done <- s.Run(ctx) }()
123+ defer func() { cancel(); <-done }()
124+
125+ // Deferred after the shutdown above, so it runs first: a failing
126+ // assertion never leaves the display stalled while Run is joined.
127+ release := releaseOnce(t)
128+ defer release.do()
129+ stalled := make(chan struct{})
130+ s.display.Post(func() { close(stalled); <-release.ch })
131+ <-stalled
132+
133+ // Not mergeable: every command takes a slot.
134+ const total = commandRingSize + 100
135+ sent := make(chan struct{})
136+ go func() {
137+ defer close(sent)
138+ for i := range total {
139+ select {
140+ case cmds <- ports.PointerFocus{ID: ports.WindowID(i + 1)}:
141+ case <-ctx.Done():
142+ return
143+ }
144+ }
145+ }()
146+ queued := func() int {
147+ s.commands.mu.Lock()
148+ defer s.commands.mu.Unlock()
149+ return len(s.commands.ring)
150+ }
151+ // Ring full, one command in the blocked forwarder, a full channel.
152+ deadline := time.Now().Add(5 * time.Second)
153+ for queued() != commandRingSize || len(cmds) != cap(cmds) {
154+ if time.Now().After(deadline) {
155+ t.Fatalf("ring holds %d (want %d), channel %d (want %d)", queued(), commandRingSize, len(cmds), cap(cmds))
156+ }
157+ time.Sleep(time.Millisecond)
30 }158 }
31- if !open || !reflect.DeepEqual(batch, want) {159+ select {
32- t.Fatalf("open=%v batch=%#v", open, batch)160+ case <-sent:
161+ t.Fatal("sender finished although the ring is full")
162+ default:
33 }163 }
34- close(cmds)164+ release.do()
35- if _, open := drainCommands(ports.PointerFocus{}, cmds); open {165+ select {
36- t.Fatal("closed channel reported open")166+ case <-sent:
167+ case <-time.After(5 * time.Second):
168+ t.Fatal("forwarder did not resume after the display was released")
37 }169 }
38 }170 }
39 171
@@ -58,3 +190,122 @@ func TestCommandThroughput(t *testing.T) {
58 t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())190 t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())
59 }191 }
60 }192 }
193+
194+// release unblocks a stalled display goroutine once, from the test or its
195+// cleanup, so a failed assertion never hangs the server shutdown.
196+type release struct {
197+ ch chan struct{}
198+ once sync.Once
199+}
200+
201+func (r *release) do() { r.once.Do(func() { close(r.ch) }) }
202+
203+// releaseOnce returns a release that the test cleanup also fires, before
204+// the cleanups registered earlier (the server's).
205+func releaseOnce(t *testing.T) *release {
206+ r := &release{ch: make(chan struct{})}
207+ t.Cleanup(r.do)
208+ return r
209+}
210+
211+// A merged motion keeps the push time of the first motion folded into it, so
212+// the reported latency is the longest wait any of its motions had.
213+func TestMergeMotionKeepsOldestPushTime(t *testing.T) {
214+ t0 := time.Unix(100, 0)
215+ first := queue(ports.PointerMotionTo{ID: 1, DX: 1})
216+ first.at = t0
217+ second := queue(ports.PointerMotionTo{ID: 1, DX: 2})
218+ second.at = t0.Add(5 * time.Millisecond)
219+ if !mergeMotion(&first, second) {
220+ t.Fatal("adjacent motions did not merge")
221+ }
222+ if !first.at.Equal(t0) {
223+ t.Fatalf("merged at = %v, want the oldest %v", first.at, t0)
224+ }
225+}
226+
227+// commandStats keeps the largest push-to-apply wait and the applied count,
228+// and take resets both.
229+func TestCommandStatsObserveAndTake(t *testing.T) {
230+ var c commandStats
231+ t0 := time.Unix(100, 0)
232+ c.observe(t0, t0.Add(3*time.Millisecond))
233+ c.observe(t0, t0.Add(9*time.Millisecond))
234+ c.observe(t0, t0.Add(time.Millisecond))
235+ if n, d := c.take(); n != 3 || d != 9*time.Millisecond {
236+ t.Fatalf("take = %d, %v; want 3, 9ms", n, d)
237+ }
238+ if n, d := c.take(); n != 0 || d != 0 {
239+ t.Fatalf("second take = %d, %v; want zeros", n, d)
240+ }
241+}
242+
243+// The ring counts the largest queued length and the folded pushes, and
244+// takeStats returns then resets them.
245+func TestCommandRingStats(t *testing.T) {
246+ s, _, _, _, _, _ := presentServer(t)
247+ r := newCommandRing(s.display, func(queuedCommand) {})
248+ release := releaseOnce(t)
249+ stalled := make(chan struct{})
250+ s.display.Post(func() { close(stalled); <-release.ch })
251+ <-stalled
252+ r.Push(queue(ports.PointerFocus{ID: 1}))
253+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1}))
254+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
255+ r.Push(queue(ports.PointerMotionTo{ID: 1, DX: 1})) // folded
256+ r.Push(queue(ports.PointerFocus{ID: 2}))
257+ st := r.takeStats()
258+ if st.maxLen != 3 || st.merged != 2 || st.maxPushBlock != 0 {
259+ t.Fatalf("stats = %+v, want maxLen 3, merged 2, no block", st)
260+ }
261+ if st := r.takeStats(); st != (ringStats{}) {
262+ t.Fatalf("stats after take = %+v, want zero", st)
263+ }
264+ release.do()
265+}
266+
267+// The report logs the period's figures and resets them; a period without
268+// applied commands logs nothing.
269+func TestReportCommandStats(t *testing.T) {
270+ var out lockedBuffer
271+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
272+ h := newAdmissionHarnessWith(t, 1, Options{}, log)
273+ s := h.s
274+ s.reportCommandStats()
275+ if strings.Contains(out.String(), "command stats") {
276+ t.Fatalf("idle period logged: %s", out.String())
277+ }
278+ t0 := time.Unix(100, 0)
279+ s.commandStats.observe(t0, t0.Add(1500*time.Microsecond))
280+ s.reportCommandStats()
281+ got := out.String()
282+ for _, want := range []string{`"message":"command stats"`, `"commands":1`, `"merged":0`, `"max_queued":0`, `"max_push_block_ms":0`, `"max_latency_ms":1.5`} {
283+ if !strings.Contains(got, want) {
284+ t.Fatalf("log lacks %s: %s", want, got)
285+ }
286+ }
287+ n := strings.Count(got, "command stats")
288+ s.reportCommandStats()
289+ if strings.Count(out.String(), "command stats") != n {
290+ t.Fatalf("stats not reset: %s", out.String())
291+ }
292+}
293+
294+// A stalled display applies nothing but fills its ring: the period still
295+// logs, so a long stall is not hidden.
296+func TestReportCommandStatsDuringStall(t *testing.T) {
297+ var out lockedBuffer
298+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}).WithField("component", "wayland")
299+ h := newAdmissionHarnessWith(t, 1, Options{}, log)
300+ s := h.s
301+ s.commands.mu.Lock()
302+ s.commands.stats.maxLen = 5
303+ s.commands.mu.Unlock()
304+ s.reportCommandStats()
305+ got := out.String()
306+ for _, want := range []string{`"message":"command stats"`, `"commands":0`, `"max_queued":5`} {
307+ if !strings.Contains(got, want) {
308+ t.Fatalf("log lacks %s: %s", want, got)
309+ }
310+ }
311+}
M internal/adapters/wayland/postring.go +48 -2
@@ -2,14 +2,17 @@ package wayland
22
33 import (
44 "sync"
5+ "time"
56
67 "github.com/bnema/purego-libwayland/server"
78 )
89
910 // postRing carries values from producer goroutines to the display goroutine
1011 // 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.
12+// guards only the slices and the pending flag. Values are applied in order of
13+// arrival and never dropped. A ring built with a merge function may fold a
14+// pushed value into the last queued one (and only that one, under the mutex),
15+// so nothing is ever reordered.
1316 //
1417 // It binds its drain once and posts it at most once per batch: Display.Post
1518 // is unbounded (a Post per value would let a fast producer grow the queue),
@@ -18,6 +21,9 @@ type postRing[T any] struct {
1821 display *server.Display
1922 apply func(T)
2023 drainFn func()
24+ // merge, when set, tries to fold v into the last queued value, which has
25+ // not been drained yet. It runs under mu and must not block or allocate.
26+ merge func(last *T, v T) bool
2127 // space is signalled (never blocking) after each drain and after a push
2228 // that left room, to wake blocked pushers one after the other.
2329 space chan struct{}
@@ -26,11 +32,36 @@ type postRing[T any] struct {
2632 ring, spare []T
2733 limit int
2834 pending bool // a drain is posted and has not taken the ring yet
35+ stats ringStats
36+}
37+
38+// ringStats is what a ring saw since the last takeStats. It lets a live log
39+// tell a slow display goroutine (long queue, long push block) from a slow
40+// producer.
41+type ringStats struct {
42+ maxLen int // largest queued length
43+ merged int // pushes folded into a queued value
44+ maxPushBlock time.Duration // longest wait of one Push on a full ring
45+}
46+
47+// takeStats returns the counters and resets them.
48+func (r *postRing[T]) takeStats() ringStats {
49+ r.mu.Lock()
50+ st := r.stats
51+ r.stats = ringStats{}
52+ r.mu.Unlock()
53+ return st
2954 }
3055
3156 // newPostRing returns a ring of limit values whose values apply runs on the
3257 // display goroutine, in order. limit must be at least 1.
3358 func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] {
59+ return newMergingPostRing(d, limit, apply, nil)
60+}
61+
62+// newMergingPostRing is newPostRing with a merge function (see postRing). A
63+// merged push needs no room: it succeeds on a full ring.
64+func newMergingPostRing[T any](d *server.Display, limit int, apply func(T), merge func(last *T, v T) bool) *postRing[T] {
3465 r := &postRing[T]{
3566 display: d,
3667 apply: apply,
@@ -38,6 +69,7 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T
3869 ring: make([]T, 0, limit),
3970 spare: make([]T, 0, limit),
4071 limit: limit,
72+ merge: merge,
4173 }
4274 r.drainFn = r.drain
4375 return r
@@ -47,10 +79,21 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T
4779 // the caller, which must not be the display goroutine, until a drain made
4880 // room; it reports false, with v dropped, once the display has stopped.
4981 func (r *postRing[T]) Push(v T) bool {
82+ var blocked time.Duration // total wait of this push on a full ring
5083 for {
5184 r.mu.Lock()
85+ // A non-empty ring always has its drain posted, so the last value is
86+ // still queued: merging into it needs neither room nor a new post.
87+ if n := len(r.ring); r.merge != nil && n > 0 && r.merge(&r.ring[n-1], v) {
88+ r.stats.merged++
89+ r.stats.maxPushBlock = max(r.stats.maxPushBlock, blocked)
90+ r.mu.Unlock()
91+ return true
92+ }
5293 if len(r.ring) < r.limit {
5394 r.ring = append(r.ring, v)
95+ r.stats.maxLen = max(r.stats.maxLen, len(r.ring))
96+ r.stats.maxPushBlock = max(r.stats.maxPushBlock, blocked)
5497 post := !r.pending
5598 r.pending = true
5699 room := len(r.ring) < r.limit
@@ -64,8 +107,11 @@ func (r *postRing[T]) Push(v T) bool {
64107 return true
65108 }
66109 r.mu.Unlock()
110+ // Only the blocking path reads the clock: the fast path stays free.
111+ start := time.Now()
67112 select {
68113 case <-r.space:
114+ blocked += time.Since(start)
69115 case <-r.display.Stopped():
70116 return false
71117 }
@@ -2,14 +2,17 @@ package wayland
2 2
3 import (3 import (
4 "sync"4 "sync"
5+ "time"
5 6
6 "github.com/bnema/purego-libwayland/server"7 "github.com/bnema/purego-libwayland/server"
7 )8 )
8 9
9 // postRing carries values from producer goroutines to the display goroutine10 // postRing carries values from producer goroutines to the display goroutine
10 // without allocating. It is a transport queue, not window state: the mutex11 // 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 or12+// guards only the slices and the pending flag. Values are applied in order of
12-// dropped, in order of arrival.13+// arrival and never dropped. A ring built with a merge function may fold a
14+// pushed value into the last queued one (and only that one, under the mutex),
15+// so nothing is ever reordered.
13 //16 //
14 // It binds its drain once and posts it at most once per batch: Display.Post17 // 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),18 // is unbounded (a Post per value would let a fast producer grow the queue),
@@ -18,6 +21,9 @@ type postRing[T any] struct {
18 display *server.Display21 display *server.Display
19 apply func(T)22 apply func(T)
20 drainFn func()23 drainFn func()
24+ // merge, when set, tries to fold v into the last queued value, which has
25+ // not been drained yet. It runs under mu and must not block or allocate.
26+ merge func(last *T, v T) bool
21 // space is signalled (never blocking) after each drain and after a push27 // space is signalled (never blocking) after each drain and after a push
22 // that left room, to wake blocked pushers one after the other.28 // that left room, to wake blocked pushers one after the other.
23 space chan struct{}29 space chan struct{}
@@ -26,11 +32,36 @@ type postRing[T any] struct {
26 ring, spare []T32 ring, spare []T
27 limit int33 limit int
28 pending bool // a drain is posted and has not taken the ring yet34 pending bool // a drain is posted and has not taken the ring yet
35+ stats ringStats
36+}
37+
38+// ringStats is what a ring saw since the last takeStats. It lets a live log
39+// tell a slow display goroutine (long queue, long push block) from a slow
40+// producer.
41+type ringStats struct {
42+ maxLen int // largest queued length
43+ merged int // pushes folded into a queued value
44+ maxPushBlock time.Duration // longest wait of one Push on a full ring
45+}
46+
47+// takeStats returns the counters and resets them.
48+func (r *postRing[T]) takeStats() ringStats {
49+ r.mu.Lock()
50+ st := r.stats
51+ r.stats = ringStats{}
52+ r.mu.Unlock()
53+ return st
29 }54 }
30 55
31 // newPostRing returns a ring of limit values whose values apply runs on the56 // newPostRing returns a ring of limit values whose values apply runs on the
32 // display goroutine, in order. limit must be at least 1.57 // 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] {58 func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T] {
59+ return newMergingPostRing(d, limit, apply, nil)
60+}
61+
62+// newMergingPostRing is newPostRing with a merge function (see postRing). A
63+// merged push needs no room: it succeeds on a full ring.
64+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]{65 r := &postRing[T]{
35 display: d,66 display: d,
36 apply: apply,67 apply: apply,
@@ -38,6 +69,7 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T
38 ring: make([]T, 0, limit),69 ring: make([]T, 0, limit),
39 spare: make([]T, 0, limit),70 spare: make([]T, 0, limit),
40 limit: limit,71 limit: limit,
72+ merge: merge,
41 }73 }
42 r.drainFn = r.drain74 r.drainFn = r.drain
43 return r75 return r
@@ -47,10 +79,21 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T
47 // the caller, which must not be the display goroutine, until a drain made79 // the caller, which must not be the display goroutine, until a drain made
48 // room; it reports false, with v dropped, once the display has stopped.80 // room; it reports false, with v dropped, once the display has stopped.
49 func (r *postRing[T]) Push(v T) bool {81 func (r *postRing[T]) Push(v T) bool {
82+ var blocked time.Duration // total wait of this push on a full ring
50 for {83 for {
51 r.mu.Lock()84 r.mu.Lock()
85+ // A non-empty ring always has its drain posted, so the last value is
86+ // still queued: merging into it needs neither room nor a new post.
87+ if n := len(r.ring); r.merge != nil && n > 0 && r.merge(&r.ring[n-1], v) {
88+ r.stats.merged++
89+ r.stats.maxPushBlock = max(r.stats.maxPushBlock, blocked)
90+ r.mu.Unlock()
91+ return true
92+ }
52 if len(r.ring) < r.limit {93 if len(r.ring) < r.limit {
53 r.ring = append(r.ring, v)94 r.ring = append(r.ring, v)
95+ r.stats.maxLen = max(r.stats.maxLen, len(r.ring))
96+ r.stats.maxPushBlock = max(r.stats.maxPushBlock, blocked)
54 post := !r.pending97 post := !r.pending
55 r.pending = true98 r.pending = true
56 room := len(r.ring) < r.limit99 room := len(r.ring) < r.limit
@@ -64,8 +107,11 @@ func (r *postRing[T]) Push(v T) bool {
64 return true107 return true
65 }108 }
66 r.mu.Unlock()109 r.mu.Unlock()
110+ // Only the blocking path reads the clock: the fast path stays free.
111+ start := time.Now()
67 select {112 select {
68 case <-r.space:113 case <-r.space:
114+ blocked += time.Since(start)
69 case <-r.display.Stopped():115 case <-r.display.Stopped():
70 return false116 return false
71 }117 }
M internal/adapters/wayland/postring_test.go +49 -0
@@ -2,6 +2,8 @@ package wayland
22
33 import (
44 "context"
5+ "runtime"
6+ "strings"
57 "sync"
68 "testing"
79 "time"
@@ -158,3 +160,50 @@ func TestPostRingPushStopsWithDisplay(t *testing.T) {
158160 t.Fatal("push blocked after the display stopped")
159161 }
160162 }
163+
164+// A push that waits on a full ring reports how long, and a drain-side push
165+// that finds room does not.
166+func TestPostRingStatsPushBlock(t *testing.T) {
167+ s, _, _, _, _, _ := presentServer(t)
168+ r := newPostRing(s.display, 1, func(int) {})
169+ // A failed assertion still frees the display before server shutdown.
170+ release := releaseOnce(t)
171+ stalled := make(chan struct{})
172+ s.display.Post(func() { close(stalled); <-release.ch })
173+ <-stalled
174+ r.Push(1)
175+ if st := r.takeStats(); st.maxLen != 1 || st.maxPushBlock != 0 {
176+ t.Fatalf("stats = %+v, want maxLen 1 and no block", st)
177+ }
178+ done := make(chan struct{})
179+ go func() { defer close(done); r.Push(2) }() // full: waits for the drain
180+ waitPushWaiting(t)
181+ seen := time.Now()
182+ time.Sleep(2 * time.Millisecond) // the block lasts at least this
183+ held := time.Since(seen)
184+ release.do()
185+ <-done
186+ if st := r.takeStats(); st.maxPushBlock < held {
187+ t.Fatalf("stats = %+v, want a push block of at least %v", st, held)
188+ }
189+}
190+
191+// waitPushWaiting returns once a goroutine waits inside postRing.Push, read
192+// from the goroutine dump, so the test needs no sleep to know the push blocks.
193+func waitPushWaiting(t *testing.T) {
194+ t.Helper()
195+ buf := make([]byte, 1<<20)
196+ deadline := time.Now().Add(5 * time.Second)
197+ for {
198+ if time.Now().After(deadline) {
199+ t.Fatalf("no goroutine waits in postRing.Push:\n%s", buf[:runtime.Stack(buf, true)])
200+ }
201+ dump := string(buf[:runtime.Stack(buf, true)])
202+ for _, g := range strings.Split(dump, "\n\n") {
203+ if strings.Contains(g, "[select") && strings.Contains(g, "postRing") && strings.Contains(g, ").Push(") {
204+ return
205+ }
206+ }
207+ runtime.Gosched()
208+ }
209+}
@@ -2,6 +2,8 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "runtime"
6+ "strings"
5 "sync"7 "sync"
6 "testing"8 "testing"
7 "time"9 "time"
@@ -158,3 +160,50 @@ func TestPostRingPushStopsWithDisplay(t *testing.T) {
158 t.Fatal("push blocked after the display stopped")160 t.Fatal("push blocked after the display stopped")
159 }161 }
160 }162 }
163+
164+// A push that waits on a full ring reports how long, and a drain-side push
165+// that finds room does not.
166+func TestPostRingStatsPushBlock(t *testing.T) {
167+ s, _, _, _, _, _ := presentServer(t)
168+ r := newPostRing(s.display, 1, func(int) {})
169+ // A failed assertion still frees the display before server shutdown.
170+ release := releaseOnce(t)
171+ stalled := make(chan struct{})
172+ s.display.Post(func() { close(stalled); <-release.ch })
173+ <-stalled
174+ r.Push(1)
175+ if st := r.takeStats(); st.maxLen != 1 || st.maxPushBlock != 0 {
176+ t.Fatalf("stats = %+v, want maxLen 1 and no block", st)
177+ }
178+ done := make(chan struct{})
179+ go func() { defer close(done); r.Push(2) }() // full: waits for the drain
180+ waitPushWaiting(t)
181+ seen := time.Now()
182+ time.Sleep(2 * time.Millisecond) // the block lasts at least this
183+ held := time.Since(seen)
184+ release.do()
185+ <-done
186+ if st := r.takeStats(); st.maxPushBlock < held {
187+ t.Fatalf("stats = %+v, want a push block of at least %v", st, held)
188+ }
189+}
190+
191+// waitPushWaiting returns once a goroutine waits inside postRing.Push, read
192+// from the goroutine dump, so the test needs no sleep to know the push blocks.
193+func waitPushWaiting(t *testing.T) {
194+ t.Helper()
195+ buf := make([]byte, 1<<20)
196+ deadline := time.Now().Add(5 * time.Second)
197+ for {
198+ if time.Now().After(deadline) {
199+ t.Fatalf("no goroutine waits in postRing.Push:\n%s", buf[:runtime.Stack(buf, true)])
200+ }
201+ dump := string(buf[:runtime.Stack(buf, true)])
202+ for _, g := range strings.Split(dump, "\n\n") {
203+ if strings.Contains(g, "[select") && strings.Contains(g, "postRing") && strings.Contains(g, ").Push(") {
204+ return
205+ }
206+ }
207+ runtime.Gosched()
208+ }
209+}
M internal/adapters/wayland/server.go +13 -52
@@ -131,10 +131,13 @@ type Server struct {
131131 // Screencast (screencast.go): casts by the adapter's ID and by the ID
132132 // core knows them by; castRing carries the adapter's commands to the
133133 // 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]
140+ commandStats commandStats
138141 castInvFn func()
139142 castInvPosted bool
140143 castInvSent bool
@@ -389,6 +392,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
389392 s.casts = opts.Casts
390393 s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}
391394 s.castRing = newPostRing(d, castRingSize, s.applyCast)
395+ s.commands = newCommandRing(d, s.applyQueued)
392396 s.castInvFn = s.publishCastInventory
393397 s.leaseDevices = map[string]*leaseDevice{}
394398 s.pendingLeases = map[uint64]*leaseObject{}
@@ -532,7 +536,7 @@ func (s *Server) Run(ctx context.Context) error {
532536 s.started = time.Now()
533537 s.ctx = ctx
534538 var wg sync.WaitGroup
535- wg.Add(13)
539+ wg.Add(14)
536540 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
537541 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
538542 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -555,23 +559,15 @@ func (s *Server) Run(ctx context.Context) error {
555559 if ctx.Err() != nil {
556560 return
557561 }
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 {
562+ // Never wait for the display goroutine: a busy display only
563+ // fills the ring, and only then does this goroutine wait.
564+ if !s.commands.Push(queue(cmd)) {
570565 return
571566 }
572567 }
573568 }
574569 }()
570+ go func() { defer wg.Done(); s.logCommandStats(ctx) }()
575571 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
576572 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
577573 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
@@ -746,41 +742,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
746742 s.castCommitted(c.ID)
747743 }
748744
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-
784745 func (s *Server) apply(cmd ports.ClientCommand) {
785746 if envelope, ok := cmd.(ports.SecurityCommand); ok {
786747 if s.security == nil || envelope.State != s.security.Snapshot() {
@@ -131,10 +131,13 @@ type Server struct {
131 // Screencast (screencast.go): casts by the adapter's ID and by the ID131 // 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 the132 // 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.CastSink134+ casts ports.CastSink
135- castSessions map[uint64]*castSession135+ castSessions map[uint64]*castSession
136- castByCore map[uint64]*castSession136+ 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]
140+ commandStats commandStats
138 castInvFn func()141 castInvFn func()
139 castInvPosted bool142 castInvPosted bool
140 castInvSent bool143 castInvSent bool
@@ -389,6 +392,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
389 s.casts = opts.Casts392 s.casts = opts.Casts
390 s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}393 s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}
391 s.castRing = newPostRing(d, castRingSize, s.applyCast)394 s.castRing = newPostRing(d, castRingSize, s.applyCast)
395+ s.commands = newCommandRing(d, s.applyQueued)
392 s.castInvFn = s.publishCastInventory396 s.castInvFn = s.publishCastInventory
393 s.leaseDevices = map[string]*leaseDevice{}397 s.leaseDevices = map[string]*leaseDevice{}
394 s.pendingLeases = map[uint64]*leaseObject{}398 s.pendingLeases = map[uint64]*leaseObject{}
@@ -532,7 +536,7 @@ func (s *Server) Run(ctx context.Context) error {
532 s.started = time.Now()536 s.started = time.Now()
533 s.ctx = ctx537 s.ctx = ctx
534 var wg sync.WaitGroup538 var wg sync.WaitGroup
535- wg.Add(13)539+ wg.Add(14)
536 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()540 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
537 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()541 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
538 go func() { defer wg.Done(); s.forwardLeases(ctx) }()542 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -555,23 +559,15 @@ func (s *Server) Run(ctx context.Context) error {
555 if ctx.Err() != nil {559 if ctx.Err() != nil {
556 return560 return
557 }561 }
558- // One display round trip per batch: Do waits for the event562+ // Never wait for the display goroutine: a busy display only
559- // loop, so a round trip per motion capped pointer events at563+ // fills the ring, and only then does this goroutine wait.
560- // ~150/s and let a backlog of stale motions build up.564+ 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 return565 return
571 }566 }
572 }567 }
573 }568 }
574 }()569 }()
570+ go func() { defer wg.Done(); s.logCommandStats(ctx) }()
575 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()571 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
576 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()572 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
577 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()573 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
@@ -746,41 +742,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
746 s.castCommitted(c.ID)742 s.castCommitted(c.ID)
747 }743 }
748 744
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) {745 func (s *Server) apply(cmd ports.ClientCommand) {
785 if envelope, ok := cmd.(ports.SecurityCommand); ok {746 if envelope, ok := cmd.(ports.SecurityCommand); ok {
786 if s.security == nil || envelope.State != s.security.Snapshot() {747 if s.security == nil || envelope.State != s.security.Snapshot() {
M internal/app/app.go +6 -3
@@ -214,14 +214,16 @@ func renderNode(dev uint64) string {
214214 }
215215
216216 // latest sends v without blocking and replaces a value the consumer has not
217-// read yet: the newest wins. Only one goroutine may send on ch; consumers only
218-// receive, so the retry after draining cannot find the buffer full again.
219-func latest[T any](ch chan T, v T) {
217+// read yet: the newest wins. It reports whether it dropped an unread value.
218+// Only one goroutine may send on ch; consumers only receive, so the retry
219+// after draining cannot find the buffer full again.
220+func latest[T any](ch chan T, v T) (replaced bool) {
220221 select {
221222 case ch <- v:
222223 default:
223224 select {
224225 case <-ch:
226+ replaced = true
225227 default:
226228 }
227229 select {
@@ -229,4 +231,5 @@ func latest[T any](ch chan T, v T) {
229231 default:
230232 }
231233 }
234+ return replaced
232235 }
@@ -214,14 +214,16 @@ func renderNode(dev uint64) string {
214 }214 }
215 215
216 // latest sends v without blocking and replaces a value the consumer has not216 // latest sends v without blocking and replaces a value the consumer has not
217-// read yet: the newest wins. Only one goroutine may send on ch; consumers only217+// read yet: the newest wins. It reports whether it dropped an unread value.
218-// receive, so the retry after draining cannot find the buffer full again.218+// Only one goroutine may send on ch; consumers only receive, so the retry
219-func latest[T any](ch chan T, v T) {219+// after draining cannot find the buffer full again.
220+func latest[T any](ch chan T, v T) (replaced bool) {
220 select {221 select {
221 case ch <- v:222 case ch <- v:
222 default:223 default:
223 select {224 select {
224 case <-ch:225 case <-ch:
226+ replaced = true
225 default:227 default:
226 }228 }
227 select {229 select {
@@ -229,4 +231,5 @@ func latest[T any](ch chan T, v T) {
229 default:231 default:
230 }232 }
231 }233 }
234+ return replaced
232 }235 }
M internal/app/capture_test.go +13 -18
@@ -11,12 +11,12 @@ import (
1111
1212 func TestCaptureUnknownOutputClosesFile(t *testing.T) {
1313 replies := make(chan ports.CaptureDone, 1)
14- set := newOutputSet(context.Background(), replies)
14+ router := newOutputRouter(context.Background(), replies, outputRoutes{})
1515 f, err := os.CreateTemp(t.TempDir(), "capture")
1616 if err != nil {
1717 t.Fatal(err)
1818 }
19- set.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})
19+ router.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})
2020 select {
2121 case result := <-replies:
2222 if result.ID != 9 || result.Err == nil {
@@ -34,17 +34,17 @@ func TestCaptureFullOutputQueueDoesNotBlockRouting(t *testing.T) {
3434 ctx, cancel := context.WithCancel(context.Background())
3535 defer cancel()
3636 replies := make(chan ports.CaptureDone, 1)
37- set := newOutputSet(ctx, replies)
37+ router := newOutputRouter(ctx, replies, outputRoutes{})
3838 queued := make(chan ports.CaptureRequest, 1)
3939 queued <- ports.CaptureRequest{ID: 1}
40- set.outs["test"] = &runningOutput{ctx: ctx, captures: queued, done: make(chan struct{}), stop: func() {}}
40+ router.outs["test"] = &mailbox{ctx: ctx, captures: queued, done: make(chan struct{}), stop: func() {}}
4141 f, err := os.CreateTemp(t.TempDir(), "capture")
4242 if err != nil {
4343 t.Fatal(err)
4444 }
4545 routed := make(chan struct{})
4646 go func() {
47- set.routeCapture(ports.CaptureRequest{ID: 2, Output: "test", Dst: ports.SHMBuffer{File: f}})
47+ router.routeCapture(ports.CaptureRequest{ID: 2, Output: "test", Dst: ports.SHMBuffer{File: f}})
4848 close(routed)
4949 }()
5050 select {
@@ -71,13 +71,13 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) {
7171 outputCtx, stop := context.WithCancel(ctx)
7272 stop()
7373 replies := make(chan ports.CaptureDone, 1)
74- set := newOutputSet(ctx, replies)
75- set.outs["test"] = &runningOutput{ctx: outputCtx, captures: make(chan ports.CaptureRequest)}
74+ router := newOutputRouter(ctx, replies, outputRoutes{})
75+ router.outs["test"] = &mailbox{ctx: outputCtx, captures: make(chan ports.CaptureRequest)}
7676 f, err := os.CreateTemp(t.TempDir(), "capture")
7777 if err != nil {
7878 t.Fatal(err)
7979 }
80- set.routeCapture(ports.CaptureRequest{ID: 7, Output: "test", Dst: ports.SHMBuffer{File: f}})
80+ router.routeCapture(ports.CaptureRequest{ID: 7, Output: "test", Dst: ports.SHMBuffer{File: f}})
8181 select {
8282 case result := <-replies:
8383 if result.ID != 7 || result.Err == nil {
@@ -93,17 +93,14 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) {
9393
9494 func TestFinishedOutputDrainsCapturesWithReplies(t *testing.T) {
9595 ctx := context.Background()
96- outputCtx, stop := context.WithCancel(ctx)
97- stop()
9896 replies := make(chan ports.CaptureDone, 1)
99- set := newOutputSet(ctx, replies)
10097 queued := make(chan ports.CaptureRequest, 1)
10198 f, err := os.CreateTemp(t.TempDir(), "capture")
10299 if err != nil {
103100 t.Fatal(err)
104101 }
105102 queued <- ports.CaptureRequest{ID: 8, Dst: ports.SHMBuffer{File: f}}
106- set.failQueued(&runningOutput{ctx: outputCtx, captures: queued})
103+ failQueued(ctx, &mailbox{captures: queued}, replies)
107104 select {
108105 case result := <-replies:
109106 if result.ID != 8 || result.Err == nil {
@@ -143,16 +140,14 @@ func TestOutputStopWithFullReplies(t *testing.T) {
143140 ctx, cancel := context.WithCancel(context.Background())
144141 replies := make(chan ports.CaptureDone, 1)
145142 replies <- ports.CaptureDone{}
146- set := newOutputSet(ctx, replies)
147- set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
148- <-ctx.Done()
149- return nil
150- })
143+ captures := make(chan ports.CaptureRequest)
144+ set := newOutputSet(ctx, replies, outputRoutes{captures: captures})
145+ set.start(ctx, "test", idleRun)
151146 f, err := os.CreateTemp(t.TempDir(), "queued")
152147 if err != nil {
153148 t.Fatal(err)
154149 }
155- set.routeCapture(ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}})
150+ captures <- ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}}
156151 cancel()
157152 done := make(chan struct{})
158153 go func() { _ = set.wait(); close(done) }()
@@ -11,12 +11,12 @@ import (
11 11
12 func TestCaptureUnknownOutputClosesFile(t *testing.T) {12 func TestCaptureUnknownOutputClosesFile(t *testing.T) {
13 replies := make(chan ports.CaptureDone, 1)13 replies := make(chan ports.CaptureDone, 1)
14- set := newOutputSet(context.Background(), replies)14+ router := newOutputRouter(context.Background(), replies, outputRoutes{})
15 f, err := os.CreateTemp(t.TempDir(), "capture")15 f, err := os.CreateTemp(t.TempDir(), "capture")
16 if err != nil {16 if err != nil {
17 t.Fatal(err)17 t.Fatal(err)
18 }18 }
19- set.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})19+ router.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})
20 select {20 select {
21 case result := <-replies:21 case result := <-replies:
22 if result.ID != 9 || result.Err == nil {22 if result.ID != 9 || result.Err == nil {
@@ -34,17 +34,17 @@ func TestCaptureFullOutputQueueDoesNotBlockRouting(t *testing.T) {
34 ctx, cancel := context.WithCancel(context.Background())34 ctx, cancel := context.WithCancel(context.Background())
35 defer cancel()35 defer cancel()
36 replies := make(chan ports.CaptureDone, 1)36 replies := make(chan ports.CaptureDone, 1)
37- set := newOutputSet(ctx, replies)37+ router := newOutputRouter(ctx, replies, outputRoutes{})
38 queued := make(chan ports.CaptureRequest, 1)38 queued := make(chan ports.CaptureRequest, 1)
39 queued <- ports.CaptureRequest{ID: 1}39 queued <- ports.CaptureRequest{ID: 1}
40- set.outs["test"] = &runningOutput{ctx: ctx, captures: queued, done: make(chan struct{}), stop: func() {}}40+ router.outs["test"] = &mailbox{ctx: ctx, captures: queued, done: make(chan struct{}), stop: func() {}}
41 f, err := os.CreateTemp(t.TempDir(), "capture")41 f, err := os.CreateTemp(t.TempDir(), "capture")
42 if err != nil {42 if err != nil {
43 t.Fatal(err)43 t.Fatal(err)
44 }44 }
45 routed := make(chan struct{})45 routed := make(chan struct{})
46 go func() {46 go func() {
47- set.routeCapture(ports.CaptureRequest{ID: 2, Output: "test", Dst: ports.SHMBuffer{File: f}})47+ router.routeCapture(ports.CaptureRequest{ID: 2, Output: "test", Dst: ports.SHMBuffer{File: f}})
48 close(routed)48 close(routed)
49 }()49 }()
50 select {50 select {
@@ -71,13 +71,13 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) {
71 outputCtx, stop := context.WithCancel(ctx)71 outputCtx, stop := context.WithCancel(ctx)
72 stop()72 stop()
73 replies := make(chan ports.CaptureDone, 1)73 replies := make(chan ports.CaptureDone, 1)
74- set := newOutputSet(ctx, replies)74+ router := newOutputRouter(ctx, replies, outputRoutes{})
75- set.outs["test"] = &runningOutput{ctx: outputCtx, captures: make(chan ports.CaptureRequest)}75+ router.outs["test"] = &mailbox{ctx: outputCtx, captures: make(chan ports.CaptureRequest)}
76 f, err := os.CreateTemp(t.TempDir(), "capture")76 f, err := os.CreateTemp(t.TempDir(), "capture")
77 if err != nil {77 if err != nil {
78 t.Fatal(err)78 t.Fatal(err)
79 }79 }
80- set.routeCapture(ports.CaptureRequest{ID: 7, Output: "test", Dst: ports.SHMBuffer{File: f}})80+ router.routeCapture(ports.CaptureRequest{ID: 7, Output: "test", Dst: ports.SHMBuffer{File: f}})
81 select {81 select {
82 case result := <-replies:82 case result := <-replies:
83 if result.ID != 7 || result.Err == nil {83 if result.ID != 7 || result.Err == nil {
@@ -93,17 +93,14 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) {
93 93
94 func TestFinishedOutputDrainsCapturesWithReplies(t *testing.T) {94 func TestFinishedOutputDrainsCapturesWithReplies(t *testing.T) {
95 ctx := context.Background()95 ctx := context.Background()
96- outputCtx, stop := context.WithCancel(ctx)
97- stop()
98 replies := make(chan ports.CaptureDone, 1)96 replies := make(chan ports.CaptureDone, 1)
99- set := newOutputSet(ctx, replies)
100 queued := make(chan ports.CaptureRequest, 1)97 queued := make(chan ports.CaptureRequest, 1)
101 f, err := os.CreateTemp(t.TempDir(), "capture")98 f, err := os.CreateTemp(t.TempDir(), "capture")
102 if err != nil {99 if err != nil {
103 t.Fatal(err)100 t.Fatal(err)
104 }101 }
105 queued <- ports.CaptureRequest{ID: 8, Dst: ports.SHMBuffer{File: f}}102 queued <- ports.CaptureRequest{ID: 8, Dst: ports.SHMBuffer{File: f}}
106- set.failQueued(&runningOutput{ctx: outputCtx, captures: queued})103+ failQueued(ctx, &mailbox{captures: queued}, replies)
107 select {104 select {
108 case result := <-replies:105 case result := <-replies:
109 if result.ID != 8 || result.Err == nil {106 if result.ID != 8 || result.Err == nil {
@@ -143,16 +140,14 @@ func TestOutputStopWithFullReplies(t *testing.T) {
143 ctx, cancel := context.WithCancel(context.Background())140 ctx, cancel := context.WithCancel(context.Background())
144 replies := make(chan ports.CaptureDone, 1)141 replies := make(chan ports.CaptureDone, 1)
145 replies <- ports.CaptureDone{}142 replies <- ports.CaptureDone{}
146- set := newOutputSet(ctx, replies)143+ captures := make(chan ports.CaptureRequest)
147- set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {144+ set := newOutputSet(ctx, replies, outputRoutes{captures: captures})
148- <-ctx.Done()145+ set.start(ctx, "test", idleRun)
149- return nil
150- })
151 f, err := os.CreateTemp(t.TempDir(), "queued")146 f, err := os.CreateTemp(t.TempDir(), "queued")
152 if err != nil {147 if err != nil {
153 t.Fatal(err)148 t.Fatal(err)
154 }149 }
155- set.routeCapture(ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}})150+ captures <- ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}}
156 cancel()151 cancel()
157 done := make(chan struct{})152 done := make(chan struct{})
158 go func() { _ = set.wait(); close(done) }()153 go func() { _ = set.wait(); close(done) }()
M internal/app/contentattacher_test.go +5 -4
@@ -17,7 +17,7 @@ import (
1717 func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
1818 ctx, cancel := context.WithCancel(context.Background())
1919 defer cancel()
20- set := newOutputSet(ctx, nil)
20+ set := newOutputSet(ctx, nil, outputRoutes{})
2121 events := make(chan ports.SecurityBackendEvent, 4)
2222 set.wireSecurity(outputChannels{securityEvents: events})
2323 attacher := newMockcontentAttacher(t)
@@ -31,7 +31,8 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
3131 security ports.SecurityState
3232 }
3333 started := make(chan seen, 2)
34- run := func(ctx context.Context, _ <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, _ ports.OutputInstance) error {
34+ run := func(ctx context.Context, feed outputFeed) error {
35+ contents, returns, security := feed.Contents, feed.Returns, feed.Security
3536 started <- seen{contents, returns, <-security}
3637 <-ctx.Done()
3738 return nil
@@ -86,13 +87,13 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
8687 func TestOutputSetWaitDetachesContents(t *testing.T) {
8788 ctx, cancel := context.WithCancel(context.Background())
8889 defer cancel()
89- set := newOutputSet(ctx, nil)
90+ set := newOutputSet(ctx, nil, outputRoutes{})
9091 attacher := newMockcontentAttacher(t)
9192 set.attach = attacher
9293 ret := portsmocks.NewMockContentReturn(t)
9394 attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once()
9495 attacher.EXPECT().DetachOutput(ports.ContentReturn(ret)).Return().Once()
95- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
96+ set.start(ctx, "A", func(ctx context.Context, feed outputFeed) error {
9697 <-ctx.Done()
9798 return nil
9899 })
@@ -17,7 +17,7 @@ import (
17 func TestOutputSetAttachesAndDetachesContents(t *testing.T) {17 func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
18 ctx, cancel := context.WithCancel(context.Background())18 ctx, cancel := context.WithCancel(context.Background())
19 defer cancel()19 defer cancel()
20- set := newOutputSet(ctx, nil)20+ set := newOutputSet(ctx, nil, outputRoutes{})
21 events := make(chan ports.SecurityBackendEvent, 4)21 events := make(chan ports.SecurityBackendEvent, 4)
22 set.wireSecurity(outputChannels{securityEvents: events})22 set.wireSecurity(outputChannels{securityEvents: events})
23 attacher := newMockcontentAttacher(t)23 attacher := newMockcontentAttacher(t)
@@ -31,7 +31,8 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
31 security ports.SecurityState31 security ports.SecurityState
32 }32 }
33 started := make(chan seen, 2)33 started := make(chan seen, 2)
34- run := func(ctx context.Context, _ <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, _ ports.OutputInstance) error {34+ run := func(ctx context.Context, feed outputFeed) error {
35+ contents, returns, security := feed.Contents, feed.Returns, feed.Security
35 started <- seen{contents, returns, <-security}36 started <- seen{contents, returns, <-security}
36 <-ctx.Done()37 <-ctx.Done()
37 return nil38 return nil
@@ -86,13 +87,13 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) {
86 func TestOutputSetWaitDetachesContents(t *testing.T) {87 func TestOutputSetWaitDetachesContents(t *testing.T) {
87 ctx, cancel := context.WithCancel(context.Background())88 ctx, cancel := context.WithCancel(context.Background())
88 defer cancel()89 defer cancel()
89- set := newOutputSet(ctx, nil)90+ set := newOutputSet(ctx, nil, outputRoutes{})
90 attacher := newMockcontentAttacher(t)91 attacher := newMockcontentAttacher(t)
91 set.attach = attacher92 set.attach = attacher
92 ret := portsmocks.NewMockContentReturn(t)93 ret := portsmocks.NewMockContentReturn(t)
93 attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once()94 attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once()
94 attacher.EXPECT().DetachOutput(ports.ContentReturn(ret)).Return().Once()95 attacher.EXPECT().DetachOutput(ports.ContentReturn(ret)).Return().Once()
95- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {96+ set.start(ctx, "A", func(ctx context.Context, feed outputFeed) error {
96 <-ctx.Done()97 <-ctx.Done()
97 return nil98 return nil
98 })99 })
A internal/app/corestats.go +37 -0
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/core"
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// commandStatsEvery is how often the command outbox counters are logged,
12+// like the output and input stats.
13+const commandStatsEvery = 10 * time.Second
14+
15+// logCommandStats logs core's command counters every commandStatsEvery
16+// until ctx ends. A queued command or a flush wait means the display goroutine
17+// held core's commands up; a period with no counter set logs nothing (a wait
18+// that ends after the tick still logs, with no new command).
19+func logCommandStats(ctx context.Context, c *core.Core, log zerowrap.Logger) {
20+ t := time.NewTicker(commandStatsEvery)
21+ defer t.Stop()
22+ for {
23+ select {
24+ case <-ctx.Done():
25+ return
26+ case <-t.C:
27+ }
28+ s := c.CommandStats()
29+ if s == (core.CommandStats{}) {
30+ continue
31+ }
32+ log.Info().Int("direct", s.Direct).Int("queued", s.Queued).Int("merged", s.Merged).
33+ Uint64("max_backlog", s.MaxBacklog).
34+ Float64("max_queue_wait_ms", s.MaxQueueWait.Seconds()*1e3).
35+ Float64("max_flush_wait_ms", s.MaxFlushWait.Seconds()*1e3).Msg("outbox stats")
36+ }
37+}
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/core"
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// commandStatsEvery is how often the command outbox counters are logged,
12+// like the output and input stats.
13+const commandStatsEvery = 10 * time.Second
14+
15+// logCommandStats logs core's command counters every commandStatsEvery
16+// until ctx ends. A queued command or a flush wait means the display goroutine
17+// held core's commands up; a period with no counter set logs nothing (a wait
18+// that ends after the tick still logs, with no new command).
19+func logCommandStats(ctx context.Context, c *core.Core, log zerowrap.Logger) {
20+ t := time.NewTicker(commandStatsEvery)
21+ defer t.Stop()
22+ for {
23+ select {
24+ case <-ctx.Done():
25+ return
26+ case <-t.C:
27+ }
28+ s := c.CommandStats()
29+ if s == (core.CommandStats{}) {
30+ continue
31+ }
32+ log.Info().Int("direct", s.Direct).Int("queued", s.Queued).Int("merged", s.Merged).
33+ Uint64("max_backlog", s.MaxBacklog).
34+ Float64("max_queue_wait_ms", s.MaxQueueWait.Seconds()*1e3).
35+ Float64("max_flush_wait_ms", s.MaxFlushWait.Seconds()*1e3).Msg("outbox stats")
36+ }
37+}
M internal/app/drm.go +8 -11
@@ -332,7 +332,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
332332 active := b.seat.Subscribe()
333333 defer b.seat.Unsubscribe(active)
334334 seatActive := true // openDRM waits for the first seat enable
335- set := newOutputSet(ctx, ch.captured)
335+ set := newOutputSet(ctx, ch.captured, ch.routes())
336336 set.attach = ch.content
337337 set.wireSecurity(ch)
338338 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
@@ -347,10 +347,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
347347 cards := map[string]*drmCard{}
348348 outputs := map[string]*drm.Output{} // owner result, read only after done
349349 send := func(ev ports.OutputEvent) {
350+ // Only the select is timed: the router logs the longest wait, which
351+ // tells whether core holds up the backend loop.
352+ start := time.Now()
350353 select {
351354 case ch.events <- ev:
352355 case <-ctx.Done():
353356 }
357+ set.router.noteEventSend(time.Since(start))
354358 }
355359 // stopping holds outputs asked to stop: true when they restart with a
356360 // new mode (core keeps the screen), false when they are gone.
@@ -507,16 +511,15 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
507511 }
508512 }()
509513 o.NewCaptureRenderer = newRenderer
510- o.StartOff = set.off[name]
511514 progress.started(name, source)
512515 if cur := o.Cursor(); cur != nil {
513516 curs.set(name, cur)
514517 }
515518 active := b.seat.Subscribe()
516519 realtime := currentConfig.Performance.Realtime
517- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
520+ started := set.start(ctx, name, func(octx context.Context, feed outputFeed) error {
518521 defer b.seat.Unsubscribe(active)
519- o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
522+ o.Security, o.SecurityChanges, o.Instance, o.StartOff = ch.security, feed.Security, feed.Instance, feed.StartOff
520523 return safe("output "+name, func() error {
521524 defer o.Close()
522525 b.addPreparer(o)
@@ -534,7 +537,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
534537 schedLog.Warn().Err(err).Msg("output scheduling")
535538 }
536539 }
537- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, rc, cu, ch.presented, cap, ch.captured)
540+ return o.Run(octx, newRenderer, loadCursor, active, feed.Scenes, feed.Contents, feed.Returns, feed.Cursor, ch.presented, feed.Captures, ch.captured)
538541 })
539542 }, &o.SecurityEvents)
540543 if !started {
@@ -698,12 +701,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
698701 complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
699702 case <-deadline:
700703 complete(progress.timeout(op))
701- case s := <-ch.scenes:
702- set.scenes(s)
703- case q := <-ch.captures:
704- set.routeCapture(q)
705- case c := <-ch.cursorChanges:
706- set.setCursor(c)
707704 case stopped := <-set.stopped:
708705 if !set.stoppedCurrent(stopped) {
709706 continue
@@ -332,7 +332,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
332 active := b.seat.Subscribe()332 active := b.seat.Subscribe()
333 defer b.seat.Unsubscribe(active)333 defer b.seat.Unsubscribe(active)
334 seatActive := true // openDRM waits for the first seat enable334 seatActive := true // openDRM waits for the first seat enable
335- set := newOutputSet(ctx, ch.captured)335+ set := newOutputSet(ctx, ch.captured, ch.routes())
336 set.attach = ch.content336 set.attach = ch.content
337 set.wireSecurity(ch)337 set.wireSecurity(ch)
338 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }338 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
@@ -347,10 +347,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
347 cards := map[string]*drmCard{}347 cards := map[string]*drmCard{}
348 outputs := map[string]*drm.Output{} // owner result, read only after done348 outputs := map[string]*drm.Output{} // owner result, read only after done
349 send := func(ev ports.OutputEvent) {349 send := func(ev ports.OutputEvent) {
350+ // Only the select is timed: the router logs the longest wait, which
351+ // tells whether core holds up the backend loop.
352+ start := time.Now()
350 select {353 select {
351 case ch.events <- ev:354 case ch.events <- ev:
352 case <-ctx.Done():355 case <-ctx.Done():
353 }356 }
357+ set.router.noteEventSend(time.Since(start))
354 }358 }
355 // stopping holds outputs asked to stop: true when they restart with a359 // stopping holds outputs asked to stop: true when they restart with a
356 // new mode (core keeps the screen), false when they are gone.360 // new mode (core keeps the screen), false when they are gone.
@@ -507,16 +511,15 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
507 }511 }
508 }()512 }()
509 o.NewCaptureRenderer = newRenderer513 o.NewCaptureRenderer = newRenderer
510- o.StartOff = set.off[name]
511 progress.started(name, source)514 progress.started(name, source)
512 if cur := o.Cursor(); cur != nil {515 if cur := o.Cursor(); cur != nil {
513 curs.set(name, cur)516 curs.set(name, cur)
514 }517 }
515 active := b.seat.Subscribe()518 active := b.seat.Subscribe()
516 realtime := currentConfig.Performance.Realtime519 realtime := currentConfig.Performance.Realtime
517- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {520+ started := set.start(ctx, name, func(octx context.Context, feed outputFeed) error {
518 defer b.seat.Unsubscribe(active)521 defer b.seat.Unsubscribe(active)
519- o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance522+ o.Security, o.SecurityChanges, o.Instance, o.StartOff = ch.security, feed.Security, feed.Instance, feed.StartOff
520 return safe("output "+name, func() error {523 return safe("output "+name, func() error {
521 defer o.Close()524 defer o.Close()
522 b.addPreparer(o)525 b.addPreparer(o)
@@ -534,7 +537,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
534 schedLog.Warn().Err(err).Msg("output scheduling")537 schedLog.Warn().Err(err).Msg("output scheduling")
535 }538 }
536 }539 }
537- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, rc, cu, ch.presented, cap, ch.captured)540+ return o.Run(octx, newRenderer, loadCursor, active, feed.Scenes, feed.Contents, feed.Returns, feed.Cursor, ch.presented, feed.Captures, ch.captured)
538 })541 })
539 }, &o.SecurityEvents)542 }, &o.SecurityEvents)
540 if !started {543 if !started {
@@ -698,12 +701,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
698 complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))701 complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err))
699 case <-deadline:702 case <-deadline:
700 complete(progress.timeout(op))703 complete(progress.timeout(op))
701- case s := <-ch.scenes:
702- set.scenes(s)
703- case q := <-ch.captures:
704- set.routeCapture(q)
705- case c := <-ch.cursorChanges:
706- set.setCursor(c)
707 case stopped := <-set.stopped:704 case stopped := <-set.stopped:
708 if !set.stoppedCurrent(stopped) {705 if !set.stoppedCurrent(stopped) {
709 continue706 continue
M internal/app/headless.go +4 -10
@@ -26,7 +26,7 @@ type headlessOptions struct {
2626 //
2727 // newRenderer is configured for virtual outputs by newVulkanRenderer.
2828 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
29- set := newOutputSet(ctx, ch.captured)
29+ set := newOutputSet(ctx, ch.captured, ch.routes())
3030 set.attach = ch.content
3131 set.wireSecurity(ch)
3232 inventory := ports.OutputHeads{}
@@ -45,9 +45,9 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
4545 cur := &headless.Cursor{}
4646 curs.set(name, cur)
4747 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured}
48- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
49- opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
50- return headless.Run(octx, opts, sc, cc, rc, cu, cap)
48+ started := set.start(ctx, name, func(octx context.Context, feed outputFeed) error {
49+ opts.Security, opts.SecurityChanges, opts.Instance = ch.security, feed.Security, feed.Instance
50+ return headless.Run(octx, opts, feed.Scenes, feed.Contents, feed.Returns, feed.Cursor, feed.Captures)
5151 }, &opts.SecurityEvents)
5252 if !started {
5353 return set.wait()
@@ -90,12 +90,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
9090 apply.replySent()
9191 case heads <- headsNext:
9292 apply.headsSent()
93- case s := <-ch.scenes:
94- set.scenes(s)
95- case q := <-ch.captures:
96- set.routeCapture(q)
97- case c := <-ch.cursorChanges:
98- set.setCursor(c)
9993 case stopped := <-set.stopped:
10094 if !set.stoppedCurrent(stopped) {
10195 continue
@@ -26,7 +26,7 @@ type headlessOptions struct {
26 //26 //
27 // newRenderer is configured for virtual outputs by newVulkanRenderer.27 // newRenderer is configured for virtual outputs by newVulkanRenderer.
28 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {28 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
29- set := newOutputSet(ctx, ch.captured)29+ set := newOutputSet(ctx, ch.captured, ch.routes())
30 set.attach = ch.content30 set.attach = ch.content
31 set.wireSecurity(ch)31 set.wireSecurity(ch)
32 inventory := ports.OutputHeads{}32 inventory := ports.OutputHeads{}
@@ -45,9 +45,9 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
45 cur := &headless.Cursor{}45 cur := &headless.Cursor{}
46 curs.set(name, cur)46 curs.set(name, cur)
47 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured}47 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured}
48- started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan *ports.ContentBatch, rc ports.ContentReturn, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {48+ started := set.start(ctx, name, func(octx context.Context, feed outputFeed) error {
49- opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance49+ opts.Security, opts.SecurityChanges, opts.Instance = ch.security, feed.Security, feed.Instance
50- return headless.Run(octx, opts, sc, cc, rc, cu, cap)50+ return headless.Run(octx, opts, feed.Scenes, feed.Contents, feed.Returns, feed.Cursor, feed.Captures)
51 }, &opts.SecurityEvents)51 }, &opts.SecurityEvents)
52 if !started {52 if !started {
53 return set.wait()53 return set.wait()
@@ -90,12 +90,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
90 apply.replySent()90 apply.replySent()
91 case heads <- headsNext:91 case heads <- headsNext:
92 apply.headsSent()92 apply.headsSent()
93- case s := <-ch.scenes:
94- set.scenes(s)
95- case q := <-ch.captures:
96- set.routeCapture(q)
97- case c := <-ch.cursorChanges:
98- set.setCursor(c)
99 case stopped := <-set.stopped:93 case stopped := <-set.stopped:
100 if !set.stoppedCurrent(stopped) {94 if !set.stoppedCurrent(stopped) {
101 continue95 continue
M internal/app/outputrouting.go +5 -0
@@ -30,3 +30,8 @@ type outputChannels struct {
3030 requests <-chan ports.OutputApply
3131 replies chan<- ports.OutputApplied
3232 }
33+
34+// routes are the inputs the output router feeds the outputs from.
35+func (ch outputChannels) routes() outputRoutes {
36+ return outputRoutes{scenes: ch.scenes, cursor: ch.cursorChanges, captures: ch.captures}
37+}
@@ -30,3 +30,8 @@ type outputChannels struct {
30 requests <-chan ports.OutputApply30 requests <-chan ports.OutputApply
31 replies chan<- ports.OutputApplied31 replies chan<- ports.OutputApplied
32 }32 }
33+
34+// routes are the inputs the output router feeds the outputs from.
35+func (ch outputChannels) routes() outputRoutes {
36+ return outputRoutes{scenes: ch.scenes, cursor: ch.cursorChanges, captures: ch.captures}
37+}
M internal/app/outputrouting_test.go +173 -39
@@ -3,33 +3,59 @@ package app
33 import (
44 "context"
55 "os"
6+ "sync"
67 "testing"
8+ "time"
79
10+ "github.com/stretchr/testify/mock"
11+
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
813 "github.com/bnema/neferwl/internal/ports"
914 )
1015
16+// routeInputs are the router's input channels, as wired in production.
17+type routeInputs struct {
18+ scenes chan []ports.Scene
19+ cursor chan ports.CursorChange
20+ captures chan ports.CaptureRequest
21+}
22+
23+func newRouteInputs() routeInputs {
24+ return routeInputs{scenes: make(chan []ports.Scene), cursor: make(chan ports.CursorChange), captures: make(chan ports.CaptureRequest)}
25+}
26+
27+func (in routeInputs) routes() outputRoutes {
28+ return outputRoutes{scenes: in.scenes, cursor: in.cursor, captures: in.captures}
29+}
30+
31+// idleRun is an output that runs until stopped.
32+func idleRun(ctx context.Context, _ outputFeed) error {
33+ <-ctx.Done()
34+ return nil
35+}
36+
1137 func TestOutputRouting(t *testing.T) {
1238 ctx, cancel := context.WithCancel(context.Background())
1339 defer cancel()
1440 replies := make(chan ports.CaptureDone, 2)
15- set := newOutputSet(ctx, replies)
41+ in := newRouteInputs()
42+ set := newOutputSet(ctx, replies, in.routes())
1643 for _, name := range []string{"A", "B"} {
17- set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
18- <-ctx.Done()
19- return nil
20- })
44+ set.start(ctx, name, idleRun)
2145 <-set.outs[name].cursor // initial cursor
2246 }
23- set.scenes([]ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}})
24- set.scenes([]ports.Scene{{Output: "A", Seq: 3}})
47+ in.scenes <- []ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}}
48+ in.scenes <- []ports.Scene{{Output: "A", Seq: 3}}
49+ in.cursor <- ports.CursorChange{} // the router took both scene sets
2550 if got := <-set.outs["A"].scenes; got.Seq != 3 {
2651 t.Fatalf("A scene: %+v", got)
2752 }
2853 if got := <-set.outs["B"].scenes; got.Seq != 2 {
2954 t.Fatalf("B scene: %+v", got)
3055 }
31- set.setCursor(ports.CursorChange{Shape: "text"})
32- set.setCursor(ports.CursorChange{Shape: "pointer"})
56+ in.cursor <- ports.CursorChange{Shape: "text"}
57+ in.cursor <- ports.CursorChange{Shape: "pointer"}
58+ in.scenes <- nil // the router took both cursors
3359 for _, name := range []string{"A", "B"} {
3460 if got := <-set.outs[name].cursor; got.Shape != "pointer" {
3561 t.Fatalf("%s cursor: %+v", name, got)
@@ -39,64 +65,172 @@ func TestOutputRouting(t *testing.T) {
3965 if err != nil {
4066 t.Fatal(err)
4167 }
42- set.routeCapture(ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}})
68+ in.captures <- ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}}
4369 if got := <-set.outs["B"].captures; got.ID != 7 {
4470 t.Fatalf("B capture: %+v", got)
4571 }
4672 if err := f.Close(); err != nil {
4773 t.Fatal(err)
4874 }
49- set.outs["A"].stop()
75+ a := set.outs["A"]
76+ a.stop()
5077 if err := set.finish("A"); err != nil {
5178 t.Fatal(err)
5279 }
53- set.scenes([]ports.Scene{{Output: "A", Seq: 4}})
54- if _, ok := set.outs["A"]; ok {
55- t.Fatal("removed output retained")
56- }
57- set.routeCapture(ports.CaptureRequest{ID: 8, Output: "A"})
80+ in.scenes <- []ports.Scene{{Output: "A", Seq: 4}}
81+ in.captures <- ports.CaptureRequest{ID: 8, Output: "A"}
5882 if got := <-replies; got.ID != 8 || got.Err == nil {
5983 t.Fatalf("removed output capture: %+v", got)
6084 }
85+ select {
86+ case sc := <-a.scenes:
87+ t.Fatalf("removed output fed a scene: %+v", sc)
88+ default:
89+ }
6190 if err := set.wait(); err != nil {
6291 t.Fatal(err)
6392 }
6493 }
6594
66-// The set remembers which outputs scenes turned off, also once they are
67-// gone: drm starts an output that reconnects off (Output.StartOff).
68-func TestOutputSetRemembersOff(t *testing.T) {
69- set := newOutputSet(context.Background(), nil)
70- set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}})
71- if !set.off["A"] || set.off["B"] {
72- t.Fatal(set.off)
73- }
74- set.scenes([]ports.Scene{{Output: "A"}})
75- if len(set.off) != 0 {
76- t.Fatal(set.off)
95+// An output started after a scene turned its name off starts off; one
96+// turned back on starts on.
97+func TestOutputStartsOffAfterOffScene(t *testing.T) {
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+ in := newRouteInputs()
101+ set := newOutputSet(ctx, nil, in.routes())
102+ startOff := make(chan bool, 1)
103+ run := func(ctx context.Context, feed outputFeed) error {
104+ startOff <- feed.StartOff
105+ <-ctx.Done()
106+ return nil
107+ }
108+ in.scenes <- []ports.Scene{{Output: "A", Off: true}, {Output: "B"}}
109+ for _, tc := range []struct {
110+ name string
111+ want bool
112+ }{{"A", true}, {"B", false}} {
113+ set.start(ctx, tc.name, run)
114+ if got := <-startOff; got != tc.want {
115+ t.Fatalf("%s start off %v, want %v", tc.name, got, tc.want)
116+ }
117+ }
118+ in.scenes <- []ports.Scene{{Output: "A"}}
119+ set.outs["A"].stop()
120+ if err := set.finish("A"); err != nil {
121+ t.Fatal(err)
122+ }
123+ set.start(ctx, "A", run)
124+ if <-startOff {
125+ t.Fatal("A starts off after a scene turned it on")
126+ }
127+ if err := set.wait(); err != nil {
128+ t.Fatal(err)
77129 }
78130 }
79131
80-func TestOutputRoutingAllocations(t *testing.T) {
81- set := newOutputSet(context.Background(), nil)
132+// A late unregister of a previous lifetime never removes its successor.
133+func TestRouterUnregisterKeepsNewerLifetime(t *testing.T) {
134+ r := newOutputRouter(context.Background(), nil, outputRoutes{})
135+ newer := &mailbox{instance: 2, scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1)}
136+ r.register("A", newer)
137+ r.unregister("A", 1)
138+ if r.outs["A"] != newer {
139+ t.Fatal("stale unregister removed the newer lifetime")
140+ }
141+ r.unregister("A", 2)
142+ if _, ok := r.outs["A"]; ok {
143+ t.Fatal("unregister kept the lifetime")
144+ }
145+}
146+
147+// Lifecycle work on the backend loop never holds up routing: while a new
148+// output's content attach blocks (it waits for the display goroutine), the
149+// running output still gets its scenes and cursor.
150+func TestBlockedStartDoesNotDelayRouting(t *testing.T) {
82151 ctx, cancel := context.WithCancel(context.Background())
83152 defer cancel()
84- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
85- <-ctx.Done()
86- return nil
153+ in := newRouteInputs()
154+ set := newOutputSet(ctx, nil, in.routes())
155+ set.start(ctx, "A", idleRun)
156+ <-set.outs["A"].cursor
157+ attacher := newMockcontentAttacher(t)
158+ set.attach = attacher
159+ release := make(chan struct{})
160+ attaching := make(chan struct{})
161+ ret := portsmocks.NewMockContentReturn(t)
162+ attacher.EXPECT().AttachOutput("B").RunAndReturn(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
163+ close(attaching)
164+ <-release
165+ return nil, ret
166+ }).Once()
167+ attacher.EXPECT().DetachOutput(mock.Anything).Maybe()
168+ releaseB := sync.OnceFunc(func() { close(release) })
169+ wait := sync.OnceValue(set.wait)
170+ started := make(chan bool, 1)
171+ startDone := make(chan struct{})
172+ go func() {
173+ defer close(startDone)
174+ started <- set.start(ctx, "B", idleRun)
175+ }()
176+ // A failed assertion still unblocks and joins B's start.
177+ t.Cleanup(func() {
178+ releaseB()
179+ cancel()
180+ <-startDone
181+ _ = wait()
87182 })
183+ <-attaching
184+ a := set.outs["A"]
185+ deadline := time.After(2 * time.Second)
186+ for seq := uint64(1); seq <= 3; seq++ {
187+ select {
188+ case in.scenes <- []ports.Scene{{Output: "A", Seq: seq}}:
189+ case <-deadline:
190+ t.Fatal("router blocked behind a starting output")
191+ }
192+ select {
193+ case got := <-a.scenes:
194+ if got.Seq != seq {
195+ t.Fatalf("A scene %d, want %d", got.Seq, seq)
196+ }
197+ case <-deadline:
198+ t.Fatal("scene not routed while another output starts")
199+ }
200+ }
201+ select {
202+ case in.cursor <- ports.CursorChange{Shape: "text"}:
203+ case <-deadline:
204+ t.Fatal("cursor blocked behind a starting output")
205+ }
206+ if got := <-a.cursor; got.Shape != "text" {
207+ t.Fatalf("A cursor %+v", got)
208+ }
209+ releaseB()
210+ if !<-started {
211+ t.Fatal("B did not start")
212+ }
213+ if err := wait(); err != nil {
214+ t.Fatal(err)
215+ }
216+}
217+
218+func TestOutputRoutingAllocations(t *testing.T) {
219+ r := newOutputRouter(context.Background(), nil, outputRoutes{})
220+ ctx, cancel := context.WithCancel(context.Background())
221+ defer cancel()
222+ m := newMailbox(1, ctx, cancel, make(chan struct{}))
223+ r.register("A", m)
224+ <-m.cursor
88225 scenes := []ports.Scene{{Output: "A"}}
89226 cursor := ports.CursorChange{Shape: "pointer"}
90227 capture := ports.CaptureRequest{Output: "A"}
91228 if got := testing.AllocsPerRun(100, func() {
92- set.scenes(scenes)
93- set.setCursor(cursor)
94- set.routeCapture(capture)
95- <-set.outs["A"].captures
229+ r.scenes(scenes)
230+ r.setCursor(cursor)
231+ r.routeCapture(capture)
232+ <-m.captures
96233 }); got != 0 {
97234 t.Fatalf("routing: %g allocations per run, want 0", got)
98235 }
99- if err := set.wait(); err != nil {
100- t.Fatal(err)
101- }
102236 }
@@ -3,33 +3,59 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6+ "sync"
6 "testing"7 "testing"
8+ "time"
7 9
10+ "github.com/stretchr/testify/mock"
11+
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
8 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
9 )14 )
10 15
16+// routeInputs are the router's input channels, as wired in production.
17+type routeInputs struct {
18+ scenes chan []ports.Scene
19+ cursor chan ports.CursorChange
20+ captures chan ports.CaptureRequest
21+}
22+
23+func newRouteInputs() routeInputs {
24+ return routeInputs{scenes: make(chan []ports.Scene), cursor: make(chan ports.CursorChange), captures: make(chan ports.CaptureRequest)}
25+}
26+
27+func (in routeInputs) routes() outputRoutes {
28+ return outputRoutes{scenes: in.scenes, cursor: in.cursor, captures: in.captures}
29+}
30+
31+// idleRun is an output that runs until stopped.
32+func idleRun(ctx context.Context, _ outputFeed) error {
33+ <-ctx.Done()
34+ return nil
35+}
36+
11 func TestOutputRouting(t *testing.T) {37 func TestOutputRouting(t *testing.T) {
12 ctx, cancel := context.WithCancel(context.Background())38 ctx, cancel := context.WithCancel(context.Background())
13 defer cancel()39 defer cancel()
14 replies := make(chan ports.CaptureDone, 2)40 replies := make(chan ports.CaptureDone, 2)
15- set := newOutputSet(ctx, replies)41+ in := newRouteInputs()
42+ set := newOutputSet(ctx, replies, in.routes())
16 for _, name := range []string{"A", "B"} {43 for _, name := range []string{"A", "B"} {
17- set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {44+ set.start(ctx, name, idleRun)
18- <-ctx.Done()
19- return nil
20- })
21 <-set.outs[name].cursor // initial cursor45 <-set.outs[name].cursor // initial cursor
22 }46 }
23- set.scenes([]ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}})47+ in.scenes <- []ports.Scene{{Output: "A", Seq: 1}, {Output: "B", Seq: 2}}
24- set.scenes([]ports.Scene{{Output: "A", Seq: 3}})48+ in.scenes <- []ports.Scene{{Output: "A", Seq: 3}}
49+ in.cursor <- ports.CursorChange{} // the router took both scene sets
25 if got := <-set.outs["A"].scenes; got.Seq != 3 {50 if got := <-set.outs["A"].scenes; got.Seq != 3 {
26 t.Fatalf("A scene: %+v", got)51 t.Fatalf("A scene: %+v", got)
27 }52 }
28 if got := <-set.outs["B"].scenes; got.Seq != 2 {53 if got := <-set.outs["B"].scenes; got.Seq != 2 {
29 t.Fatalf("B scene: %+v", got)54 t.Fatalf("B scene: %+v", got)
30 }55 }
31- set.setCursor(ports.CursorChange{Shape: "text"})56+ in.cursor <- ports.CursorChange{Shape: "text"}
32- set.setCursor(ports.CursorChange{Shape: "pointer"})57+ in.cursor <- ports.CursorChange{Shape: "pointer"}
58+ in.scenes <- nil // the router took both cursors
33 for _, name := range []string{"A", "B"} {59 for _, name := range []string{"A", "B"} {
34 if got := <-set.outs[name].cursor; got.Shape != "pointer" {60 if got := <-set.outs[name].cursor; got.Shape != "pointer" {
35 t.Fatalf("%s cursor: %+v", name, got)61 t.Fatalf("%s cursor: %+v", name, got)
@@ -39,64 +65,172 @@ func TestOutputRouting(t *testing.T) {
39 if err != nil {65 if err != nil {
40 t.Fatal(err)66 t.Fatal(err)
41 }67 }
42- set.routeCapture(ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}})68+ in.captures <- ports.CaptureRequest{ID: 7, Output: "B", Dst: ports.SHMBuffer{File: f}}
43 if got := <-set.outs["B"].captures; got.ID != 7 {69 if got := <-set.outs["B"].captures; got.ID != 7 {
44 t.Fatalf("B capture: %+v", got)70 t.Fatalf("B capture: %+v", got)
45 }71 }
46 if err := f.Close(); err != nil {72 if err := f.Close(); err != nil {
47 t.Fatal(err)73 t.Fatal(err)
48 }74 }
49- set.outs["A"].stop()75+ a := set.outs["A"]
76+ a.stop()
50 if err := set.finish("A"); err != nil {77 if err := set.finish("A"); err != nil {
51 t.Fatal(err)78 t.Fatal(err)
52 }79 }
53- set.scenes([]ports.Scene{{Output: "A", Seq: 4}})80+ in.scenes <- []ports.Scene{{Output: "A", Seq: 4}}
54- if _, ok := set.outs["A"]; ok {81+ in.captures <- ports.CaptureRequest{ID: 8, Output: "A"}
55- t.Fatal("removed output retained")
56- }
57- set.routeCapture(ports.CaptureRequest{ID: 8, Output: "A"})
58 if got := <-replies; got.ID != 8 || got.Err == nil {82 if got := <-replies; got.ID != 8 || got.Err == nil {
59 t.Fatalf("removed output capture: %+v", got)83 t.Fatalf("removed output capture: %+v", got)
60 }84 }
85+ select {
86+ case sc := <-a.scenes:
87+ t.Fatalf("removed output fed a scene: %+v", sc)
88+ default:
89+ }
61 if err := set.wait(); err != nil {90 if err := set.wait(); err != nil {
62 t.Fatal(err)91 t.Fatal(err)
63 }92 }
64 }93 }
65 94
66-// The set remembers which outputs scenes turned off, also once they are95+// An output started after a scene turned its name off starts off; one
67-// gone: drm starts an output that reconnects off (Output.StartOff).96+// turned back on starts on.
68-func TestOutputSetRemembersOff(t *testing.T) {97+func TestOutputStartsOffAfterOffScene(t *testing.T) {
69- set := newOutputSet(context.Background(), nil)98+ ctx, cancel := context.WithCancel(context.Background())
70- set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}})99+ defer cancel()
71- if !set.off["A"] || set.off["B"] {100+ in := newRouteInputs()
72- t.Fatal(set.off)101+ set := newOutputSet(ctx, nil, in.routes())
73- }102+ startOff := make(chan bool, 1)
74- set.scenes([]ports.Scene{{Output: "A"}})103+ run := func(ctx context.Context, feed outputFeed) error {
75- if len(set.off) != 0 {104+ startOff <- feed.StartOff
76- t.Fatal(set.off)105+ <-ctx.Done()
106+ return nil
107+ }
108+ in.scenes <- []ports.Scene{{Output: "A", Off: true}, {Output: "B"}}
109+ for _, tc := range []struct {
110+ name string
111+ want bool
112+ }{{"A", true}, {"B", false}} {
113+ set.start(ctx, tc.name, run)
114+ if got := <-startOff; got != tc.want {
115+ t.Fatalf("%s start off %v, want %v", tc.name, got, tc.want)
116+ }
117+ }
118+ in.scenes <- []ports.Scene{{Output: "A"}}
119+ set.outs["A"].stop()
120+ if err := set.finish("A"); err != nil {
121+ t.Fatal(err)
122+ }
123+ set.start(ctx, "A", run)
124+ if <-startOff {
125+ t.Fatal("A starts off after a scene turned it on")
126+ }
127+ if err := set.wait(); err != nil {
128+ t.Fatal(err)
77 }129 }
78 }130 }
79 131
80-func TestOutputRoutingAllocations(t *testing.T) {132+// A late unregister of a previous lifetime never removes its successor.
81- set := newOutputSet(context.Background(), nil)133+func TestRouterUnregisterKeepsNewerLifetime(t *testing.T) {
134+ r := newOutputRouter(context.Background(), nil, outputRoutes{})
135+ newer := &mailbox{instance: 2, scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1)}
136+ r.register("A", newer)
137+ r.unregister("A", 1)
138+ if r.outs["A"] != newer {
139+ t.Fatal("stale unregister removed the newer lifetime")
140+ }
141+ r.unregister("A", 2)
142+ if _, ok := r.outs["A"]; ok {
143+ t.Fatal("unregister kept the lifetime")
144+ }
145+}
146+
147+// Lifecycle work on the backend loop never holds up routing: while a new
148+// output's content attach blocks (it waits for the display goroutine), the
149+// running output still gets its scenes and cursor.
150+func TestBlockedStartDoesNotDelayRouting(t *testing.T) {
82 ctx, cancel := context.WithCancel(context.Background())151 ctx, cancel := context.WithCancel(context.Background())
83 defer cancel()152 defer cancel()
84- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {153+ in := newRouteInputs()
85- <-ctx.Done()154+ set := newOutputSet(ctx, nil, in.routes())
86- return nil155+ set.start(ctx, "A", idleRun)
156+ <-set.outs["A"].cursor
157+ attacher := newMockcontentAttacher(t)
158+ set.attach = attacher
159+ release := make(chan struct{})
160+ attaching := make(chan struct{})
161+ ret := portsmocks.NewMockContentReturn(t)
162+ attacher.EXPECT().AttachOutput("B").RunAndReturn(func(string) (<-chan *ports.ContentBatch, ports.ContentReturn) {
163+ close(attaching)
164+ <-release
165+ return nil, ret
166+ }).Once()
167+ attacher.EXPECT().DetachOutput(mock.Anything).Maybe()
168+ releaseB := sync.OnceFunc(func() { close(release) })
169+ wait := sync.OnceValue(set.wait)
170+ started := make(chan bool, 1)
171+ startDone := make(chan struct{})
172+ go func() {
173+ defer close(startDone)
174+ started <- set.start(ctx, "B", idleRun)
175+ }()
176+ // A failed assertion still unblocks and joins B's start.
177+ t.Cleanup(func() {
178+ releaseB()
179+ cancel()
180+ <-startDone
181+ _ = wait()
87 })182 })
183+ <-attaching
184+ a := set.outs["A"]
185+ deadline := time.After(2 * time.Second)
186+ for seq := uint64(1); seq <= 3; seq++ {
187+ select {
188+ case in.scenes <- []ports.Scene{{Output: "A", Seq: seq}}:
189+ case <-deadline:
190+ t.Fatal("router blocked behind a starting output")
191+ }
192+ select {
193+ case got := <-a.scenes:
194+ if got.Seq != seq {
195+ t.Fatalf("A scene %d, want %d", got.Seq, seq)
196+ }
197+ case <-deadline:
198+ t.Fatal("scene not routed while another output starts")
199+ }
200+ }
201+ select {
202+ case in.cursor <- ports.CursorChange{Shape: "text"}:
203+ case <-deadline:
204+ t.Fatal("cursor blocked behind a starting output")
205+ }
206+ if got := <-a.cursor; got.Shape != "text" {
207+ t.Fatalf("A cursor %+v", got)
208+ }
209+ releaseB()
210+ if !<-started {
211+ t.Fatal("B did not start")
212+ }
213+ if err := wait(); err != nil {
214+ t.Fatal(err)
215+ }
216+}
217+
218+func TestOutputRoutingAllocations(t *testing.T) {
219+ r := newOutputRouter(context.Background(), nil, outputRoutes{})
220+ ctx, cancel := context.WithCancel(context.Background())
221+ defer cancel()
222+ m := newMailbox(1, ctx, cancel, make(chan struct{}))
223+ r.register("A", m)
224+ <-m.cursor
88 scenes := []ports.Scene{{Output: "A"}}225 scenes := []ports.Scene{{Output: "A"}}
89 cursor := ports.CursorChange{Shape: "pointer"}226 cursor := ports.CursorChange{Shape: "pointer"}
90 capture := ports.CaptureRequest{Output: "A"}227 capture := ports.CaptureRequest{Output: "A"}
91 if got := testing.AllocsPerRun(100, func() {228 if got := testing.AllocsPerRun(100, func() {
92- set.scenes(scenes)229+ r.scenes(scenes)
93- set.setCursor(cursor)230+ r.setCursor(cursor)
94- set.routeCapture(capture)231+ r.routeCapture(capture)
95- <-set.outs["A"].captures232+ <-m.captures
96 }); got != 0 {233 }); got != 0 {
97 t.Fatalf("routing: %g allocations per run, want 0", got)234 t.Fatalf("routing: %g allocations per run, want 0", got)
98 }235 }
99- if err := set.wait(); err != nil {
100- t.Fatal(err)
101- }
102 }236 }
M internal/app/outputs.go +62 -86
@@ -13,7 +13,21 @@ import (
1313
1414 // outputRun drives one output until ctx ends: it renders the scenes and
1515 // surface contents it receives, with the cursor the client asks for.
16-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error
16+type outputRun func(ctx context.Context, feed outputFeed) error
17+
18+// outputFeed is what an output lifetime runs on. StartOff tells that the
19+// latest scene for its name turned it off: it starts off until core's first
20+// scene (a display reconnecting from deep sleep stays dark).
21+type outputFeed struct {
22+ Scenes <-chan ports.Scene
23+ Contents <-chan *ports.ContentBatch
24+ Returns ports.ContentReturn
25+ Cursor <-chan ports.CursorChange
26+ Captures <-chan ports.CaptureRequest
27+ Security <-chan ports.SecurityState
28+ Instance ports.OutputInstance
29+ StartOff bool
30+}
1731
1832 // contentAttacher connects an output to the surface contents: wayland feeds
1933 // every attached output batches of window contents, starting with the latest
@@ -29,19 +43,20 @@ type contentAttacher interface {
2943 DetachOutput(feed ports.ContentReturn)
3044 }
3145
32-// outputSet owns the running outputs and feeds them. Core sends one scene
33-// per output; each goes to its output, latest first. Surface contents reach
34-// every output (windows move between outputs) straight from wayland through
35-// attach, which also replays the current windows to a new output.
36-// Only the goroutine running loop touches the set.
46+// outputSet owns the output lifetimes: start and stop, security
47+// registration and content feeds. Only the backend loop touches it. Scenes,
48+// cursor and captures reach the outputs through the router, its own
49+// goroutine, so lifecycle work here never delays another output's frames.
50+// Surface contents reach every output (windows move between outputs)
51+// straight from wayland through attach, which also replays the current
52+// windows to a new output.
3753 type outputSet struct {
38- outs map[string]*runningOutput
54+ outs map[string]*runningOutput
55+ router *outputRouter
56+ // stopRouter ends the router (router.done closes once it returned).
57+ stopRouter context.CancelFunc
3958 // attach is nil when no wayland server feeds contents (tests).
40- attach contentAttacher
41- // off is the latest Scene.Off per output name, kept after the output
42- // goes: it reconnects off until core's first scene (Output.StartOff).
43- off map[string]bool
44- cursor ports.CursorChange
59+ attach contentAttacher
4560 stopped chan outputStopped
4661 captured chan<- ports.CaptureDone
4762 ctx context.Context
@@ -61,24 +76,25 @@ type outputStopped struct {
6176 instance ports.OutputInstance
6277 }
6378
79+// runningOutput is one output lifetime: the mailbox the router feeds, and
80+// what the backend loop owns.
6481 type runningOutput struct {
65- instance ports.OutputInstance
82+ *mailbox
6683 security chan ports.SecurityState
6784 securityEvents chan ports.SecurityBackendEvent
68- scenes chan ports.Scene
69- cursor chan ports.CursorChange
70- captures chan ports.CaptureRequest
71- ctx context.Context
72- stop context.CancelFunc
7385 err error
74- done chan struct{}
7586 // feed is the content feed AttachOutput returned (nil: none), the handle
7687 // to detach once the output stopped.
7788 feed ports.ContentReturn
7889 }
7990
80-func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
81- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
91+// newOutputSet returns a set whose router feeds the outputs from in, until
92+// ctx ends or wait stops it.
93+func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone, in outputRoutes) *outputSet {
94+ rctx, stopRouter := context.WithCancel(ctx)
95+ s := &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, router: newOutputRouter(ctx, captured, in), stopRouter: stopRouter, stopped: make(chan outputStopped), quit: make(chan struct{})}
96+ go s.router.run(rctx)
97+ return s
8298 }
8399
84100 // start runs an output in its own goroutine, fed the window contents.
@@ -98,22 +114,29 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire
98114 s.setSecurity(s.security.Snapshot())
99115 }
100116 octx, stop := context.WithCancel(ctx)
101- r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
117+ r := &runningOutput{mailbox: newMailbox(instance, octx, stop, make(chan struct{})), security: make(chan ports.SecurityState, 1)}
102118 r.security <- s.state // available before any content replay
103119 r.securityEvents = s.forwardSecurity()
104120 for _, destination := range wire {
105121 *destination = r.securityEvents
106122 }
107123 // After the security state above: the replay of the windows starts here.
108- var contents <-chan *ports.ContentBatch
109- var returns ports.ContentReturn
124+ feed := outputFeed{Scenes: r.scenes, Cursor: r.cursor, Captures: r.captures, Security: r.security, Instance: instance}
110125 if s.attach != nil {
111- contents, returns = s.attach.AttachOutput(name)
126+ feed.Contents, feed.Returns = s.attach.AttachOutput(name)
127+ }
128+ r.feed = feed.Returns
129+ // The router feeds it from now on, current cursor first.
130+ off, ok := s.router.add(name, r.mailbox)
131+ if !ok {
132+ stop()
133+ s.detach(r)
134+ return false
112135 }
113- r.feed = returns
136+ feed.StartOff = off
114137 s.outs[name] = r
115138 go func() {
116- r.err = run(octx, r.scenes, contents, returns, r.cursor, r.captures, r.security, r.instance)
139+ r.err = run(octx, feed)
117140 if r.securityEvents != nil {
118141 close(r.securityEvents)
119142 }
@@ -124,7 +147,6 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire
124147 case <-s.quit:
125148 }
126149 }()
127- r.cursor <- s.cursor
128150 return true
129151 }
130152
@@ -200,11 +222,7 @@ func (s *outputSet) setSecurity(state ports.SecurityState) bool {
200222 }
201223 s.state = state
202224 for _, r := range s.outs {
203- select {
204- case <-r.security:
205- default:
206- }
207- r.security <- state
225+ latest(r.security, state)
208226 }
209227 return true
210228 }
@@ -215,36 +233,6 @@ func (s *outputSet) stoppedCurrent(stopped outputStopped) bool {
215233 return r != nil && r.instance == stopped.instance
216234 }
217235
218-// setCursor gives every output the latest cursor, replacing an unread one.
219-func (s *outputSet) setCursor(c ports.CursorChange) {
220- s.cursor = c
221- for _, r := range s.outs {
222- select {
223- case <-r.cursor:
224- default:
225- }
226- r.cursor <- c
227- }
228-}
229-
230-// scenes routes each scene to its output, replacing an unread one.
231-func (s *outputSet) scenes(set []ports.Scene) {
232- for _, sc := range set {
233- if sc.Off {
234- s.off[sc.Output] = true
235- } else {
236- delete(s.off, sc.Output)
237- }
238- if r := s.outs[sc.Output]; r != nil {
239- select {
240- case <-r.scenes:
241- default:
242- }
243- r.scenes <- sc
244- }
245- }
246-}
247-
248236 // finish forgets a stopped output and returns its error.
249237 func (s *outputSet) finish(name string) error {
250238 r := s.outs[name]
@@ -253,7 +241,9 @@ func (s *outputSet) finish(name string) error {
253241 }
254242 <-r.done
255243 r.stop()
256- s.failQueued(r)
244+ // Once the router no longer feeds it, nothing else queues a capture.
245+ s.router.remove(name, r.instance)
246+ failQueued(s.ctx, r.mailbox, s.captured)
257247 s.detach(r)
258248 delete(s.outs, name)
259249 return r.err
@@ -276,10 +266,13 @@ func (s *outputSet) wait() error {
276266 }
277267 close(s.quit)
278268 s.forwarders.Wait()
269+ // No capture is routed past this point.
270+ s.stopRouter()
271+ <-s.router.done
279272 for name, r := range s.outs {
280273 r.stop()
281274 <-r.done
282- s.failQueued(r)
275+ failQueued(s.ctx, r.mailbox, s.captured)
283276 s.detach(r)
284277 if first == nil && r.err != nil {
285278 first = r.err
@@ -427,29 +420,12 @@ func (c *cursors) move(output string, x, y float64, motion bool) {
427420 }
428421
429422 // routeCapture transfers the request to its output or closes it on failure.
430-func (s *outputSet) routeCapture(req ports.CaptureRequest) {
431- r := s.outs[req.Output]
432- if r == nil {
433- capture.Fail(s.ctx, req, fmt.Errorf("output %q unavailable", req.Output), s.captured)
434- return
435- }
436- select {
437- case r.captures <- req:
438- case <-r.done:
439- r.stop()
440- capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
441- case <-r.ctx.Done():
442- capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
443- default:
444- capture.Fail(s.ctx, req, fmt.Errorf("output %q capture queue full", req.Output), s.captured)
445- }
446-}
447-
448-func (s *outputSet) failQueued(r *runningOutput) {
423+// failQueued fails the captures left in a stopped output's mailbox.
424+func failQueued(ctx context.Context, m *mailbox, captured chan<- ports.CaptureDone) {
449425 for {
450426 select {
451- case req := <-r.captures:
452- capture.Fail(s.ctx, req, capture.ErrOutputStopped, s.captured)
427+ case req := <-m.captures:
428+ capture.Fail(ctx, req, capture.ErrOutputStopped, captured)
453429 default:
454430 return
455431 }
@@ -13,7 +13,21 @@ import (
13 13
14 // outputRun drives one output until ctx ends: it renders the scenes and14 // outputRun drives one output until ctx ends: it renders the scenes and
15 // surface contents it receives, with the cursor the client asks for.15 // surface contents it receives, with the cursor the client asks for.
16-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan *ports.ContentBatch, returns ports.ContentReturn, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error16+type outputRun func(ctx context.Context, feed outputFeed) error
17+
18+// outputFeed is what an output lifetime runs on. StartOff tells that the
19+// latest scene for its name turned it off: it starts off until core's first
20+// scene (a display reconnecting from deep sleep stays dark).
21+type outputFeed struct {
22+ Scenes <-chan ports.Scene
23+ Contents <-chan *ports.ContentBatch
24+ Returns ports.ContentReturn
25+ Cursor <-chan ports.CursorChange
26+ Captures <-chan ports.CaptureRequest
27+ Security <-chan ports.SecurityState
28+ Instance ports.OutputInstance
29+ StartOff bool
30+}
17 31
18 // contentAttacher connects an output to the surface contents: wayland feeds32 // contentAttacher connects an output to the surface contents: wayland feeds
19 // every attached output batches of window contents, starting with the latest33 // every attached output batches of window contents, starting with the latest
@@ -29,19 +43,20 @@ type contentAttacher interface {
29 DetachOutput(feed ports.ContentReturn)43 DetachOutput(feed ports.ContentReturn)
30 }44 }
31 45
32-// outputSet owns the running outputs and feeds them. Core sends one scene46+// outputSet owns the output lifetimes: start and stop, security
33-// per output; each goes to its output, latest first. Surface contents reach47+// registration and content feeds. Only the backend loop touches it. Scenes,
34-// every output (windows move between outputs) straight from wayland through48+// cursor and captures reach the outputs through the router, its own
35-// attach, which also replays the current windows to a new output.49+// goroutine, so lifecycle work here never delays another output's frames.
36-// Only the goroutine running loop touches the set.50+// Surface contents reach every output (windows move between outputs)
51+// straight from wayland through attach, which also replays the current
52+// windows to a new output.
37 type outputSet struct {53 type outputSet struct {
38- outs map[string]*runningOutput54+ outs map[string]*runningOutput
55+ router *outputRouter
56+ // stopRouter ends the router (router.done closes once it returned).
57+ stopRouter context.CancelFunc
39 // attach is nil when no wayland server feeds contents (tests).58 // attach is nil when no wayland server feeds contents (tests).
40- attach contentAttacher59+ attach contentAttacher
41- // off is the latest Scene.Off per output name, kept after the output
42- // goes: it reconnects off until core's first scene (Output.StartOff).
43- off map[string]bool
44- cursor ports.CursorChange
45 stopped chan outputStopped60 stopped chan outputStopped
46 captured chan<- ports.CaptureDone61 captured chan<- ports.CaptureDone
47 ctx context.Context62 ctx context.Context
@@ -61,24 +76,25 @@ type outputStopped struct {
61 instance ports.OutputInstance76 instance ports.OutputInstance
62 }77 }
63 78
79+// runningOutput is one output lifetime: the mailbox the router feeds, and
80+// what the backend loop owns.
64 type runningOutput struct {81 type runningOutput struct {
65- instance ports.OutputInstance82+ *mailbox
66 security chan ports.SecurityState83 security chan ports.SecurityState
67 securityEvents chan ports.SecurityBackendEvent84 securityEvents chan ports.SecurityBackendEvent
68- scenes chan ports.Scene
69- cursor chan ports.CursorChange
70- captures chan ports.CaptureRequest
71- ctx context.Context
72- stop context.CancelFunc
73 err error85 err error
74- done chan struct{}
75 // feed is the content feed AttachOutput returned (nil: none), the handle86 // feed is the content feed AttachOutput returned (nil: none), the handle
76 // to detach once the output stopped.87 // to detach once the output stopped.
77 feed ports.ContentReturn88 feed ports.ContentReturn
78 }89 }
79 90
80-func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {91+// newOutputSet returns a set whose router feeds the outputs from in, until
81- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}92+// ctx ends or wait stops it.
93+func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone, in outputRoutes) *outputSet {
94+ rctx, stopRouter := context.WithCancel(ctx)
95+ s := &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, router: newOutputRouter(ctx, captured, in), stopRouter: stopRouter, stopped: make(chan outputStopped), quit: make(chan struct{})}
96+ go s.router.run(rctx)
97+ return s
82 }98 }
83 99
84 // start runs an output in its own goroutine, fed the window contents.100 // start runs an output in its own goroutine, fed the window contents.
@@ -98,22 +114,29 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire
98 s.setSecurity(s.security.Snapshot())114 s.setSecurity(s.security.Snapshot())
99 }115 }
100 octx, stop := context.WithCancel(ctx)116 octx, stop := context.WithCancel(ctx)
101- r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}117+ r := &runningOutput{mailbox: newMailbox(instance, octx, stop, make(chan struct{})), security: make(chan ports.SecurityState, 1)}
102 r.security <- s.state // available before any content replay118 r.security <- s.state // available before any content replay
103 r.securityEvents = s.forwardSecurity()119 r.securityEvents = s.forwardSecurity()
104 for _, destination := range wire {120 for _, destination := range wire {
105 *destination = r.securityEvents121 *destination = r.securityEvents
106 }122 }
107 // After the security state above: the replay of the windows starts here.123 // After the security state above: the replay of the windows starts here.
108- var contents <-chan *ports.ContentBatch124+ feed := outputFeed{Scenes: r.scenes, Cursor: r.cursor, Captures: r.captures, Security: r.security, Instance: instance}
109- var returns ports.ContentReturn
110 if s.attach != nil {125 if s.attach != nil {
111- contents, returns = s.attach.AttachOutput(name)126+ feed.Contents, feed.Returns = s.attach.AttachOutput(name)
127+ }
128+ r.feed = feed.Returns
129+ // The router feeds it from now on, current cursor first.
130+ off, ok := s.router.add(name, r.mailbox)
131+ if !ok {
132+ stop()
133+ s.detach(r)
134+ return false
112 }135 }
113- r.feed = returns136+ feed.StartOff = off
114 s.outs[name] = r137 s.outs[name] = r
115 go func() {138 go func() {
116- r.err = run(octx, r.scenes, contents, returns, r.cursor, r.captures, r.security, r.instance)139+ r.err = run(octx, feed)
117 if r.securityEvents != nil {140 if r.securityEvents != nil {
118 close(r.securityEvents)141 close(r.securityEvents)
119 }142 }
@@ -124,7 +147,6 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire
124 case <-s.quit:147 case <-s.quit:
125 }148 }
126 }()149 }()
127- r.cursor <- s.cursor
128 return true150 return true
129 }151 }
130 152
@@ -200,11 +222,7 @@ func (s *outputSet) setSecurity(state ports.SecurityState) bool {
200 }222 }
201 s.state = state223 s.state = state
202 for _, r := range s.outs {224 for _, r := range s.outs {
203- select {225+ latest(r.security, state)
204- case <-r.security:
205- default:
206- }
207- r.security <- state
208 }226 }
209 return true227 return true
210 }228 }
@@ -215,36 +233,6 @@ func (s *outputSet) stoppedCurrent(stopped outputStopped) bool {
215 return r != nil && r.instance == stopped.instance233 return r != nil && r.instance == stopped.instance
216 }234 }
217 235
218-// setCursor gives every output the latest cursor, replacing an unread one.
219-func (s *outputSet) setCursor(c ports.CursorChange) {
220- s.cursor = c
221- for _, r := range s.outs {
222- select {
223- case <-r.cursor:
224- default:
225- }
226- r.cursor <- c
227- }
228-}
229-
230-// scenes routes each scene to its output, replacing an unread one.
231-func (s *outputSet) scenes(set []ports.Scene) {
232- for _, sc := range set {
233- if sc.Off {
234- s.off[sc.Output] = true
235- } else {
236- delete(s.off, sc.Output)
237- }
238- if r := s.outs[sc.Output]; r != nil {
239- select {
240- case <-r.scenes:
241- default:
242- }
243- r.scenes <- sc
244- }
245- }
246-}
247-
248 // finish forgets a stopped output and returns its error.236 // finish forgets a stopped output and returns its error.
249 func (s *outputSet) finish(name string) error {237 func (s *outputSet) finish(name string) error {
250 r := s.outs[name]238 r := s.outs[name]
@@ -253,7 +241,9 @@ func (s *outputSet) finish(name string) error {
253 }241 }
254 <-r.done242 <-r.done
255 r.stop()243 r.stop()
256- s.failQueued(r)244+ // Once the router no longer feeds it, nothing else queues a capture.
245+ s.router.remove(name, r.instance)
246+ failQueued(s.ctx, r.mailbox, s.captured)
257 s.detach(r)247 s.detach(r)
258 delete(s.outs, name)248 delete(s.outs, name)
259 return r.err249 return r.err
@@ -276,10 +266,13 @@ func (s *outputSet) wait() error {
276 }266 }
277 close(s.quit)267 close(s.quit)
278 s.forwarders.Wait()268 s.forwarders.Wait()
269+ // No capture is routed past this point.
270+ s.stopRouter()
271+ <-s.router.done
279 for name, r := range s.outs {272 for name, r := range s.outs {
280 r.stop()273 r.stop()
281 <-r.done274 <-r.done
282- s.failQueued(r)275+ failQueued(s.ctx, r.mailbox, s.captured)
283 s.detach(r)276 s.detach(r)
284 if first == nil && r.err != nil {277 if first == nil && r.err != nil {
285 first = r.err278 first = r.err
@@ -427,29 +420,12 @@ func (c *cursors) move(output string, x, y float64, motion bool) {
427 }420 }
428 421
429 // routeCapture transfers the request to its output or closes it on failure.422 // routeCapture transfers the request to its output or closes it on failure.
430-func (s *outputSet) routeCapture(req ports.CaptureRequest) {423+// failQueued fails the captures left in a stopped output's mailbox.
431- r := s.outs[req.Output]424+func failQueued(ctx context.Context, m *mailbox, captured chan<- ports.CaptureDone) {
432- if r == nil {
433- capture.Fail(s.ctx, req, fmt.Errorf("output %q unavailable", req.Output), s.captured)
434- return
435- }
436- select {
437- case r.captures <- req:
438- case <-r.done:
439- r.stop()
440- capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
441- case <-r.ctx.Done():
442- capture.Fail(s.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
443- default:
444- capture.Fail(s.ctx, req, fmt.Errorf("output %q capture queue full", req.Output), s.captured)
445- }
446-}
447-
448-func (s *outputSet) failQueued(r *runningOutput) {
449 for {425 for {
450 select {426 select {
451- case req := <-r.captures:427+ case req := <-m.captures:
452- capture.Fail(s.ctx, req, capture.ErrOutputStopped, s.captured)428+ capture.Fail(ctx, req, capture.ErrOutputStopped, captured)
453 default:429 default:
454 return430 return
455 }431 }
M internal/app/outputs_test.go +6 -4
@@ -12,12 +12,14 @@ import (
1212 func TestOutputSetCursor(t *testing.T) {
1313 ctx, cancel := context.WithCancel(context.Background())
1414 defer cancel()
15- set := newOutputSet(context.Background(), nil)
15+ cursors := make(chan ports.CursorChange)
16+ set := newOutputSet(context.Background(), nil, outputRoutes{cursor: cursors})
1617 got := map[string]chan ports.CursorChange{}
1718 run := func(name string) outputRun {
1819 got[name] = make(chan ports.CursorChange, 8)
1920 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
21+ return func(ctx context.Context, feed outputFeed) error {
22+ cursor := feed.Cursor
2123 for {
2224 select {
2325 case <-ctx.Done():
@@ -41,11 +43,11 @@ func TestOutputSetCursor(t *testing.T) {
4143 }
4244 set.start(ctx, "A", run("A"))
4345 expect("A", ports.CursorChange{})
44- set.setCursor(ports.CursorChange{Shape: "text"})
46+ cursors <- ports.CursorChange{Shape: "text"}
4547 expect("A", ports.CursorChange{Shape: "text"})
4648 set.start(ctx, "B", run("B"))
4749 expect("B", ports.CursorChange{Shape: "text"})
48- set.setCursor(ports.CursorChange{Hidden: true})
50+ cursors <- ports.CursorChange{Hidden: true}
4951 expect("A", ports.CursorChange{Hidden: true})
5052 expect("B", ports.CursorChange{Hidden: true})
5153 if err := set.wait(); err != nil {
@@ -12,12 +12,14 @@ import (
12 func TestOutputSetCursor(t *testing.T) {12 func TestOutputSetCursor(t *testing.T) {
13 ctx, cancel := context.WithCancel(context.Background())13 ctx, cancel := context.WithCancel(context.Background())
14 defer cancel()14 defer cancel()
15- set := newOutputSet(context.Background(), nil)15+ cursors := make(chan ports.CursorChange)
16+ set := newOutputSet(context.Background(), nil, outputRoutes{cursor: cursors})
16 got := map[string]chan ports.CursorChange{}17 got := map[string]chan ports.CursorChange{}
17 run := func(name string) outputRun {18 run := func(name string) outputRun {
18 got[name] = make(chan ports.CursorChange, 8)19 got[name] = make(chan ports.CursorChange, 8)
19 out := got[name]20 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {21+ return func(ctx context.Context, feed outputFeed) error {
22+ cursor := feed.Cursor
21 for {23 for {
22 select {24 select {
23 case <-ctx.Done():25 case <-ctx.Done():
@@ -41,11 +43,11 @@ func TestOutputSetCursor(t *testing.T) {
41 }43 }
42 set.start(ctx, "A", run("A"))44 set.start(ctx, "A", run("A"))
43 expect("A", ports.CursorChange{})45 expect("A", ports.CursorChange{})
44- set.setCursor(ports.CursorChange{Shape: "text"})46+ cursors <- ports.CursorChange{Shape: "text"}
45 expect("A", ports.CursorChange{Shape: "text"})47 expect("A", ports.CursorChange{Shape: "text"})
46 set.start(ctx, "B", run("B"))48 set.start(ctx, "B", run("B"))
47 expect("B", ports.CursorChange{Shape: "text"})49 expect("B", ports.CursorChange{Shape: "text"})
48- set.setCursor(ports.CursorChange{Hidden: true})50+ cursors <- ports.CursorChange{Hidden: true}
49 expect("A", ports.CursorChange{Hidden: true})51 expect("A", ports.CursorChange{Hidden: true})
50 expect("B", ports.CursorChange{Hidden: true})52 expect("B", ports.CursorChange{Hidden: true})
51 if err := set.wait(); err != nil {53 if err := set.wait(); err != nil {
A internal/app/router.go +244 -0
@@ -0,0 +1,244 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// outputRouter feeds the running outputs their scenes, cursor and capture
17+// requests. It runs in its own goroutine and never blocks: every mailbox is
18+// latest-wins (scenes, cursor) or fails fast when full (captures). Output
19+// lifecycle (scan, modeset, start and stop, security, leases) runs on the
20+// backend loop, so a hotplug or a configuration on one output never delays
21+// another output's frames or cursor.
22+//
23+// The backend loop registers an output before it runs and unregisters it
24+// once it stopped, over ops; registration is lossless and ordered, and an
25+// output's instance keys it so a late unregister never removes its
26+// successor.
27+type outputRouter struct {
28+ outs map[string]*mailbox
29+ // off is the latest Scene.Off per output name, kept after the output
30+ // goes: it reconnects off until core's first scene (drm StartOff).
31+ off map[string]bool
32+ cursor ports.CursorChange
33+ captured chan<- ports.CaptureDone
34+ ctx context.Context
35+
36+ in outputRoutes
37+ ops chan routerOp
38+ done chan struct{} // closed when run returns
39+
40+ log zerowrap.Logger
41+ // stats counts since the last report; only run's goroutine touches it.
42+ stats routerStats
43+ // eventWait is the longest time (ns) the backend loop blocked sending an
44+ // OutputEvent to core since the last report. The backend loop writes it,
45+ // so it is atomic.
46+ eventWait atomic.Int64
47+}
48+
49+// routerStatsEvery is the period of the "routing stats" log entry.
50+const routerStatsEvery = 10 * time.Second
51+
52+// routerStats tells whether outputs keep up: a scene or cursor replaced
53+// unread means an output did not consume the previous one before the next.
54+type routerStats struct {
55+ scenes, scenesReplaced uint64
56+ cursor, cursorReplaced uint64
57+ capturesFull uint64 // captures failed because the queue was full
58+}
59+
60+// outputRoutes are the router's inputs: core's scenes, wayland's cursor and
61+// capture requests.
62+type outputRoutes struct {
63+ scenes <-chan []ports.Scene
64+ cursor <-chan ports.CursorChange
65+ captures <-chan ports.CaptureRequest
66+}
67+
68+// mailbox is what the router feeds one output lifetime.
69+type mailbox struct {
70+ instance ports.OutputInstance
71+ scenes chan ports.Scene
72+ cursor chan ports.CursorChange
73+ captures chan ports.CaptureRequest
74+ // ctx ends and done closes with the output; stop cancels it.
75+ ctx context.Context
76+ stop context.CancelFunc
77+ done chan struct{}
78+}
79+
80+func newMailbox(instance ports.OutputInstance, ctx context.Context, stop context.CancelFunc, done chan struct{}) *mailbox {
81+ return &mailbox{instance: instance, scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: ctx, stop: stop, done: done}
82+}
83+
84+// routerOp registers (add set) or unregisters an output. reply carries the
85+// registration's start-off state; it is closed after an unregister.
86+type routerOp struct {
87+ name string
88+ add *mailbox
89+ gone ports.OutputInstance
90+ reply chan bool
91+}
92+
93+func newOutputRouter(ctx context.Context, captured chan<- ports.CaptureDone, in outputRoutes) *outputRouter {
94+ return &outputRouter{outs: map[string]*mailbox{}, off: map[string]bool{}, captured: captured, ctx: ctx, in: in, ops: make(chan routerOp), done: make(chan struct{}), log: logging.For(ctx, "router")}
95+}
96+
97+// run routes until ctx ends.
98+func (r *outputRouter) run(ctx context.Context) {
99+ defer close(r.done)
100+ report := time.NewTicker(routerStatsEvery)
101+ defer report.Stop()
102+ for {
103+ select {
104+ case <-ctx.Done():
105+ return
106+ case <-report.C:
107+ r.report()
108+ case op := <-r.ops:
109+ r.apply(op)
110+ case set := <-r.in.scenes:
111+ r.scenes(set)
112+ case c := <-r.in.cursor:
113+ r.setCursor(c)
114+ case q := <-r.in.captures:
115+ r.routeCapture(q)
116+ }
117+ }
118+}
119+
120+// report logs and resets the counters; it stays quiet when nothing happened.
121+func (r *outputRouter) report() {
122+ st, wait := r.stats, time.Duration(r.eventWait.Swap(0))
123+ r.stats = routerStats{}
124+ if st == (routerStats{}) && wait == 0 {
125+ return
126+ }
127+ r.log.Info().
128+ Uint64("scenes", st.scenes).
129+ Uint64("scenes_replaced", st.scenesReplaced).
130+ Uint64("cursor", st.cursor).
131+ Uint64("cursor_replaced", st.cursorReplaced).
132+ Uint64("captures_full", st.capturesFull).
133+ Float64("max_event_send_ms", float64(wait)/float64(time.Millisecond)).
134+ Msg("routing stats")
135+}
136+
137+// noteEventSend records how long the backend loop waited for core to take an
138+// event. Backend loop only; lock-free so it never adds to the wait.
139+func (r *outputRouter) noteEventSend(d time.Duration) {
140+ n := int64(d)
141+ for {
142+ cur := r.eventWait.Load()
143+ if n <= cur || r.eventWait.CompareAndSwap(cur, n) {
144+ return
145+ }
146+ }
147+}
148+
149+func (r *outputRouter) apply(op routerOp) {
150+ if op.add != nil {
151+ op.reply <- r.register(op.name, op.add)
152+ return
153+ }
154+ r.unregister(op.name, op.gone)
155+ close(op.reply)
156+}
157+
158+// register feeds m from now on, starting with the current cursor, and
159+// reports whether the output's latest scene turned it off.
160+func (r *outputRouter) register(name string, m *mailbox) bool {
161+ r.outs[name] = m
162+ m.cursor <- r.cursor
163+ return r.off[name]
164+}
165+
166+// unregister stops feeding the output lifetime instance of name.
167+func (r *outputRouter) unregister(name string, instance ports.OutputInstance) {
168+ if m := r.outs[name]; m != nil && m.instance == instance {
169+ delete(r.outs, name)
170+ }
171+}
172+
173+// setCursor gives every output the latest cursor, replacing an unread one.
174+func (r *outputRouter) setCursor(c ports.CursorChange) {
175+ r.cursor = c
176+ r.stats.cursor++
177+ for _, m := range r.outs {
178+ if latest(m.cursor, c) {
179+ r.stats.cursorReplaced++
180+ }
181+ }
182+}
183+
184+// scenes routes each scene to its output, replacing an unread one.
185+func (r *outputRouter) scenes(set []ports.Scene) {
186+ for _, sc := range set {
187+ if sc.Off {
188+ r.off[sc.Output] = true
189+ } else {
190+ delete(r.off, sc.Output)
191+ }
192+ if m := r.outs[sc.Output]; m != nil {
193+ r.stats.scenes++
194+ if latest(m.scenes, sc) {
195+ r.stats.scenesReplaced++
196+ }
197+ }
198+ }
199+}
200+
201+// routeCapture hands the request to its output or fails it at once: a full
202+// or stopped output never holds up the router.
203+func (r *outputRouter) routeCapture(req ports.CaptureRequest) {
204+ m := r.outs[req.Output]
205+ if m == nil {
206+ capture.Fail(r.ctx, req, fmt.Errorf("output %q unavailable", req.Output), r.captured)
207+ return
208+ }
209+ select {
210+ case m.captures <- req:
211+ case <-m.done:
212+ m.stop()
213+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), r.captured)
214+ case <-m.ctx.Done():
215+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), r.captured)
216+ default:
217+ r.stats.capturesFull++
218+ capture.Fail(r.ctx, req, fmt.Errorf("output %q capture queue full", req.Output), r.captured)
219+ }
220+}
221+
222+// add registers m for name and returns its start-off state. Backend loop
223+// only; false once the router has stopped.
224+func (r *outputRouter) add(name string, m *mailbox) (off, ok bool) {
225+ reply := make(chan bool, 1)
226+ select {
227+ case r.ops <- routerOp{name: name, add: m, reply: reply}:
228+ return <-reply, true
229+ case <-r.done:
230+ return false, false
231+ }
232+}
233+
234+// remove unregisters the lifetime instance of name and returns once the
235+// router no longer feeds it, so its queued captures can be failed. Backend
236+// loop only.
237+func (r *outputRouter) remove(name string, instance ports.OutputInstance) {
238+ reply := make(chan bool)
239+ select {
240+ case r.ops <- routerOp{name: name, gone: instance, reply: reply}:
241+ <-reply
242+ case <-r.done:
243+ }
244+}
@@ -0,0 +1,244 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// outputRouter feeds the running outputs their scenes, cursor and capture
17+// requests. It runs in its own goroutine and never blocks: every mailbox is
18+// latest-wins (scenes, cursor) or fails fast when full (captures). Output
19+// lifecycle (scan, modeset, start and stop, security, leases) runs on the
20+// backend loop, so a hotplug or a configuration on one output never delays
21+// another output's frames or cursor.
22+//
23+// The backend loop registers an output before it runs and unregisters it
24+// once it stopped, over ops; registration is lossless and ordered, and an
25+// output's instance keys it so a late unregister never removes its
26+// successor.
27+type outputRouter struct {
28+ outs map[string]*mailbox
29+ // off is the latest Scene.Off per output name, kept after the output
30+ // goes: it reconnects off until core's first scene (drm StartOff).
31+ off map[string]bool
32+ cursor ports.CursorChange
33+ captured chan<- ports.CaptureDone
34+ ctx context.Context
35+
36+ in outputRoutes
37+ ops chan routerOp
38+ done chan struct{} // closed when run returns
39+
40+ log zerowrap.Logger
41+ // stats counts since the last report; only run's goroutine touches it.
42+ stats routerStats
43+ // eventWait is the longest time (ns) the backend loop blocked sending an
44+ // OutputEvent to core since the last report. The backend loop writes it,
45+ // so it is atomic.
46+ eventWait atomic.Int64
47+}
48+
49+// routerStatsEvery is the period of the "routing stats" log entry.
50+const routerStatsEvery = 10 * time.Second
51+
52+// routerStats tells whether outputs keep up: a scene or cursor replaced
53+// unread means an output did not consume the previous one before the next.
54+type routerStats struct {
55+ scenes, scenesReplaced uint64
56+ cursor, cursorReplaced uint64
57+ capturesFull uint64 // captures failed because the queue was full
58+}
59+
60+// outputRoutes are the router's inputs: core's scenes, wayland's cursor and
61+// capture requests.
62+type outputRoutes struct {
63+ scenes <-chan []ports.Scene
64+ cursor <-chan ports.CursorChange
65+ captures <-chan ports.CaptureRequest
66+}
67+
68+// mailbox is what the router feeds one output lifetime.
69+type mailbox struct {
70+ instance ports.OutputInstance
71+ scenes chan ports.Scene
72+ cursor chan ports.CursorChange
73+ captures chan ports.CaptureRequest
74+ // ctx ends and done closes with the output; stop cancels it.
75+ ctx context.Context
76+ stop context.CancelFunc
77+ done chan struct{}
78+}
79+
80+func newMailbox(instance ports.OutputInstance, ctx context.Context, stop context.CancelFunc, done chan struct{}) *mailbox {
81+ return &mailbox{instance: instance, scenes: make(chan ports.Scene, 1), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: ctx, stop: stop, done: done}
82+}
83+
84+// routerOp registers (add set) or unregisters an output. reply carries the
85+// registration's start-off state; it is closed after an unregister.
86+type routerOp struct {
87+ name string
88+ add *mailbox
89+ gone ports.OutputInstance
90+ reply chan bool
91+}
92+
93+func newOutputRouter(ctx context.Context, captured chan<- ports.CaptureDone, in outputRoutes) *outputRouter {
94+ return &outputRouter{outs: map[string]*mailbox{}, off: map[string]bool{}, captured: captured, ctx: ctx, in: in, ops: make(chan routerOp), done: make(chan struct{}), log: logging.For(ctx, "router")}
95+}
96+
97+// run routes until ctx ends.
98+func (r *outputRouter) run(ctx context.Context) {
99+ defer close(r.done)
100+ report := time.NewTicker(routerStatsEvery)
101+ defer report.Stop()
102+ for {
103+ select {
104+ case <-ctx.Done():
105+ return
106+ case <-report.C:
107+ r.report()
108+ case op := <-r.ops:
109+ r.apply(op)
110+ case set := <-r.in.scenes:
111+ r.scenes(set)
112+ case c := <-r.in.cursor:
113+ r.setCursor(c)
114+ case q := <-r.in.captures:
115+ r.routeCapture(q)
116+ }
117+ }
118+}
119+
120+// report logs and resets the counters; it stays quiet when nothing happened.
121+func (r *outputRouter) report() {
122+ st, wait := r.stats, time.Duration(r.eventWait.Swap(0))
123+ r.stats = routerStats{}
124+ if st == (routerStats{}) && wait == 0 {
125+ return
126+ }
127+ r.log.Info().
128+ Uint64("scenes", st.scenes).
129+ Uint64("scenes_replaced", st.scenesReplaced).
130+ Uint64("cursor", st.cursor).
131+ Uint64("cursor_replaced", st.cursorReplaced).
132+ Uint64("captures_full", st.capturesFull).
133+ Float64("max_event_send_ms", float64(wait)/float64(time.Millisecond)).
134+ Msg("routing stats")
135+}
136+
137+// noteEventSend records how long the backend loop waited for core to take an
138+// event. Backend loop only; lock-free so it never adds to the wait.
139+func (r *outputRouter) noteEventSend(d time.Duration) {
140+ n := int64(d)
141+ for {
142+ cur := r.eventWait.Load()
143+ if n <= cur || r.eventWait.CompareAndSwap(cur, n) {
144+ return
145+ }
146+ }
147+}
148+
149+func (r *outputRouter) apply(op routerOp) {
150+ if op.add != nil {
151+ op.reply <- r.register(op.name, op.add)
152+ return
153+ }
154+ r.unregister(op.name, op.gone)
155+ close(op.reply)
156+}
157+
158+// register feeds m from now on, starting with the current cursor, and
159+// reports whether the output's latest scene turned it off.
160+func (r *outputRouter) register(name string, m *mailbox) bool {
161+ r.outs[name] = m
162+ m.cursor <- r.cursor
163+ return r.off[name]
164+}
165+
166+// unregister stops feeding the output lifetime instance of name.
167+func (r *outputRouter) unregister(name string, instance ports.OutputInstance) {
168+ if m := r.outs[name]; m != nil && m.instance == instance {
169+ delete(r.outs, name)
170+ }
171+}
172+
173+// setCursor gives every output the latest cursor, replacing an unread one.
174+func (r *outputRouter) setCursor(c ports.CursorChange) {
175+ r.cursor = c
176+ r.stats.cursor++
177+ for _, m := range r.outs {
178+ if latest(m.cursor, c) {
179+ r.stats.cursorReplaced++
180+ }
181+ }
182+}
183+
184+// scenes routes each scene to its output, replacing an unread one.
185+func (r *outputRouter) scenes(set []ports.Scene) {
186+ for _, sc := range set {
187+ if sc.Off {
188+ r.off[sc.Output] = true
189+ } else {
190+ delete(r.off, sc.Output)
191+ }
192+ if m := r.outs[sc.Output]; m != nil {
193+ r.stats.scenes++
194+ if latest(m.scenes, sc) {
195+ r.stats.scenesReplaced++
196+ }
197+ }
198+ }
199+}
200+
201+// routeCapture hands the request to its output or fails it at once: a full
202+// or stopped output never holds up the router.
203+func (r *outputRouter) routeCapture(req ports.CaptureRequest) {
204+ m := r.outs[req.Output]
205+ if m == nil {
206+ capture.Fail(r.ctx, req, fmt.Errorf("output %q unavailable", req.Output), r.captured)
207+ return
208+ }
209+ select {
210+ case m.captures <- req:
211+ case <-m.done:
212+ m.stop()
213+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), r.captured)
214+ case <-m.ctx.Done():
215+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), r.captured)
216+ default:
217+ r.stats.capturesFull++
218+ capture.Fail(r.ctx, req, fmt.Errorf("output %q capture queue full", req.Output), r.captured)
219+ }
220+}
221+
222+// add registers m for name and returns its start-off state. Backend loop
223+// only; false once the router has stopped.
224+func (r *outputRouter) add(name string, m *mailbox) (off, ok bool) {
225+ reply := make(chan bool, 1)
226+ select {
227+ case r.ops <- routerOp{name: name, add: m, reply: reply}:
228+ return <-reply, true
229+ case <-r.done:
230+ return false, false
231+ }
232+}
233+
234+// remove unregisters the lifetime instance of name and returns once the
235+// router no longer feeds it, so its queued captures can be failed. Backend
236+// loop only.
237+func (r *outputRouter) remove(name string, instance ports.OutputInstance) {
238+ reply := make(chan bool)
239+ select {
240+ case r.ops <- routerOp{name: name, gone: instance, reply: reply}:
241+ <-reply
242+ case <-r.done:
243+ }
244+}
A internal/app/router_stats_test.go +141 -0
@@ -0,0 +1,141 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// statsRegistered returns a router with output A registered and its initial
17+// cursor drained.
18+func statsRegistered(ctx context.Context, in outputRoutes) (*outputRouter, *mailbox) {
19+ r := newOutputRouter(ctx, nil, in)
20+ mctx, stop := context.WithCancel(ctx)
21+ m := newMailbox(1, mctx, stop, make(chan struct{}))
22+ r.register("A", m)
23+ <-m.cursor
24+ return r, m
25+}
26+
27+// A scene or cursor sent before the output read the previous one counts as
28+// replaced: the output is behind.
29+func TestRouterStatsCountReplaced(t *testing.T) {
30+ r, m := statsRegistered(t.Context(), outputRoutes{})
31+ r.scenes([]ports.Scene{{Output: "A", Seq: 1}})
32+ if r.stats.scenes != 1 || r.stats.scenesReplaced != 0 {
33+ t.Fatalf("one scene: %+v", r.stats)
34+ }
35+ r.scenes([]ports.Scene{{Output: "A", Seq: 2}, {Output: "missing"}})
36+ if r.stats.scenes != 2 || r.stats.scenesReplaced != 1 {
37+ t.Fatalf("two scenes unread: %+v", r.stats)
38+ }
39+ <-m.scenes
40+ r.scenes([]ports.Scene{{Output: "A", Seq: 3}})
41+ if r.stats.scenesReplaced != 1 {
42+ t.Fatalf("a drained mailbox is not behind: %+v", r.stats)
43+ }
44+
45+ r.setCursor(ports.CursorChange{Shape: "text"})
46+ r.setCursor(ports.CursorChange{Shape: "pointer"})
47+ if r.stats.cursor != 2 || r.stats.cursorReplaced != 1 {
48+ t.Fatalf("cursor: %+v", r.stats)
49+ }
50+ if got := <-m.cursor; got.Shape != "pointer" {
51+ t.Fatalf("cursor %+v", got)
52+ }
53+}
54+
55+// A full capture queue fails the request fast and counts it.
56+func TestRouterStatsCapturesFull(t *testing.T) {
57+ replies := make(chan ports.CaptureDone, 2)
58+ r, m := statsRegistered(t.Context(), outputRoutes{})
59+ r.captured = replies
60+ for id := range uint64(cap(m.captures)) {
61+ r.routeCapture(ports.CaptureRequest{ID: id, Output: "A"})
62+ }
63+ if r.stats.capturesFull != 0 {
64+ t.Fatalf("queue not full yet: %+v", r.stats)
65+ }
66+ r.routeCapture(ports.CaptureRequest{ID: 99, Output: "A"})
67+ r.routeCapture(ports.CaptureRequest{ID: 100, Output: "missing"})
68+ if r.stats.capturesFull != 1 {
69+ t.Fatalf("captures full: %+v", r.stats)
70+ }
71+ if got := <-replies; got.ID != 99 || got.Err == nil {
72+ t.Fatalf("rejected capture: %+v", got)
73+ }
74+}
75+
76+// The backend loop reports its longest wait on core; shorter ones keep it.
77+func TestRouterNoteEventSendKeepsMax(t *testing.T) {
78+ r := newOutputRouter(t.Context(), nil, outputRoutes{})
79+ for _, d := range []time.Duration{3 * time.Millisecond, 12 * time.Millisecond, time.Millisecond} {
80+ r.noteEventSend(d)
81+ }
82+ if got := time.Duration(r.eventWait.Load()); got != 12*time.Millisecond {
83+ t.Fatalf("max wait %v, want 12ms", got)
84+ }
85+}
86+
87+// Every 10 s the router logs and resets its counters, and stays quiet when
88+// nothing happened.
89+func TestRouterLogsStatsEvery10s(t *testing.T) {
90+ synctest.Test(t, func(t *testing.T) {
91+ var out lockedBuffer
92+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out})
93+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
94+ in := newRouteInputs()
95+ r := newOutputRouter(ctx, nil, in.routes())
96+ go r.run(ctx)
97+ m := newMailbox(1, ctx, func() {}, make(chan struct{}))
98+ if _, ok := r.add("A", m); !ok {
99+ t.Fatal("router stopped")
100+ }
101+ <-m.cursor
102+
103+ time.Sleep(routerStatsEvery)
104+ synctest.Wait()
105+ if got := out.String(); got != "" {
106+ t.Fatalf("idle router logged: %s", got)
107+ }
108+
109+ in.scenes <- []ports.Scene{{Output: "A", Seq: 1}}
110+ in.scenes <- []ports.Scene{{Output: "A", Seq: 2}}
111+ in.cursor <- ports.CursorChange{Shape: "text"}
112+ in.cursor <- ports.CursorChange{Shape: "pointer"}
113+ in.scenes <- nil // the router took both cursors
114+ r.noteEventSend(1500 * time.Microsecond)
115+ time.Sleep(routerStatsEvery)
116+ synctest.Wait()
117+
118+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
119+ if len(lines) != 1 {
120+ t.Fatalf("log lines: %q", lines)
121+ }
122+ var entry map[string]any
123+ if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil {
124+ t.Fatal(err)
125+ }
126+ want := map[string]any{"message": "routing stats", "component": "router", "scenes": 2.0, "scenes_replaced": 1.0, "cursor": 2.0, "cursor_replaced": 1.0, "captures_full": 0.0, "max_event_send_ms": 1.5}
127+ for k, v := range want {
128+ if entry[k] != v {
129+ t.Errorf("%s = %v, want %v (%s)", k, entry[k], v, lines[0])
130+ }
131+ }
132+
133+ time.Sleep(routerStatsEvery)
134+ synctest.Wait()
135+ if n := strings.Count(out.String(), "routing stats"); n != 1 {
136+ t.Fatalf("counters were not reset: %d entries", n)
137+ }
138+ cancel()
139+ <-r.done
140+ })
141+}
@@ -0,0 +1,141 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+// statsRegistered returns a router with output A registered and its initial
17+// cursor drained.
18+func statsRegistered(ctx context.Context, in outputRoutes) (*outputRouter, *mailbox) {
19+ r := newOutputRouter(ctx, nil, in)
20+ mctx, stop := context.WithCancel(ctx)
21+ m := newMailbox(1, mctx, stop, make(chan struct{}))
22+ r.register("A", m)
23+ <-m.cursor
24+ return r, m
25+}
26+
27+// A scene or cursor sent before the output read the previous one counts as
28+// replaced: the output is behind.
29+func TestRouterStatsCountReplaced(t *testing.T) {
30+ r, m := statsRegistered(t.Context(), outputRoutes{})
31+ r.scenes([]ports.Scene{{Output: "A", Seq: 1}})
32+ if r.stats.scenes != 1 || r.stats.scenesReplaced != 0 {
33+ t.Fatalf("one scene: %+v", r.stats)
34+ }
35+ r.scenes([]ports.Scene{{Output: "A", Seq: 2}, {Output: "missing"}})
36+ if r.stats.scenes != 2 || r.stats.scenesReplaced != 1 {
37+ t.Fatalf("two scenes unread: %+v", r.stats)
38+ }
39+ <-m.scenes
40+ r.scenes([]ports.Scene{{Output: "A", Seq: 3}})
41+ if r.stats.scenesReplaced != 1 {
42+ t.Fatalf("a drained mailbox is not behind: %+v", r.stats)
43+ }
44+
45+ r.setCursor(ports.CursorChange{Shape: "text"})
46+ r.setCursor(ports.CursorChange{Shape: "pointer"})
47+ if r.stats.cursor != 2 || r.stats.cursorReplaced != 1 {
48+ t.Fatalf("cursor: %+v", r.stats)
49+ }
50+ if got := <-m.cursor; got.Shape != "pointer" {
51+ t.Fatalf("cursor %+v", got)
52+ }
53+}
54+
55+// A full capture queue fails the request fast and counts it.
56+func TestRouterStatsCapturesFull(t *testing.T) {
57+ replies := make(chan ports.CaptureDone, 2)
58+ r, m := statsRegistered(t.Context(), outputRoutes{})
59+ r.captured = replies
60+ for id := range uint64(cap(m.captures)) {
61+ r.routeCapture(ports.CaptureRequest{ID: id, Output: "A"})
62+ }
63+ if r.stats.capturesFull != 0 {
64+ t.Fatalf("queue not full yet: %+v", r.stats)
65+ }
66+ r.routeCapture(ports.CaptureRequest{ID: 99, Output: "A"})
67+ r.routeCapture(ports.CaptureRequest{ID: 100, Output: "missing"})
68+ if r.stats.capturesFull != 1 {
69+ t.Fatalf("captures full: %+v", r.stats)
70+ }
71+ if got := <-replies; got.ID != 99 || got.Err == nil {
72+ t.Fatalf("rejected capture: %+v", got)
73+ }
74+}
75+
76+// The backend loop reports its longest wait on core; shorter ones keep it.
77+func TestRouterNoteEventSendKeepsMax(t *testing.T) {
78+ r := newOutputRouter(t.Context(), nil, outputRoutes{})
79+ for _, d := range []time.Duration{3 * time.Millisecond, 12 * time.Millisecond, time.Millisecond} {
80+ r.noteEventSend(d)
81+ }
82+ if got := time.Duration(r.eventWait.Load()); got != 12*time.Millisecond {
83+ t.Fatalf("max wait %v, want 12ms", got)
84+ }
85+}
86+
87+// Every 10 s the router logs and resets its counters, and stays quiet when
88+// nothing happened.
89+func TestRouterLogsStatsEvery10s(t *testing.T) {
90+ synctest.Test(t, func(t *testing.T) {
91+ var out lockedBuffer
92+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out})
93+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
94+ in := newRouteInputs()
95+ r := newOutputRouter(ctx, nil, in.routes())
96+ go r.run(ctx)
97+ m := newMailbox(1, ctx, func() {}, make(chan struct{}))
98+ if _, ok := r.add("A", m); !ok {
99+ t.Fatal("router stopped")
100+ }
101+ <-m.cursor
102+
103+ time.Sleep(routerStatsEvery)
104+ synctest.Wait()
105+ if got := out.String(); got != "" {
106+ t.Fatalf("idle router logged: %s", got)
107+ }
108+
109+ in.scenes <- []ports.Scene{{Output: "A", Seq: 1}}
110+ in.scenes <- []ports.Scene{{Output: "A", Seq: 2}}
111+ in.cursor <- ports.CursorChange{Shape: "text"}
112+ in.cursor <- ports.CursorChange{Shape: "pointer"}
113+ in.scenes <- nil // the router took both cursors
114+ r.noteEventSend(1500 * time.Microsecond)
115+ time.Sleep(routerStatsEvery)
116+ synctest.Wait()
117+
118+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
119+ if len(lines) != 1 {
120+ t.Fatalf("log lines: %q", lines)
121+ }
122+ var entry map[string]any
123+ if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil {
124+ t.Fatal(err)
125+ }
126+ want := map[string]any{"message": "routing stats", "component": "router", "scenes": 2.0, "scenes_replaced": 1.0, "cursor": 2.0, "cursor_replaced": 1.0, "captures_full": 0.0, "max_event_send_ms": 1.5}
127+ for k, v := range want {
128+ if entry[k] != v {
129+ t.Errorf("%s = %v, want %v (%s)", k, entry[k], v, lines[0])
130+ }
131+ }
132+
133+ time.Sleep(routerStatsEvery)
134+ synctest.Wait()
135+ if n := strings.Count(out.String(), "routing stats"); n != 1 {
136+ t.Fatalf("counters were not reset: %d entries", n)
137+ }
138+ cancel()
139+ <-r.done
140+ })
141+}
M internal/app/security_duplicate_test.go +3 -3
@@ -25,9 +25,9 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T
2525 t.Fatal(err)
2626 }
2727 events := make(chan ports.SecurityBackendEvent, 4)
28- set := newOutputSet(context.Background(), nil)
28+ set := newOutputSet(context.Background(), nil, outputRoutes{})
2929 set.wireSecurity(outputChannels{security: gate, securityEvents: events})
30- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
30+ run := func(ctx context.Context, feed outputFeed) error {
3131 <-ctx.Done()
3232 return nil
3333 }
@@ -93,7 +93,7 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T
9393
9494 func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) {
9595 events := make(chan ports.SecurityBackendEvent, 2)
96- set := newOutputSet(context.Background(), nil)
96+ set := newOutputSet(context.Background(), nil, outputRoutes{})
9797 set.wireSecurity(outputChannels{securityEvents: events})
9898 set.nextInstance = ^ports.OutputInstance(0) - 1
9999 instance, err := registerDiscardedOutput(set, "DP-1")
@@ -25,9 +25,9 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T
25 t.Fatal(err)25 t.Fatal(err)
26 }26 }
27 events := make(chan ports.SecurityBackendEvent, 4)27 events := make(chan ports.SecurityBackendEvent, 4)
28- set := newOutputSet(context.Background(), nil)28+ set := newOutputSet(context.Background(), nil, outputRoutes{})
29 set.wireSecurity(outputChannels{security: gate, securityEvents: events})29 set.wireSecurity(outputChannels{security: gate, securityEvents: events})
30- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {30+ run := func(ctx context.Context, feed outputFeed) error {
31 <-ctx.Done()31 <-ctx.Done()
32 return nil32 return nil
33 }33 }
@@ -93,7 +93,7 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T
93 93
94 func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) {94 func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) {
95 events := make(chan ports.SecurityBackendEvent, 2)95 events := make(chan ports.SecurityBackendEvent, 2)
96- set := newOutputSet(context.Background(), nil)96+ set := newOutputSet(context.Background(), nil, outputRoutes{})
97 set.wireSecurity(outputChannels{securityEvents: events})97 set.wireSecurity(outputChannels{securityEvents: events})
98 set.nextInstance = ^ports.OutputInstance(0) - 198 set.nextInstance = ^ports.OutputInstance(0) - 1
99 instance, err := registerDiscardedOutput(set, "DP-1")99 instance, err := registerDiscardedOutput(set, "DP-1")
M internal/app/security_hotplug_test.go +12 -8
@@ -45,7 +45,7 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
4545 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
4646 defer cancel()
4747 events := make(chan ports.SecurityBackendEvent, 8)
48- set := newOutputSet(ctx, nil)
48+ set := newOutputSet(ctx, nil, outputRoutes{})
4949 set.wireSecurity(outputChannels{securityEvents: events})
5050 defer func() {
5151 if err := set.wait(); err != nil {
@@ -66,7 +66,8 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
6666 commands := make(chan []ports.SecurityBackendEvent, 1)
6767 closed := make(chan bool, 1)
6868 var ownerEvents chan<- ports.SecurityBackendEvent
69- if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
69+ if !set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
70+ security, instance := feed.Security, feed.Instance
7071 defer func() { closed <- closeSuccess }()
7172 state := <-security
7273 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
@@ -193,7 +194,7 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
193194 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
194195 defer cancel()
195196 events := make(chan ports.SecurityBackendEvent) // block display after registration
196- set := newOutputSet(ctx, nil)
197+ set := newOutputSet(ctx, nil, outputRoutes{})
197198 set.wireSecurity(outputChannels{securityEvents: events})
198199 defer func() {
199200 if err := set.wait(); err != nil {
@@ -205,7 +206,8 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
205206 startDone := make(chan bool, 1)
206207 var ownerEvents chan<- ports.SecurityBackendEvent
207208 go func() {
208- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
209+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
210+ instance := feed.Instance
209211 for i := range 40 {
210212 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
211213 if i%2 == 0 {
@@ -285,7 +287,7 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
285287 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
286288 defer cancel()
287289 events := make(chan ports.SecurityBackendEvent)
288- set := newOutputSet(ctx, nil)
290+ set := newOutputSet(ctx, nil, outputRoutes{})
289291 set.wireSecurity(outputChannels{securityEvents: events})
290292 defer func() {
291293 if err := set.wait(); err != nil {
@@ -295,7 +297,8 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
295297 startDone := make(chan bool, 1)
296298 var ownerEvents chan<- ports.SecurityBackendEvent
297299 go func() {
298- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
300+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
301+ instance := feed.Instance
299302 for i := range 16 {
300303 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
301304 select {
@@ -343,14 +346,15 @@ func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
343346 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
344347 defer cancel()
345348 events := make(chan ports.SecurityBackendEvent)
346- set := newOutputSet(ctx, nil)
349+ set := newOutputSet(ctx, nil, outputRoutes{})
347350 set.wireSecurity(outputChannels{securityEvents: events})
348351 accepted := make(chan int, 40)
349352 ownerDone := make(chan struct{})
350353 startDone := make(chan bool, 1)
351354 var ownerEvents chan<- ports.SecurityBackendEvent
352355 go func() {
353- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
356+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
357+ instance := feed.Instance
354358 defer close(ownerDone)
355359 for i := range 40 {
356360 select {
@@ -45,7 +45,7 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
45 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)45 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
46 defer cancel()46 defer cancel()
47 events := make(chan ports.SecurityBackendEvent, 8)47 events := make(chan ports.SecurityBackendEvent, 8)
48- set := newOutputSet(ctx, nil)48+ set := newOutputSet(ctx, nil, outputRoutes{})
49 set.wireSecurity(outputChannels{securityEvents: events})49 set.wireSecurity(outputChannels{securityEvents: events})
50 defer func() {50 defer func() {
51 if err := set.wait(); err != nil {51 if err := set.wait(); err != nil {
@@ -66,7 +66,8 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
66 commands := make(chan []ports.SecurityBackendEvent, 1)66 commands := make(chan []ports.SecurityBackendEvent, 1)
67 closed := make(chan bool, 1)67 closed := make(chan bool, 1)
68 var ownerEvents chan<- ports.SecurityBackendEvent68 var ownerEvents chan<- ports.SecurityBackendEvent
69- if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {69+ if !set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
70+ security, instance := feed.Security, feed.Instance
70 defer func() { closed <- closeSuccess }()71 defer func() { closed <- closeSuccess }()
71 state := <-security72 state := <-security
72 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}73 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
@@ -193,7 +194,7 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
193 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)194 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
194 defer cancel()195 defer cancel()
195 events := make(chan ports.SecurityBackendEvent) // block display after registration196 events := make(chan ports.SecurityBackendEvent) // block display after registration
196- set := newOutputSet(ctx, nil)197+ set := newOutputSet(ctx, nil, outputRoutes{})
197 set.wireSecurity(outputChannels{securityEvents: events})198 set.wireSecurity(outputChannels{securityEvents: events})
198 defer func() {199 defer func() {
199 if err := set.wait(); err != nil {200 if err := set.wait(); err != nil {
@@ -205,7 +206,8 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
205 startDone := make(chan bool, 1)206 startDone := make(chan bool, 1)
206 var ownerEvents chan<- ports.SecurityBackendEvent207 var ownerEvents chan<- ports.SecurityBackendEvent
207 go func() {208 go func() {
208- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {209+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
210+ instance := feed.Instance
209 for i := range 40 {211 for i := range 40 {
210 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}212 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
211 if i%2 == 0 {213 if i%2 == 0 {
@@ -285,7 +287,7 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
285 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)287 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
286 defer cancel()288 defer cancel()
287 events := make(chan ports.SecurityBackendEvent)289 events := make(chan ports.SecurityBackendEvent)
288- set := newOutputSet(ctx, nil)290+ set := newOutputSet(ctx, nil, outputRoutes{})
289 set.wireSecurity(outputChannels{securityEvents: events})291 set.wireSecurity(outputChannels{securityEvents: events})
290 defer func() {292 defer func() {
291 if err := set.wait(); err != nil {293 if err := set.wait(); err != nil {
@@ -295,7 +297,8 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
295 startDone := make(chan bool, 1)297 startDone := make(chan bool, 1)
296 var ownerEvents chan<- ports.SecurityBackendEvent298 var ownerEvents chan<- ports.SecurityBackendEvent
297 go func() {299 go func() {
298- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {300+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
301+ instance := feed.Instance
299 for i := range 16 {302 for i := range 16 {
300 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}303 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
301 select {304 select {
@@ -343,14 +346,15 @@ func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
343 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)346 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
344 defer cancel()347 defer cancel()
345 events := make(chan ports.SecurityBackendEvent)348 events := make(chan ports.SecurityBackendEvent)
346- set := newOutputSet(ctx, nil)349+ set := newOutputSet(ctx, nil, outputRoutes{})
347 set.wireSecurity(outputChannels{securityEvents: events})350 set.wireSecurity(outputChannels{securityEvents: events})
348 accepted := make(chan int, 40)351 accepted := make(chan int, 40)
349 ownerDone := make(chan struct{})352 ownerDone := make(chan struct{})
350 startDone := make(chan bool, 1)353 startDone := make(chan bool, 1)
351 var ownerEvents chan<- ports.SecurityBackendEvent354 var ownerEvents chan<- ports.SecurityBackendEvent
352 go func() {355 go func() {
353- startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {356+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
357+ instance := feed.Instance
354 defer close(ownerDone)358 defer close(ownerDone)
355 for i := range 40 {359 for i := range 40 {
356 select {360 select {
M internal/app/security_test.go +9 -8
@@ -15,13 +15,14 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) {
1515 ctx, cancel := context.WithCancel(context.Background())
1616 defer cancel()
1717 events := make(chan ports.SecurityBackendEvent)
18- set := newOutputSet(ctx, nil)
18+ set := newOutputSet(ctx, nil, outputRoutes{})
1919 set.wireSecurity(outputChannels{securityEvents: events})
2020 set.setSecurity(ports.SecurityState{Generation: 7, Protected: true})
2121 started := make(chan ports.OutputInstance, 1)
2222 result := make(chan bool, 1)
2323 go func() {
24- result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
24+ result <- set.start(ctx, "DP-1", func(ctx context.Context, feed outputFeed) error {
25+ security, instance := feed.Security, feed.Instance
2526 state := <-security
2627 if state != (ports.SecurityState{Generation: 7, Protected: true}) {
2728 return errors.New("missing startup protection")
@@ -65,9 +66,9 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) {
6566 func TestSecurityReplacementAndStaleState(t *testing.T) {
6667 ctx := context.Background()
6768 events := make(chan ports.SecurityBackendEvent, 4)
68- set := newOutputSet(ctx, nil)
69+ set := newOutputSet(ctx, nil, outputRoutes{})
6970 set.wireSecurity(outputChannels{securityEvents: events})
70- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
71+ run := func(ctx context.Context, feed outputFeed) error {
7172 <-ctx.Done()
7273 return nil
7374 }
@@ -163,11 +164,11 @@ func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) {
163164 }
164165
165166 func TestSecurityInstanceExhaustion(t *testing.T) {
166- set := newOutputSet(context.Background(), nil)
167+ set := newOutputSet(context.Background(), nil, outputRoutes{})
167168 events := make(chan ports.SecurityBackendEvent, 2)
168169 set.wireSecurity(outputChannels{securityEvents: events})
169170 set.nextInstance = ^ports.OutputInstance(0) - 1
170- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
171+ run := func(ctx context.Context, feed outputFeed) error {
171172 <-ctx.Done()
172173 return nil
173174 }
@@ -200,11 +201,11 @@ func TestSecurityInstanceExhaustion(t *testing.T) {
200201 func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) {
201202 ctx, cancel := context.WithCancel(context.Background())
202203 defer cancel()
203- set := newOutputSet(ctx, nil)
204+ set := newOutputSet(ctx, nil, outputRoutes{})
204205 events := make(chan ports.SecurityBackendEvent, 1)
205206 set.wireSecurity(outputChannels{securityEvents: events})
206207 var ownerEvents chan<- ports.SecurityBackendEvent
207- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
208+ set.start(ctx, "DP-1", func(ctx context.Context, feed outputFeed) error {
208209 <-ctx.Done()
209210 return nil
210211 }, &ownerEvents)
@@ -15,13 +15,14 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) {
15 ctx, cancel := context.WithCancel(context.Background())15 ctx, cancel := context.WithCancel(context.Background())
16 defer cancel()16 defer cancel()
17 events := make(chan ports.SecurityBackendEvent)17 events := make(chan ports.SecurityBackendEvent)
18- set := newOutputSet(ctx, nil)18+ set := newOutputSet(ctx, nil, outputRoutes{})
19 set.wireSecurity(outputChannels{securityEvents: events})19 set.wireSecurity(outputChannels{securityEvents: events})
20 set.setSecurity(ports.SecurityState{Generation: 7, Protected: true})20 set.setSecurity(ports.SecurityState{Generation: 7, Protected: true})
21 started := make(chan ports.OutputInstance, 1)21 started := make(chan ports.OutputInstance, 1)
22 result := make(chan bool, 1)22 result := make(chan bool, 1)
23 go func() {23 go func() {
24- result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {24+ result <- set.start(ctx, "DP-1", func(ctx context.Context, feed outputFeed) error {
25+ security, instance := feed.Security, feed.Instance
25 state := <-security26 state := <-security
26 if state != (ports.SecurityState{Generation: 7, Protected: true}) {27 if state != (ports.SecurityState{Generation: 7, Protected: true}) {
27 return errors.New("missing startup protection")28 return errors.New("missing startup protection")
@@ -65,9 +66,9 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) {
65 func TestSecurityReplacementAndStaleState(t *testing.T) {66 func TestSecurityReplacementAndStaleState(t *testing.T) {
66 ctx := context.Background()67 ctx := context.Background()
67 events := make(chan ports.SecurityBackendEvent, 4)68 events := make(chan ports.SecurityBackendEvent, 4)
68- set := newOutputSet(ctx, nil)69+ set := newOutputSet(ctx, nil, outputRoutes{})
69 set.wireSecurity(outputChannels{securityEvents: events})70 set.wireSecurity(outputChannels{securityEvents: events})
70- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {71+ run := func(ctx context.Context, feed outputFeed) error {
71 <-ctx.Done()72 <-ctx.Done()
72 return nil73 return nil
73 }74 }
@@ -163,11 +164,11 @@ func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) {
163 }164 }
164 165
165 func TestSecurityInstanceExhaustion(t *testing.T) {166 func TestSecurityInstanceExhaustion(t *testing.T) {
166- set := newOutputSet(context.Background(), nil)167+ set := newOutputSet(context.Background(), nil, outputRoutes{})
167 events := make(chan ports.SecurityBackendEvent, 2)168 events := make(chan ports.SecurityBackendEvent, 2)
168 set.wireSecurity(outputChannels{securityEvents: events})169 set.wireSecurity(outputChannels{securityEvents: events})
169 set.nextInstance = ^ports.OutputInstance(0) - 1170 set.nextInstance = ^ports.OutputInstance(0) - 1
170- run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {171+ run := func(ctx context.Context, feed outputFeed) error {
171 <-ctx.Done()172 <-ctx.Done()
172 return nil173 return nil
173 }174 }
@@ -200,11 +201,11 @@ func TestSecurityInstanceExhaustion(t *testing.T) {
200 func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) {201 func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) {
201 ctx, cancel := context.WithCancel(context.Background())202 ctx, cancel := context.WithCancel(context.Background())
202 defer cancel()203 defer cancel()
203- set := newOutputSet(ctx, nil)204+ set := newOutputSet(ctx, nil, outputRoutes{})
204 events := make(chan ports.SecurityBackendEvent, 1)205 events := make(chan ports.SecurityBackendEvent, 1)
205 set.wireSecurity(outputChannels{securityEvents: events})206 set.wireSecurity(outputChannels{securityEvents: events})
206 var ownerEvents chan<- ports.SecurityBackendEvent207 var ownerEvents chan<- ports.SecurityBackendEvent
207- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan *ports.ContentBatch, _ ports.ContentReturn, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {208+ set.start(ctx, "DP-1", func(ctx context.Context, feed outputFeed) error {
208 <-ctx.Done()209 <-ctx.Done()
209 return nil210 return nil
210 }, &ownerEvents)211 }, &ownerEvents)
M internal/app/session.go +2 -0
@@ -342,6 +342,8 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
342342 }()
343343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344344 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345+ workers.Add(1)
346+ go func() { defer workers.Done(); logCommandStats(ctx, c, logging.For(ctx, "core")) }()
345347 if xdisplay != nil {
346348 workers.Add(1)
347349 // X11 failing leaves the Wayland session running; Run logs why.
@@ -342,6 +342,8 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
342 }()342 }()
343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344 go func() { defer workers.Done(); done <- c.Run(ctx) }()344 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345+ workers.Add(1)
346+ go func() { defer workers.Done(); logCommandStats(ctx, c, logging.For(ctx, "core")) }()
345 if xdisplay != nil {347 if xdisplay != nil {
346 workers.Add(1)348 workers.Add(1)
347 // X11 failing leaves the Wayland session running; Run logs why.349 // X11 failing leaves the Wayland session running; Run logs why.
M internal/core/core.go +78 -12
@@ -142,7 +142,9 @@ type Core struct {
142142 // (clientgesture.go).
143143 gesture clientGesture
144144 // ping carries watchdog probes (Ping); Run answers each one.
145- ping chan chan struct{}
145+ ping chan chan struct{}
146+ // out queues the commands for c.ch.Commands; Run's sender drains it.
147+ out outbox
146148 frameC <-chan time.Time
147149 frameTimer ports.Timer
148150 // shots is the snapshot of what the screens show before an action
@@ -350,7 +352,12 @@ func (c *Core) apply(cfg ports.Config) error {
350352 // their windows can map.
351353 func (c *Core) publishPending(ctx context.Context) error {
352354 if p := c.placement.anyPending(); p != c.sent.pending {
353- if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
355+ // Pending must be in wayland's queue before the spawn that follows.
356+ send := c.command
357+ if p {
358+ send = c.commandFlushed
359+ }
360+ if err := send(ctx, ports.SlotsPending{Pending: p}); err != nil {
354361 return err
355362 }
356363 c.sent.pending = p
@@ -405,7 +412,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
405412 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
406413 }
407414 // A placeholder screen holds windows until the first output arrives.
408- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{})}
415+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}
409416 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
410417 if err := c.apply(cfg); err != nil {
411418 return nil, err
@@ -442,22 +449,60 @@ func (c *Core) now() time.Time {
442449 return time.Now()
443450 }
444451
452+// command queues v for the wayland goroutine and never blocks: a busy
453+// display must not stall keyboard, scenes or frame wakes. The sender
454+// goroutine of Run delivers the queue in order.
445455 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
456+ _, err := c.queue(ctx, v)
457+ return err
458+}
459+
460+// commandFlushed is command for a command that another channel depends on:
461+// it returns once v is in the command channel. SlotsPending must reach
462+// wayland before the spawn whose window it lets wayland claim; the spawn
463+// goes on its own channel, so queue order alone does not keep them in order.
464+func (c *Core) commandFlushed(ctx context.Context, v ports.ClientCommand) error {
465+ seq, err := c.queue(ctx, v)
466+ if err != nil || seq == 0 {
467+ return err
468+ }
469+ if err := c.out.flush(ctx, c.ch.Commands, seq); err != nil {
470+ return err
471+ }
472+ // As a blocked send did: the rest of the event must not go on under a
473+ // new epoch.
446474 if c.inputEpochChanged() {
447475 return errSecurityChanged
448476 }
449- if c.opts.Security != nil {
450- v = ports.SecurityCommand{State: c.security, Command: v}
477+ return nil
478+}
479+
480+// queue pushes v to the outbox and returns its sequence number (0: not
481+// queued).
482+func (c *Core) queue(ctx context.Context, v ports.ClientCommand) (uint64, error) {
483+ if err := ctx.Err(); err != nil {
484+ return 0, err
451485 }
452- select {
453- case <-ctx.Done():
454- return ctx.Err()
455- case c.ch.Commands <- v:
456- if c.inputEpochChanged() {
457- return errSecurityChanged
486+ if c.inputEpochChanged() {
487+ return 0, errSecurityChanged
488+ }
489+ if c.ch.Commands == nil {
490+ // Nobody receives: queueing would only grow without bound.
491+ return 0, nil
492+ }
493+ seq := c.out.push(c.ch.Commands, v, c.security, c.opts.Security != nil)
494+ if c.out.backlog() > outboxLimit {
495+ if err := c.out.flush(ctx, c.ch.Commands, seq-outboxLimit); err != nil {
496+ return seq, err
458497 }
459- return nil
460498 }
499+ // The queued command keeps the epoch it was made under, so a display
500+ // that has moved on drops it. Report the change all the same: the rest
501+ // of the event was decided under the old epoch and must not go on.
502+ if c.inputEpochChanged() {
503+ return seq, errSecurityChanged
504+ }
505+ return seq, nil
461506 }
462507
463508 // clientRect is the placement minus the border on its inset sides.
@@ -1058,7 +1103,28 @@ func (c *Core) Ping(ctx context.Context) {
10581103 }
10591104 }
10601105
1106+// CommandStats returns the command outbox counters since the last call.
1107+// Any goroutine may call it.
1108+func (c *Core) CommandStats() CommandStats { return c.out.takeStats() }
1109+
10611110 func (c *Core) Run(ctx context.Context) error {
1111+ // The sender stops with Run, not only with ctx: Run also returns on
1112+ // quit and on errors. Run waits for it so nothing sends after Run.
1113+ sendCtx, stopSender := context.WithCancel(ctx)
1114+ senderDone := make(chan struct{})
1115+ if c.ch.Commands != nil {
1116+ c.out.start()
1117+ }
1118+ go func() {
1119+ defer close(senderDone)
1120+ if c.ch.Commands != nil {
1121+ c.out.run(sendCtx, c.ch.Commands)
1122+ }
1123+ }()
1124+ defer func() {
1125+ stopSender()
1126+ <-senderDone
1127+ }()
10621128 c.syncSecurity()
10631129 defer c.stopFrame()
10641130 defer c.stopCaptureTimer()
@@ -142,7 +142,9 @@ type Core struct {
142 // (clientgesture.go).142 // (clientgesture.go).
143 gesture clientGesture143 gesture clientGesture
144 // ping carries watchdog probes (Ping); Run answers each one.144 // ping carries watchdog probes (Ping); Run answers each one.
145- ping chan chan struct{}145+ ping chan chan struct{}
146+ // out queues the commands for c.ch.Commands; Run's sender drains it.
147+ out outbox
146 frameC <-chan time.Time148 frameC <-chan time.Time
147 frameTimer ports.Timer149 frameTimer ports.Timer
148 // shots is the snapshot of what the screens show before an action150 // shots is the snapshot of what the screens show before an action
@@ -350,7 +352,12 @@ func (c *Core) apply(cfg ports.Config) error {
350 // their windows can map.352 // their windows can map.
351 func (c *Core) publishPending(ctx context.Context) error {353 func (c *Core) publishPending(ctx context.Context) error {
352 if p := c.placement.anyPending(); p != c.sent.pending {354 if p := c.placement.anyPending(); p != c.sent.pending {
353- if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {355+ // Pending must be in wayland's queue before the spawn that follows.
356+ send := c.command
357+ if p {
358+ send = c.commandFlushed
359+ }
360+ if err := send(ctx, ports.SlotsPending{Pending: p}); err != nil {
354 return err361 return err
355 }362 }
356 c.sent.pending = p363 c.sent.pending = p
@@ -405,7 +412,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
405 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")412 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
406 }413 }
407 // A placeholder screen holds windows until the first output arrives.414 // A placeholder screen holds windows until the first output arrives.
408- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{})}415+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}
409 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}416 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
410 if err := c.apply(cfg); err != nil {417 if err := c.apply(cfg); err != nil {
411 return nil, err418 return nil, err
@@ -442,22 +449,60 @@ func (c *Core) now() time.Time {
442 return time.Now()449 return time.Now()
443 }450 }
444 451
452+// command queues v for the wayland goroutine and never blocks: a busy
453+// display must not stall keyboard, scenes or frame wakes. The sender
454+// goroutine of Run delivers the queue in order.
445 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {455 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
456+ _, err := c.queue(ctx, v)
457+ return err
458+}
459+
460+// commandFlushed is command for a command that another channel depends on:
461+// it returns once v is in the command channel. SlotsPending must reach
462+// wayland before the spawn whose window it lets wayland claim; the spawn
463+// goes on its own channel, so queue order alone does not keep them in order.
464+func (c *Core) commandFlushed(ctx context.Context, v ports.ClientCommand) error {
465+ seq, err := c.queue(ctx, v)
466+ if err != nil || seq == 0 {
467+ return err
468+ }
469+ if err := c.out.flush(ctx, c.ch.Commands, seq); err != nil {
470+ return err
471+ }
472+ // As a blocked send did: the rest of the event must not go on under a
473+ // new epoch.
446 if c.inputEpochChanged() {474 if c.inputEpochChanged() {
447 return errSecurityChanged475 return errSecurityChanged
448 }476 }
449- if c.opts.Security != nil {477+ return nil
450- v = ports.SecurityCommand{State: c.security, Command: v}478+}
479+
480+// queue pushes v to the outbox and returns its sequence number (0: not
481+// queued).
482+func (c *Core) queue(ctx context.Context, v ports.ClientCommand) (uint64, error) {
483+ if err := ctx.Err(); err != nil {
484+ return 0, err
451 }485 }
452- select {486+ if c.inputEpochChanged() {
453- case <-ctx.Done():487+ return 0, errSecurityChanged
454- return ctx.Err()488+ }
455- case c.ch.Commands <- v:489+ if c.ch.Commands == nil {
456- if c.inputEpochChanged() {490+ // Nobody receives: queueing would only grow without bound.
457- return errSecurityChanged491+ return 0, nil
492+ }
493+ seq := c.out.push(c.ch.Commands, v, c.security, c.opts.Security != nil)
494+ if c.out.backlog() > outboxLimit {
495+ if err := c.out.flush(ctx, c.ch.Commands, seq-outboxLimit); err != nil {
496+ return seq, err
458 }497 }
459- return nil
460 }498 }
499+ // The queued command keeps the epoch it was made under, so a display
500+ // that has moved on drops it. Report the change all the same: the rest
501+ // of the event was decided under the old epoch and must not go on.
502+ if c.inputEpochChanged() {
503+ return seq, errSecurityChanged
504+ }
505+ return seq, nil
461 }506 }
462 507
463 // clientRect is the placement minus the border on its inset sides.508 // clientRect is the placement minus the border on its inset sides.
@@ -1058,7 +1103,28 @@ func (c *Core) Ping(ctx context.Context) {
1058 }1103 }
1059 }1104 }
1060 1105
1106+// CommandStats returns the command outbox counters since the last call.
1107+// Any goroutine may call it.
1108+func (c *Core) CommandStats() CommandStats { return c.out.takeStats() }
1109+
1061 func (c *Core) Run(ctx context.Context) error {1110 func (c *Core) Run(ctx context.Context) error {
1111+ // The sender stops with Run, not only with ctx: Run also returns on
1112+ // quit and on errors. Run waits for it so nothing sends after Run.
1113+ sendCtx, stopSender := context.WithCancel(ctx)
1114+ senderDone := make(chan struct{})
1115+ if c.ch.Commands != nil {
1116+ c.out.start()
1117+ }
1118+ go func() {
1119+ defer close(senderDone)
1120+ if c.ch.Commands != nil {
1121+ c.out.run(sendCtx, c.ch.Commands)
1122+ }
1123+ }()
1124+ defer func() {
1125+ stopSender()
1126+ <-senderDone
1127+ }()
1062 c.syncSecurity()1128 c.syncSecurity()
1063 defer c.stopFrame()1129 defer c.stopFrame()
1064 defer c.stopCaptureTimer()1130 defer c.stopCaptureTimer()
A internal/core/outbox.go +302 -0
@@ -0,0 +1,302 @@
1+package core
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// outbox carries commands from the owner goroutine to the sender goroutine
12+// without ever blocking the owner. It is a transport queue, not window
13+// state: the mutex guards only the slices. Commands leave in the order they
14+// were queued; the only merge is of adjacent pointer motions (see push).
15+//
16+// The wayland display goroutine can be busy for a while. Sending from the
17+// owner would stall keyboard, scenes and frame wakes behind it, so the owner
18+// queues and the sender goroutine does the blocking send.
19+type outbox struct {
20+ // wake is signalled (never blocking) after each push.
21+ wake chan struct{}
22+
23+ mu sync.Mutex
24+ queue, spare []outCmd
25+ // busy is set while the sender holds a batch it has not finished
26+ // sending: a command pushed then must queue behind it.
27+ busy bool
28+ // pushed and sent count the entries queued and delivered (a merge adds
29+ // none); progress is signalled (never blocking) at each delivery, for
30+ // flush. The owner is its only waiter, so one pending signal is enough.
31+ pushed, sent uint64
32+ progress chan struct{}
33+ // running is set while a sender (run) owns delivery.
34+ running bool
35+ // sendMu is held by whoever takes and delivers a batch, so batches
36+ // never interleave (the sender, or flush without one).
37+ sendMu sync.Mutex
38+ // stats are guarded by mu, reset by takeStats. Their times come from
39+ // the system clock, not core's: they measure real waits, and the sender
40+ // must not step a test clock.
41+ stats CommandStats
42+}
43+
44+// CommandStats tell whether the display holds up core's commands: how many
45+// queued instead of going straight out, how long they waited, and how long
46+// core itself waited (flush). They are reset when read.
47+type CommandStats struct {
48+ // Direct and Queued count the commands sent at once and queued; Merged
49+ // counts motions folded into a queued one.
50+ Direct, Queued, Merged int
51+ // MaxBacklog is the longest queue seen.
52+ MaxBacklog uint64
53+ // MaxQueueWait is the longest time a queued command took to reach the
54+ // channel; MaxFlushWait the longest time core waited in flush (a spawn's
55+ // SlotsPending, or a full outbox).
56+ MaxQueueWait, MaxFlushWait time.Duration
57+}
58+
59+// takeStats returns the counters and starts over.
60+func (o *outbox) takeStats() CommandStats {
61+ o.mu.Lock()
62+ defer o.mu.Unlock()
63+ s := o.stats
64+ o.stats = CommandStats{}
65+ return s
66+}
67+
68+// outCmd is a queued command. A pointer motion is kept unboxed so that a
69+// merge boxes the sum once, when it leaves, not at every merge.
70+type outCmd struct {
71+ cmd ports.ClientCommand
72+ // state is the owner envelope; secured is false without a security gate.
73+ state ports.SecurityState
74+ secured bool
75+ // motion is cmd when it is a pointer motion. merged means cmd is stale
76+ // and motion carries the sum.
77+ motion ports.PointerMotionTo
78+ isMotion bool
79+ merged bool
80+ // at is when it queued; a merge keeps the first motion's.
81+ at time.Time
82+}
83+
84+// outboxLimit bounds the commands waiting for the display. Motion merges, so
85+// only a display stalled for a long while reaches it; then core waits for
86+// room as a full channel made it wait before, rather than grow without
87+// bound or drop order-sensitive input.
88+const outboxLimit = 4096
89+
90+// backlog is the number of queued commands not yet delivered.
91+func (o *outbox) backlog() uint64 {
92+ o.mu.Lock()
93+ defer o.mu.Unlock()
94+ return o.pushed - o.sent
95+}
96+
97+func newOutbox() outbox {
98+ return outbox{wake: make(chan struct{}, 1), progress: make(chan struct{}, 1)}
99+}
100+
101+// command returns what the sender delivers.
102+func (o *outCmd) command() ports.ClientCommand {
103+ v := o.cmd
104+ if o.merged {
105+ v = o.motion
106+ }
107+ if o.secured {
108+ return ports.SecurityCommand{State: o.state, Command: v}
109+ }
110+ return v
111+}
112+
113+// push delivers v, wrapped in an envelope for state when secured. With
114+// nothing queued or in flight and room in out it sends at once, which keeps
115+// the display's latency when it keeps up; otherwise it queues v. A pointer
116+// motion for the same window and envelope as the one queued last replaces
117+// it: the position is the latest and the relative deltas add up, so locked
118+// pointers (games) lose no movement. Nothing else is merged or reordered:
119+// buttons, focus, keys and gestures depend on their order.
120+//
121+// It returns the entry's sequence number, for flush.
122+func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, state ports.SecurityState, secured bool) uint64 {
123+ e := outCmd{cmd: v, state: state, secured: secured}
124+ e.motion, e.isMotion = v.(ports.PointerMotionTo)
125+ o.mu.Lock()
126+ // A full channel would make the attempt fail after building the
127+ // envelope; queue at once instead. The length is only a hint: queued
128+ // delivery keeps the order either way.
129+ if !o.busy && len(o.queue) == 0 && (cap(out) == 0 || len(out) < cap(out)) {
130+ // A send that cannot block is safe under the lock.
131+ select {
132+ case out <- e.command():
133+ o.pushed++
134+ o.sent = o.pushed
135+ o.stats.Direct++
136+ seq := o.pushed
137+ o.mu.Unlock()
138+ return seq
139+ default:
140+ }
141+ }
142+ // Only a queued command pays for the clock read.
143+ e.at = time.Now()
144+ if n := len(o.queue); n > 0 && e.isMotion {
145+ last := &o.queue[n-1]
146+ if last.isMotion && last.motion.ID == e.motion.ID && last.secured == secured && last.state == state {
147+ e.motion.DX += last.motion.DX
148+ e.motion.DY += last.motion.DY
149+ e.motion.UnaccelDX += last.motion.UnaccelDX
150+ e.motion.UnaccelDY += last.motion.UnaccelDY
151+ e.merged = true
152+ e.at = last.at
153+ *last = e
154+ o.stats.Merged++
155+ seq := o.pushed
156+ o.mu.Unlock()
157+ return seq
158+ }
159+ }
160+ o.queue = append(o.queue, e)
161+ o.pushed++
162+ o.stats.Queued++
163+ o.stats.MaxBacklog = max(o.stats.MaxBacklog, o.pushed-o.sent)
164+ seq := o.pushed
165+ o.mu.Unlock()
166+ select {
167+ case o.wake <- struct{}{}:
168+ default:
169+ }
170+ return seq
171+}
172+
173+// flush waits until the entry seq is in out, or ctx ends. It is for the
174+// rare command another channel depends on (SlotsPending before a spawn);
175+// everything else never waits. Without a running sender (core driven
176+// without Run), it delivers the queue itself, in order.
177+func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error {
178+ var start time.Time
179+ defer func() {
180+ if !start.IsZero() {
181+ w := time.Since(start)
182+ o.mu.Lock()
183+ o.stats.MaxFlushWait = max(o.stats.MaxFlushWait, w)
184+ o.mu.Unlock()
185+ }
186+ }()
187+ for {
188+ o.mu.Lock()
189+ done, running := o.sent >= seq, o.running
190+ o.mu.Unlock()
191+ if done {
192+ return nil
193+ }
194+ if start.IsZero() {
195+ start = time.Now()
196+ }
197+ if !running {
198+ o.sendMu.Lock()
199+ ok := o.deliver(ctx, out, o.take())
200+ o.sendMu.Unlock()
201+ if !ok {
202+ return ctx.Err()
203+ }
204+ continue
205+ }
206+ // A signal left from an earlier delivery only costs a recheck.
207+ select {
208+ case <-o.progress:
209+ case <-ctx.Done():
210+ return ctx.Err()
211+ }
212+ }
213+}
214+
215+// take returns the queued commands and leaves an empty queue. The caller
216+// hands the batch back with recycle once it has sent it.
217+func (o *outbox) take() []outCmd {
218+ o.mu.Lock()
219+ batch := o.queue
220+ o.queue, o.spare = o.spare[:0], nil
221+ o.busy = len(batch) > 0
222+ o.mu.Unlock()
223+ return batch
224+}
225+
226+// recycle keeps the batch's storage for the next take.
227+func (o *outbox) recycle(batch []outCmd) {
228+ // Do not retain what the commands reference in the spare batch.
229+ n := uint64(len(batch))
230+ clear(batch)
231+ o.mu.Lock()
232+ o.spare = batch[:0]
233+ o.busy = false
234+ if n > 0 {
235+ o.sent += n
236+ }
237+ o.mu.Unlock()
238+ if n > 0 {
239+ select {
240+ case o.progress <- struct{}{}:
241+ default:
242+ }
243+ }
244+}
245+
246+// start marks a sender as running; call it before starting run, so that a
247+// flush from the owner never delivers alongside it.
248+func (o *outbox) start() {
249+ o.mu.Lock()
250+ o.running = true
251+ o.mu.Unlock()
252+}
253+
254+// run delivers the queue to out in order until ctx ends. A command whose
255+// send is waiting is not merged any more, but what queues behind it is.
256+func (o *outbox) run(ctx context.Context, out chan<- ports.ClientCommand) {
257+ defer func() {
258+ o.mu.Lock()
259+ o.running = false
260+ o.mu.Unlock()
261+ }()
262+ for {
263+ o.sendMu.Lock()
264+ batch := o.take()
265+ if len(batch) == 0 {
266+ o.recycle(batch)
267+ o.sendMu.Unlock()
268+ select {
269+ case <-o.wake:
270+ case <-ctx.Done():
271+ return
272+ }
273+ continue
274+ }
275+ ok := o.deliver(ctx, out, batch)
276+ o.sendMu.Unlock()
277+ if !ok {
278+ return
279+ }
280+ }
281+}
282+
283+// deliver sends batch to out in order and recycles it; false when ctx ended
284+// first (the rest of the batch is dropped with the session).
285+func (o *outbox) deliver(ctx context.Context, out chan<- ports.ClientCommand, batch []outCmd) bool {
286+ // Each entry's wait ends with its own send: a send that blocks midway
287+ // must not count against the entries already out.
288+ var wait time.Duration
289+ for i := range batch {
290+ select {
291+ case out <- batch[i].command():
292+ case <-ctx.Done():
293+ return false
294+ }
295+ wait = max(wait, time.Since(batch[i].at))
296+ }
297+ o.mu.Lock()
298+ o.stats.MaxQueueWait = max(o.stats.MaxQueueWait, wait)
299+ o.mu.Unlock()
300+ o.recycle(batch)
301+ return true
302+}
@@ -0,0 +1,302 @@
1+package core
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// outbox carries commands from the owner goroutine to the sender goroutine
12+// without ever blocking the owner. It is a transport queue, not window
13+// state: the mutex guards only the slices. Commands leave in the order they
14+// were queued; the only merge is of adjacent pointer motions (see push).
15+//
16+// The wayland display goroutine can be busy for a while. Sending from the
17+// owner would stall keyboard, scenes and frame wakes behind it, so the owner
18+// queues and the sender goroutine does the blocking send.
19+type outbox struct {
20+ // wake is signalled (never blocking) after each push.
21+ wake chan struct{}
22+
23+ mu sync.Mutex
24+ queue, spare []outCmd
25+ // busy is set while the sender holds a batch it has not finished
26+ // sending: a command pushed then must queue behind it.
27+ busy bool
28+ // pushed and sent count the entries queued and delivered (a merge adds
29+ // none); progress is signalled (never blocking) at each delivery, for
30+ // flush. The owner is its only waiter, so one pending signal is enough.
31+ pushed, sent uint64
32+ progress chan struct{}
33+ // running is set while a sender (run) owns delivery.
34+ running bool
35+ // sendMu is held by whoever takes and delivers a batch, so batches
36+ // never interleave (the sender, or flush without one).
37+ sendMu sync.Mutex
38+ // stats are guarded by mu, reset by takeStats. Their times come from
39+ // the system clock, not core's: they measure real waits, and the sender
40+ // must not step a test clock.
41+ stats CommandStats
42+}
43+
44+// CommandStats tell whether the display holds up core's commands: how many
45+// queued instead of going straight out, how long they waited, and how long
46+// core itself waited (flush). They are reset when read.
47+type CommandStats struct {
48+ // Direct and Queued count the commands sent at once and queued; Merged
49+ // counts motions folded into a queued one.
50+ Direct, Queued, Merged int
51+ // MaxBacklog is the longest queue seen.
52+ MaxBacklog uint64
53+ // MaxQueueWait is the longest time a queued command took to reach the
54+ // channel; MaxFlushWait the longest time core waited in flush (a spawn's
55+ // SlotsPending, or a full outbox).
56+ MaxQueueWait, MaxFlushWait time.Duration
57+}
58+
59+// takeStats returns the counters and starts over.
60+func (o *outbox) takeStats() CommandStats {
61+ o.mu.Lock()
62+ defer o.mu.Unlock()
63+ s := o.stats
64+ o.stats = CommandStats{}
65+ return s
66+}
67+
68+// outCmd is a queued command. A pointer motion is kept unboxed so that a
69+// merge boxes the sum once, when it leaves, not at every merge.
70+type outCmd struct {
71+ cmd ports.ClientCommand
72+ // state is the owner envelope; secured is false without a security gate.
73+ state ports.SecurityState
74+ secured bool
75+ // motion is cmd when it is a pointer motion. merged means cmd is stale
76+ // and motion carries the sum.
77+ motion ports.PointerMotionTo
78+ isMotion bool
79+ merged bool
80+ // at is when it queued; a merge keeps the first motion's.
81+ at time.Time
82+}
83+
84+// outboxLimit bounds the commands waiting for the display. Motion merges, so
85+// only a display stalled for a long while reaches it; then core waits for
86+// room as a full channel made it wait before, rather than grow without
87+// bound or drop order-sensitive input.
88+const outboxLimit = 4096
89+
90+// backlog is the number of queued commands not yet delivered.
91+func (o *outbox) backlog() uint64 {
92+ o.mu.Lock()
93+ defer o.mu.Unlock()
94+ return o.pushed - o.sent
95+}
96+
97+func newOutbox() outbox {
98+ return outbox{wake: make(chan struct{}, 1), progress: make(chan struct{}, 1)}
99+}
100+
101+// command returns what the sender delivers.
102+func (o *outCmd) command() ports.ClientCommand {
103+ v := o.cmd
104+ if o.merged {
105+ v = o.motion
106+ }
107+ if o.secured {
108+ return ports.SecurityCommand{State: o.state, Command: v}
109+ }
110+ return v
111+}
112+
113+// push delivers v, wrapped in an envelope for state when secured. With
114+// nothing queued or in flight and room in out it sends at once, which keeps
115+// the display's latency when it keeps up; otherwise it queues v. A pointer
116+// motion for the same window and envelope as the one queued last replaces
117+// it: the position is the latest and the relative deltas add up, so locked
118+// pointers (games) lose no movement. Nothing else is merged or reordered:
119+// buttons, focus, keys and gestures depend on their order.
120+//
121+// It returns the entry's sequence number, for flush.
122+func (o *outbox) push(out chan<- ports.ClientCommand, v ports.ClientCommand, state ports.SecurityState, secured bool) uint64 {
123+ e := outCmd{cmd: v, state: state, secured: secured}
124+ e.motion, e.isMotion = v.(ports.PointerMotionTo)
125+ o.mu.Lock()
126+ // A full channel would make the attempt fail after building the
127+ // envelope; queue at once instead. The length is only a hint: queued
128+ // delivery keeps the order either way.
129+ if !o.busy && len(o.queue) == 0 && (cap(out) == 0 || len(out) < cap(out)) {
130+ // A send that cannot block is safe under the lock.
131+ select {
132+ case out <- e.command():
133+ o.pushed++
134+ o.sent = o.pushed
135+ o.stats.Direct++
136+ seq := o.pushed
137+ o.mu.Unlock()
138+ return seq
139+ default:
140+ }
141+ }
142+ // Only a queued command pays for the clock read.
143+ e.at = time.Now()
144+ if n := len(o.queue); n > 0 && e.isMotion {
145+ last := &o.queue[n-1]
146+ if last.isMotion && last.motion.ID == e.motion.ID && last.secured == secured && last.state == state {
147+ e.motion.DX += last.motion.DX
148+ e.motion.DY += last.motion.DY
149+ e.motion.UnaccelDX += last.motion.UnaccelDX
150+ e.motion.UnaccelDY += last.motion.UnaccelDY
151+ e.merged = true
152+ e.at = last.at
153+ *last = e
154+ o.stats.Merged++
155+ seq := o.pushed
156+ o.mu.Unlock()
157+ return seq
158+ }
159+ }
160+ o.queue = append(o.queue, e)
161+ o.pushed++
162+ o.stats.Queued++
163+ o.stats.MaxBacklog = max(o.stats.MaxBacklog, o.pushed-o.sent)
164+ seq := o.pushed
165+ o.mu.Unlock()
166+ select {
167+ case o.wake <- struct{}{}:
168+ default:
169+ }
170+ return seq
171+}
172+
173+// flush waits until the entry seq is in out, or ctx ends. It is for the
174+// rare command another channel depends on (SlotsPending before a spawn);
175+// everything else never waits. Without a running sender (core driven
176+// without Run), it delivers the queue itself, in order.
177+func (o *outbox) flush(ctx context.Context, out chan<- ports.ClientCommand, seq uint64) error {
178+ var start time.Time
179+ defer func() {
180+ if !start.IsZero() {
181+ w := time.Since(start)
182+ o.mu.Lock()
183+ o.stats.MaxFlushWait = max(o.stats.MaxFlushWait, w)
184+ o.mu.Unlock()
185+ }
186+ }()
187+ for {
188+ o.mu.Lock()
189+ done, running := o.sent >= seq, o.running
190+ o.mu.Unlock()
191+ if done {
192+ return nil
193+ }
194+ if start.IsZero() {
195+ start = time.Now()
196+ }
197+ if !running {
198+ o.sendMu.Lock()
199+ ok := o.deliver(ctx, out, o.take())
200+ o.sendMu.Unlock()
201+ if !ok {
202+ return ctx.Err()
203+ }
204+ continue
205+ }
206+ // A signal left from an earlier delivery only costs a recheck.
207+ select {
208+ case <-o.progress:
209+ case <-ctx.Done():
210+ return ctx.Err()
211+ }
212+ }
213+}
214+
215+// take returns the queued commands and leaves an empty queue. The caller
216+// hands the batch back with recycle once it has sent it.
217+func (o *outbox) take() []outCmd {
218+ o.mu.Lock()
219+ batch := o.queue
220+ o.queue, o.spare = o.spare[:0], nil
221+ o.busy = len(batch) > 0
222+ o.mu.Unlock()
223+ return batch
224+}
225+
226+// recycle keeps the batch's storage for the next take.
227+func (o *outbox) recycle(batch []outCmd) {
228+ // Do not retain what the commands reference in the spare batch.
229+ n := uint64(len(batch))
230+ clear(batch)
231+ o.mu.Lock()
232+ o.spare = batch[:0]
233+ o.busy = false
234+ if n > 0 {
235+ o.sent += n
236+ }
237+ o.mu.Unlock()
238+ if n > 0 {
239+ select {
240+ case o.progress <- struct{}{}:
241+ default:
242+ }
243+ }
244+}
245+
246+// start marks a sender as running; call it before starting run, so that a
247+// flush from the owner never delivers alongside it.
248+func (o *outbox) start() {
249+ o.mu.Lock()
250+ o.running = true
251+ o.mu.Unlock()
252+}
253+
254+// run delivers the queue to out in order until ctx ends. A command whose
255+// send is waiting is not merged any more, but what queues behind it is.
256+func (o *outbox) run(ctx context.Context, out chan<- ports.ClientCommand) {
257+ defer func() {
258+ o.mu.Lock()
259+ o.running = false
260+ o.mu.Unlock()
261+ }()
262+ for {
263+ o.sendMu.Lock()
264+ batch := o.take()
265+ if len(batch) == 0 {
266+ o.recycle(batch)
267+ o.sendMu.Unlock()
268+ select {
269+ case <-o.wake:
270+ case <-ctx.Done():
271+ return
272+ }
273+ continue
274+ }
275+ ok := o.deliver(ctx, out, batch)
276+ o.sendMu.Unlock()
277+ if !ok {
278+ return
279+ }
280+ }
281+}
282+
283+// deliver sends batch to out in order and recycles it; false when ctx ended
284+// first (the rest of the batch is dropped with the session).
285+func (o *outbox) deliver(ctx context.Context, out chan<- ports.ClientCommand, batch []outCmd) bool {
286+ // Each entry's wait ends with its own send: a send that blocks midway
287+ // must not count against the entries already out.
288+ var wait time.Duration
289+ for i := range batch {
290+ select {
291+ case out <- batch[i].command():
292+ case <-ctx.Done():
293+ return false
294+ }
295+ wait = max(wait, time.Since(batch[i].at))
296+ }
297+ o.mu.Lock()
298+ o.stats.MaxQueueWait = max(o.stats.MaxQueueWait, wait)
299+ o.mu.Unlock()
300+ o.recycle(batch)
301+ return true
302+}
A internal/core/outbox_run_test.go +204 -0
@@ -0,0 +1,204 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outboxRig runs a core whose command channel nobody reads until the test
13+// does. Run and the sender live in a synctest bubble, so synctest.Wait
14+// returns once both are idle.
15+type outboxRig struct {
16+ input chan ports.InputEvent
17+ client chan ports.ClientEvent
18+ output chan ports.OutputEvent
19+ commands chan ports.ClientCommand
20+ scenes chan []ports.Scene
21+}
22+
23+func startOutboxRig(t *testing.T, commandCap int) *outboxRig {
24+ t.Helper()
25+ cfg := scrollDefaults()
26+ cfg.Border.Width = 0
27+ cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
28+ cfg.Binds["Cmd+minus"] = "scale-down"
29+ r := &outboxRig{
30+ input: make(chan ports.InputEvent),
31+ client: make(chan ports.ClientEvent),
32+ output: make(chan ports.OutputEvent),
33+ commands: make(chan ports.ClientCommand, commandCap),
34+ scenes: make(chan []ports.Scene, 1),
35+ }
36+ c, err := core.New(cfg, core.Channels{Input: r.input, Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes}, core.Options{})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ ctx, cancel := context.WithCancel(context.Background())
41+ done := make(chan error, 1)
42+ go func() { done <- c.Run(ctx) }()
43+ t.Cleanup(func() {
44+ cancel()
45+ // Run waits for its sender, so a sender stuck on the full channel
46+ // would keep this from returning.
47+ if err := receive(t, done); err != nil {
48+ t.Error(err)
49+ }
50+ })
51+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
52+ r.client <- ports.WindowMapped{ID: 1}
53+ synctest.Wait()
54+ // Settle: drop the scenes and the commands of the map.
55+ r.drain()
56+ return r
57+}
58+
59+// drain empties the scene and command channels and lets the sender refill
60+// them from its queue until nothing moves.
61+func (r *outboxRig) drain() []ports.ClientCommand {
62+ var got []ports.ClientCommand
63+ for {
64+ synctest.Wait()
65+ select {
66+ case <-r.scenes:
67+ default:
68+ }
69+ select {
70+ case v := <-r.commands:
71+ got = append(got, v)
72+ default:
73+ synctest.Wait()
74+ if len(r.commands) == 0 {
75+ return got
76+ }
77+ }
78+ }
79+}
80+
81+// A display that reads nothing must not stall core: motion keeps coming, and
82+// a bind after it still runs and publishes its scene.
83+func TestCommandsNeverBlockCore(t *testing.T) {
84+ synctest.Test(t, func(t *testing.T) {
85+ r := startOutboxRig(t, 32)
86+ const n = 10000
87+ for i := range n {
88+ // Each send returns only once Run is back in its select.
89+ r.input <- ports.PointerMotion{X: 500 + float64(i%100), Y: 200, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4}
90+ }
91+ r.input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
92+ synctest.Wait()
93+ select {
94+ case set := <-r.scenes:
95+ if set[0].Scale != 5.0/3 {
96+ t.Fatalf("scale %v, want 5/3", set[0].Scale)
97+ }
98+ default:
99+ t.Fatal("no scene: core is blocked on commands")
100+ }
101+ // Once the display reads again, it gets everything in order, with
102+ // the motion merged and no movement lost.
103+ var dx, dy, ux, uy float64
104+ var motions int
105+ var scaled bool
106+ for _, v := range r.drain() {
107+ switch v := v.(type) {
108+ case ports.PointerMotionTo:
109+ motions++
110+ dx, dy, ux, uy = dx+v.DX, dy+v.DY, ux+v.UnaccelDX, uy+v.UnaccelDY
111+ case ports.SetOutputs:
112+ scaled = v.Outputs[0].Scale == 5.0/3
113+ }
114+ }
115+ if dx != n || dy != 2*n || ux != 3*n || uy != 4*n {
116+ t.Fatalf("lost movement: %v %v %v %v", dx, dy, ux, uy)
117+ }
118+ if motions > 40 {
119+ t.Fatalf("%d motions reached the display, want them merged while it was away", motions)
120+ }
121+ if !scaled {
122+ t.Fatal("scale change not sent after the motions")
123+ }
124+ })
125+}
126+
127+// Commands reach the display in the order core made them. Only adjacent
128+// motions of one window merge.
129+func TestCommandsKeepOrderAndMergeAdjacentMotion(t *testing.T) {
130+ synctest.Test(t, func(t *testing.T) {
131+ r := startOutboxRig(t, 0)
132+ // No reader: the sender holds the first command, which is in flight
133+ // and no longer merges, and the rest queues behind it.
134+ motion := func(x, dx float64) {
135+ r.input <- ports.PointerMotion{X: x, Y: 200, DX: dx, UnaccelDX: dx}
136+ synctest.Wait()
137+ }
138+ // How the first commands split between the sender's batch and the
139+ // queue depends on when it wakes. The first key is a barrier: what
140+ // is pushed after the owner handled it never merges with what
141+ // came before, so the rest is deterministic.
142+ motion(500, 1)
143+ r.input <- ports.KeyEvent{Keysym: "y", Keycode: 21, Pressed: true}
144+ synctest.Wait()
145+ motion(501, 2)
146+ motion(502, 4)
147+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
148+ synctest.Wait()
149+ motion(503, 8)
150+ r.input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
151+ synctest.Wait()
152+ motion(504, 16)
153+ motion(505, 32)
154+ var got []ports.ClientCommand
155+ behind := false
156+ for _, v := range r.drainUnbuffered() {
157+ switch v.(type) {
158+ case ports.UserActivity, ports.SetOutputs:
159+ continue
160+ }
161+ if k, ok := v.(ports.ForwardKey); ok && k.Key.Keysym == "y" {
162+ behind = true
163+ continue
164+ }
165+ if behind {
166+ got = append(got, v)
167+ }
168+ }
169+ want := []ports.ClientCommand{
170+ ports.PointerMotionTo{ID: 1, X: 502, Y: 200, DX: 6, UnaccelDX: 6},
171+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true},
172+ ports.PointerMotionTo{ID: 1, X: 503, Y: 200, DX: 8, UnaccelDX: 8},
173+ ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}},
174+ ports.PointerMotionTo{ID: 1, X: 505, Y: 200, DX: 48, UnaccelDX: 48},
175+ }
176+ wantOrder(t, got, want)
177+ })
178+}
179+
180+// drainUnbuffered receives what the sender hands over one by one.
181+func (r *outboxRig) drainUnbuffered() []ports.ClientCommand {
182+ var got []ports.ClientCommand
183+ for {
184+ synctest.Wait()
185+ select {
186+ case v := <-r.commands:
187+ got = append(got, v)
188+ default:
189+ return got
190+ }
191+ }
192+}
193+
194+func wantOrder(t *testing.T, got, want []ports.ClientCommand) {
195+ t.Helper()
196+ if len(got) != len(want) {
197+ t.Fatalf("got %d commands, want %d:\n%#v", len(got), len(want), got)
198+ }
199+ for i := range want {
200+ if got[i] != want[i] {
201+ t.Errorf("command %d: got %#v, want %#v", i, got[i], want[i])
202+ }
203+ }
204+}
@@ -0,0 +1,204 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outboxRig runs a core whose command channel nobody reads until the test
13+// does. Run and the sender live in a synctest bubble, so synctest.Wait
14+// returns once both are idle.
15+type outboxRig struct {
16+ input chan ports.InputEvent
17+ client chan ports.ClientEvent
18+ output chan ports.OutputEvent
19+ commands chan ports.ClientCommand
20+ scenes chan []ports.Scene
21+}
22+
23+func startOutboxRig(t *testing.T, commandCap int) *outboxRig {
24+ t.Helper()
25+ cfg := scrollDefaults()
26+ cfg.Border.Width = 0
27+ cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
28+ cfg.Binds["Cmd+minus"] = "scale-down"
29+ r := &outboxRig{
30+ input: make(chan ports.InputEvent),
31+ client: make(chan ports.ClientEvent),
32+ output: make(chan ports.OutputEvent),
33+ commands: make(chan ports.ClientCommand, commandCap),
34+ scenes: make(chan []ports.Scene, 1),
35+ }
36+ c, err := core.New(cfg, core.Channels{Input: r.input, Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes}, core.Options{})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ ctx, cancel := context.WithCancel(context.Background())
41+ done := make(chan error, 1)
42+ go func() { done <- c.Run(ctx) }()
43+ t.Cleanup(func() {
44+ cancel()
45+ // Run waits for its sender, so a sender stuck on the full channel
46+ // would keep this from returning.
47+ if err := receive(t, done); err != nil {
48+ t.Error(err)
49+ }
50+ })
51+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
52+ r.client <- ports.WindowMapped{ID: 1}
53+ synctest.Wait()
54+ // Settle: drop the scenes and the commands of the map.
55+ r.drain()
56+ return r
57+}
58+
59+// drain empties the scene and command channels and lets the sender refill
60+// them from its queue until nothing moves.
61+func (r *outboxRig) drain() []ports.ClientCommand {
62+ var got []ports.ClientCommand
63+ for {
64+ synctest.Wait()
65+ select {
66+ case <-r.scenes:
67+ default:
68+ }
69+ select {
70+ case v := <-r.commands:
71+ got = append(got, v)
72+ default:
73+ synctest.Wait()
74+ if len(r.commands) == 0 {
75+ return got
76+ }
77+ }
78+ }
79+}
80+
81+// A display that reads nothing must not stall core: motion keeps coming, and
82+// a bind after it still runs and publishes its scene.
83+func TestCommandsNeverBlockCore(t *testing.T) {
84+ synctest.Test(t, func(t *testing.T) {
85+ r := startOutboxRig(t, 32)
86+ const n = 10000
87+ for i := range n {
88+ // Each send returns only once Run is back in its select.
89+ r.input <- ports.PointerMotion{X: 500 + float64(i%100), Y: 200, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4}
90+ }
91+ r.input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
92+ synctest.Wait()
93+ select {
94+ case set := <-r.scenes:
95+ if set[0].Scale != 5.0/3 {
96+ t.Fatalf("scale %v, want 5/3", set[0].Scale)
97+ }
98+ default:
99+ t.Fatal("no scene: core is blocked on commands")
100+ }
101+ // Once the display reads again, it gets everything in order, with
102+ // the motion merged and no movement lost.
103+ var dx, dy, ux, uy float64
104+ var motions int
105+ var scaled bool
106+ for _, v := range r.drain() {
107+ switch v := v.(type) {
108+ case ports.PointerMotionTo:
109+ motions++
110+ dx, dy, ux, uy = dx+v.DX, dy+v.DY, ux+v.UnaccelDX, uy+v.UnaccelDY
111+ case ports.SetOutputs:
112+ scaled = v.Outputs[0].Scale == 5.0/3
113+ }
114+ }
115+ if dx != n || dy != 2*n || ux != 3*n || uy != 4*n {
116+ t.Fatalf("lost movement: %v %v %v %v", dx, dy, ux, uy)
117+ }
118+ if motions > 40 {
119+ t.Fatalf("%d motions reached the display, want them merged while it was away", motions)
120+ }
121+ if !scaled {
122+ t.Fatal("scale change not sent after the motions")
123+ }
124+ })
125+}
126+
127+// Commands reach the display in the order core made them. Only adjacent
128+// motions of one window merge.
129+func TestCommandsKeepOrderAndMergeAdjacentMotion(t *testing.T) {
130+ synctest.Test(t, func(t *testing.T) {
131+ r := startOutboxRig(t, 0)
132+ // No reader: the sender holds the first command, which is in flight
133+ // and no longer merges, and the rest queues behind it.
134+ motion := func(x, dx float64) {
135+ r.input <- ports.PointerMotion{X: x, Y: 200, DX: dx, UnaccelDX: dx}
136+ synctest.Wait()
137+ }
138+ // How the first commands split between the sender's batch and the
139+ // queue depends on when it wakes. The first key is a barrier: what
140+ // is pushed after the owner handled it never merges with what
141+ // came before, so the rest is deterministic.
142+ motion(500, 1)
143+ r.input <- ports.KeyEvent{Keysym: "y", Keycode: 21, Pressed: true}
144+ synctest.Wait()
145+ motion(501, 2)
146+ motion(502, 4)
147+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
148+ synctest.Wait()
149+ motion(503, 8)
150+ r.input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
151+ synctest.Wait()
152+ motion(504, 16)
153+ motion(505, 32)
154+ var got []ports.ClientCommand
155+ behind := false
156+ for _, v := range r.drainUnbuffered() {
157+ switch v.(type) {
158+ case ports.UserActivity, ports.SetOutputs:
159+ continue
160+ }
161+ if k, ok := v.(ports.ForwardKey); ok && k.Key.Keysym == "y" {
162+ behind = true
163+ continue
164+ }
165+ if behind {
166+ got = append(got, v)
167+ }
168+ }
169+ want := []ports.ClientCommand{
170+ ports.PointerMotionTo{ID: 1, X: 502, Y: 200, DX: 6, UnaccelDX: 6},
171+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true},
172+ ports.PointerMotionTo{ID: 1, X: 503, Y: 200, DX: 8, UnaccelDX: 8},
173+ ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}},
174+ ports.PointerMotionTo{ID: 1, X: 505, Y: 200, DX: 48, UnaccelDX: 48},
175+ }
176+ wantOrder(t, got, want)
177+ })
178+}
179+
180+// drainUnbuffered receives what the sender hands over one by one.
181+func (r *outboxRig) drainUnbuffered() []ports.ClientCommand {
182+ var got []ports.ClientCommand
183+ for {
184+ synctest.Wait()
185+ select {
186+ case v := <-r.commands:
187+ got = append(got, v)
188+ default:
189+ return got
190+ }
191+ }
192+}
193+
194+func wantOrder(t *testing.T, got, want []ports.ClientCommand) {
195+ t.Helper()
196+ if len(got) != len(want) {
197+ t.Fatalf("got %d commands, want %d:\n%#v", len(got), len(want), got)
198+ }
199+ for i := range want {
200+ if got[i] != want[i] {
201+ t.Errorf("command %d: got %#v, want %#v", i, got[i], want[i])
202+ }
203+ }
204+}
A internal/core/outbox_test.go +212 -0
@@ -0,0 +1,212 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "testing/synctest"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func wantOutbox(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
14+ t.Helper()
15+ if !slices.Equal(got, want) {
16+ t.Fatalf("commands %#v, want %#v", got, want)
17+ }
18+}
19+
20+// takeAll returns what the sender would deliver, in order.
21+func takeAll(o *outbox) []ports.ClientCommand {
22+ var got []ports.ClientCommand
23+ for _, e := range o.take() {
24+ got = append(got, e.command())
25+ }
26+ return got
27+}
28+
29+// Only adjacent motions for the same window merge: the position is the
30+// latest, the deltas add up. Everything else keeps its place.
31+func TestOutboxMergesAdjacentMotionOnly(t *testing.T) {
32+ o := newOutbox()
33+ // Nobody receives, so every command queues.
34+ out := make(chan ports.ClientCommand)
35+ push := func(v ports.ClientCommand) { o.push(out, v, ports.SecurityState{}, false) }
36+ push(ports.PointerMotionTo{ID: 1, X: 1, Y: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
37+ push(ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
38+ push(ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true})
39+ push(ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5})
40+ push(ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7})
41+ push(ports.FocusWindow{ID: 2})
42+ push(ports.PointerMotionTo{ID: 2, X: 5, Y: 5, DX: 1})
43+ push(ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 1})
44+ want := []ports.ClientCommand{
45+ ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44},
46+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true},
47+ ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5},
48+ ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7},
49+ ports.FocusWindow{ID: 2},
50+ ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 2},
51+ }
52+ wantOutbox(t, takeAll(&o), want...)
53+}
54+
55+// Wrapped motions merge only under an identical owner envelope, and keep it.
56+func TestOutboxMergesSecuredMotionPerEnvelope(t *testing.T) {
57+ o := newOutbox()
58+ out := make(chan ports.ClientCommand)
59+ a := ports.SecurityState{Generation: 1}
60+ b := ports.SecurityState{Generation: 2}
61+ o.push(out, ports.PointerMotionTo{ID: 1, X: 1, DX: 1}, a, true)
62+ o.push(out, ports.PointerMotionTo{ID: 1, X: 2, DX: 2}, a, true)
63+ o.push(out, ports.PointerMotionTo{ID: 1, X: 3, DX: 4}, b, true)
64+ o.push(out, ports.PointerMotionTo{ID: 1, X: 4, DX: 8}, ports.SecurityState{}, false)
65+ wantOutbox(t, takeAll(&o),
66+ ports.SecurityCommand{State: a, Command: ports.PointerMotionTo{ID: 1, X: 2, DX: 3}},
67+ ports.SecurityCommand{State: b, Command: ports.PointerMotionTo{ID: 1, X: 3, DX: 4}},
68+ ports.PointerMotionTo{ID: 1, X: 4, DX: 8},
69+ )
70+}
71+
72+// With room in the channel and nothing queued, a command goes straight out.
73+func TestOutboxSendsDirectlyWhileDisplayKeepsUp(t *testing.T) {
74+ o := newOutbox()
75+ out := make(chan ports.ClientCommand, 4)
76+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, ports.SecurityState{}, false)
77+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, ports.SecurityState{}, false)
78+ if len(out) != 2 {
79+ t.Fatalf("%d commands sent, want 2 unmerged", len(out))
80+ }
81+ if got := o.take(); len(got) != 0 {
82+ t.Fatalf("queued %d", len(got))
83+ }
84+}
85+
86+// A display that reads nothing for long fills the outbox to its limit, then
87+// core waits for room: the queue never grows past it, nothing is dropped.
88+func TestOutboxIsBounded(t *testing.T) {
89+ synctest.Test(t, func(t *testing.T) {
90+ out := make(chan ports.ClientCommand)
91+ c := &Core{out: newOutbox(), ch: Channels{Commands: out}}
92+ c.out.start()
93+ ctx, cancel := context.WithCancel(context.Background())
94+ defer cancel()
95+ go c.out.run(ctx, out)
96+ done := make(chan error, 1)
97+ go func() {
98+ for i := range outboxLimit + 10 {
99+ // Not mergeable: each takes a place.
100+ if err := c.command(ctx, ports.FocusWindow{ID: WindowID(i)}); err != nil {
101+ done <- err
102+ return
103+ }
104+ }
105+ done <- nil
106+ }()
107+ synctest.Wait()
108+ if n := c.out.backlog(); n != outboxLimit+1 {
109+ t.Fatalf("backlog %d, want the limit and the one waiting", n)
110+ }
111+ select {
112+ case err := <-done:
113+ t.Fatalf("pushed past the limit: %v", err)
114+ default:
115+ }
116+ for i := range outboxLimit + 10 {
117+ if got := (<-out).(ports.FocusWindow); got.ID != WindowID(i) {
118+ t.Fatalf("command %d is %+v", i, got)
119+ }
120+ }
121+ if err := <-done; err != nil {
122+ t.Fatal(err)
123+ }
124+ })
125+}
126+
127+// Queuing a command and delivering it allocates nothing once warm: the
128+// batches are recycled and flush's progress signal is reused.
129+func TestOutboxQueuedDeliveryAllocations(t *testing.T) {
130+ o := newOutbox()
131+ full := make(chan ports.ClientCommand, 1)
132+ full <- ports.FocusWindow{}
133+ sink := make(chan ports.ClientCommand, 8)
134+ var cmd ports.ClientCommand = ports.FocusWindow{ID: 1}
135+ var state ports.SecurityState
136+ ctx := context.Background()
137+ cycle := func() {
138+ // full has no room: push queues, even for a secured command.
139+ o.push(full, cmd, state, false)
140+ o.push(full, cmd, state, true)
141+ o.deliver(ctx, sink, o.take())
142+ <-sink
143+ <-sink
144+ select {
145+ case <-o.progress:
146+ default:
147+ t.Fatal("delivery left no progress signal")
148+ }
149+ }
150+ cycle()
151+ // The secured delivery boxes its envelope once; the failed direct send
152+ // must not box another.
153+ if n := testing.AllocsPerRun(100, cycle); n > 1 {
154+ t.Fatalf("%v allocations per queued delivery, want at most 1", n)
155+ }
156+}
157+
158+// The stats tell direct sends from queued ones, count merges, and time how
159+// long the first queued command waited for the channel and core in flush.
160+func TestOutboxStats(t *testing.T) {
161+ synctest.Test(t, func(t *testing.T) {
162+ o := newOutbox()
163+ out := make(chan ports.ClientCommand, 1)
164+ ctx := context.Background()
165+ var none ports.SecurityState
166+ o.push(out, ports.FocusWindow{ID: 1}, none, false) // direct: room
167+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, none, false)
168+ time.Sleep(5 * time.Millisecond)
169+ seq := o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, none, false) // merges
170+ time.Sleep(5 * time.Millisecond)
171+ go func() {
172+ time.Sleep(20 * time.Millisecond)
173+ <-out
174+ }()
175+ if err := o.flush(ctx, out, seq); err != nil {
176+ t.Fatal(err)
177+ }
178+ s := o.takeStats()
179+ want := CommandStats{Direct: 1, Queued: 1, Merged: 1, MaxBacklog: 1, MaxQueueWait: 30 * time.Millisecond, MaxFlushWait: 20 * time.Millisecond}
180+ if s != want {
181+ t.Fatalf("stats %+v, want %+v", s, want)
182+ }
183+ if s := o.takeStats(); s != (CommandStats{}) {
184+ t.Fatalf("not reset: %+v", s)
185+ }
186+ })
187+}
188+
189+// A send that blocks midway through a batch counts against the entries still
190+// waiting, not the ones already out: the wait is per entry.
191+func TestOutboxQueueWaitIsPerEntry(t *testing.T) {
192+ synctest.Test(t, func(t *testing.T) {
193+ o := newOutbox()
194+ out := make(chan ports.ClientCommand)
195+ var none ports.SecurityState
196+ o.push(out, ports.FocusWindow{ID: 1}, none, false) // queued at 0
197+ time.Sleep(9 * time.Millisecond)
198+ o.push(out, ports.FocusWindow{ID: 2}, none, false) // queued at 9 ms
199+ go func() {
200+ time.Sleep(time.Millisecond)
201+ <-out // first out at 10 ms
202+ time.Sleep(10 * time.Millisecond)
203+ <-out // second out at 20 ms
204+ }()
205+ if !o.deliver(context.Background(), out, o.take()) {
206+ t.Fatal("deliver stopped")
207+ }
208+ if got := o.takeStats().MaxQueueWait; got != 11*time.Millisecond {
209+ t.Fatalf("max queue wait %v, want 11ms (the second entry's)", got)
210+ }
211+ })
212+}
@@ -0,0 +1,212 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "testing/synctest"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func wantOutbox(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
14+ t.Helper()
15+ if !slices.Equal(got, want) {
16+ t.Fatalf("commands %#v, want %#v", got, want)
17+ }
18+}
19+
20+// takeAll returns what the sender would deliver, in order.
21+func takeAll(o *outbox) []ports.ClientCommand {
22+ var got []ports.ClientCommand
23+ for _, e := range o.take() {
24+ got = append(got, e.command())
25+ }
26+ return got
27+}
28+
29+// Only adjacent motions for the same window merge: the position is the
30+// latest, the deltas add up. Everything else keeps its place.
31+func TestOutboxMergesAdjacentMotionOnly(t *testing.T) {
32+ o := newOutbox()
33+ // Nobody receives, so every command queues.
34+ out := make(chan ports.ClientCommand)
35+ push := func(v ports.ClientCommand) { o.push(out, v, ports.SecurityState{}, false) }
36+ push(ports.PointerMotionTo{ID: 1, X: 1, Y: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
37+ push(ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
38+ push(ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true})
39+ push(ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5})
40+ push(ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7})
41+ push(ports.FocusWindow{ID: 2})
42+ push(ports.PointerMotionTo{ID: 2, X: 5, Y: 5, DX: 1})
43+ push(ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 1})
44+ want := []ports.ClientCommand{
45+ ports.PointerMotionTo{ID: 1, X: 2, Y: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44},
46+ ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true},
47+ ports.PointerMotionTo{ID: 1, X: 3, Y: 3, DX: 5},
48+ ports.PointerMotionTo{ID: 2, X: 4, Y: 4, DX: 7},
49+ ports.FocusWindow{ID: 2},
50+ ports.PointerMotionTo{ID: 2, X: 6, Y: 6, DX: 2},
51+ }
52+ wantOutbox(t, takeAll(&o), want...)
53+}
54+
55+// Wrapped motions merge only under an identical owner envelope, and keep it.
56+func TestOutboxMergesSecuredMotionPerEnvelope(t *testing.T) {
57+ o := newOutbox()
58+ out := make(chan ports.ClientCommand)
59+ a := ports.SecurityState{Generation: 1}
60+ b := ports.SecurityState{Generation: 2}
61+ o.push(out, ports.PointerMotionTo{ID: 1, X: 1, DX: 1}, a, true)
62+ o.push(out, ports.PointerMotionTo{ID: 1, X: 2, DX: 2}, a, true)
63+ o.push(out, ports.PointerMotionTo{ID: 1, X: 3, DX: 4}, b, true)
64+ o.push(out, ports.PointerMotionTo{ID: 1, X: 4, DX: 8}, ports.SecurityState{}, false)
65+ wantOutbox(t, takeAll(&o),
66+ ports.SecurityCommand{State: a, Command: ports.PointerMotionTo{ID: 1, X: 2, DX: 3}},
67+ ports.SecurityCommand{State: b, Command: ports.PointerMotionTo{ID: 1, X: 3, DX: 4}},
68+ ports.PointerMotionTo{ID: 1, X: 4, DX: 8},
69+ )
70+}
71+
72+// With room in the channel and nothing queued, a command goes straight out.
73+func TestOutboxSendsDirectlyWhileDisplayKeepsUp(t *testing.T) {
74+ o := newOutbox()
75+ out := make(chan ports.ClientCommand, 4)
76+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, ports.SecurityState{}, false)
77+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, ports.SecurityState{}, false)
78+ if len(out) != 2 {
79+ t.Fatalf("%d commands sent, want 2 unmerged", len(out))
80+ }
81+ if got := o.take(); len(got) != 0 {
82+ t.Fatalf("queued %d", len(got))
83+ }
84+}
85+
86+// A display that reads nothing for long fills the outbox to its limit, then
87+// core waits for room: the queue never grows past it, nothing is dropped.
88+func TestOutboxIsBounded(t *testing.T) {
89+ synctest.Test(t, func(t *testing.T) {
90+ out := make(chan ports.ClientCommand)
91+ c := &Core{out: newOutbox(), ch: Channels{Commands: out}}
92+ c.out.start()
93+ ctx, cancel := context.WithCancel(context.Background())
94+ defer cancel()
95+ go c.out.run(ctx, out)
96+ done := make(chan error, 1)
97+ go func() {
98+ for i := range outboxLimit + 10 {
99+ // Not mergeable: each takes a place.
100+ if err := c.command(ctx, ports.FocusWindow{ID: WindowID(i)}); err != nil {
101+ done <- err
102+ return
103+ }
104+ }
105+ done <- nil
106+ }()
107+ synctest.Wait()
108+ if n := c.out.backlog(); n != outboxLimit+1 {
109+ t.Fatalf("backlog %d, want the limit and the one waiting", n)
110+ }
111+ select {
112+ case err := <-done:
113+ t.Fatalf("pushed past the limit: %v", err)
114+ default:
115+ }
116+ for i := range outboxLimit + 10 {
117+ if got := (<-out).(ports.FocusWindow); got.ID != WindowID(i) {
118+ t.Fatalf("command %d is %+v", i, got)
119+ }
120+ }
121+ if err := <-done; err != nil {
122+ t.Fatal(err)
123+ }
124+ })
125+}
126+
127+// Queuing a command and delivering it allocates nothing once warm: the
128+// batches are recycled and flush's progress signal is reused.
129+func TestOutboxQueuedDeliveryAllocations(t *testing.T) {
130+ o := newOutbox()
131+ full := make(chan ports.ClientCommand, 1)
132+ full <- ports.FocusWindow{}
133+ sink := make(chan ports.ClientCommand, 8)
134+ var cmd ports.ClientCommand = ports.FocusWindow{ID: 1}
135+ var state ports.SecurityState
136+ ctx := context.Background()
137+ cycle := func() {
138+ // full has no room: push queues, even for a secured command.
139+ o.push(full, cmd, state, false)
140+ o.push(full, cmd, state, true)
141+ o.deliver(ctx, sink, o.take())
142+ <-sink
143+ <-sink
144+ select {
145+ case <-o.progress:
146+ default:
147+ t.Fatal("delivery left no progress signal")
148+ }
149+ }
150+ cycle()
151+ // The secured delivery boxes its envelope once; the failed direct send
152+ // must not box another.
153+ if n := testing.AllocsPerRun(100, cycle); n > 1 {
154+ t.Fatalf("%v allocations per queued delivery, want at most 1", n)
155+ }
156+}
157+
158+// The stats tell direct sends from queued ones, count merges, and time how
159+// long the first queued command waited for the channel and core in flush.
160+func TestOutboxStats(t *testing.T) {
161+ synctest.Test(t, func(t *testing.T) {
162+ o := newOutbox()
163+ out := make(chan ports.ClientCommand, 1)
164+ ctx := context.Background()
165+ var none ports.SecurityState
166+ o.push(out, ports.FocusWindow{ID: 1}, none, false) // direct: room
167+ o.push(out, ports.PointerMotionTo{ID: 1, DX: 1}, none, false)
168+ time.Sleep(5 * time.Millisecond)
169+ seq := o.push(out, ports.PointerMotionTo{ID: 1, DX: 2}, none, false) // merges
170+ time.Sleep(5 * time.Millisecond)
171+ go func() {
172+ time.Sleep(20 * time.Millisecond)
173+ <-out
174+ }()
175+ if err := o.flush(ctx, out, seq); err != nil {
176+ t.Fatal(err)
177+ }
178+ s := o.takeStats()
179+ want := CommandStats{Direct: 1, Queued: 1, Merged: 1, MaxBacklog: 1, MaxQueueWait: 30 * time.Millisecond, MaxFlushWait: 20 * time.Millisecond}
180+ if s != want {
181+ t.Fatalf("stats %+v, want %+v", s, want)
182+ }
183+ if s := o.takeStats(); s != (CommandStats{}) {
184+ t.Fatalf("not reset: %+v", s)
185+ }
186+ })
187+}
188+
189+// A send that blocks midway through a batch counts against the entries still
190+// waiting, not the ones already out: the wait is per entry.
191+func TestOutboxQueueWaitIsPerEntry(t *testing.T) {
192+ synctest.Test(t, func(t *testing.T) {
193+ o := newOutbox()
194+ out := make(chan ports.ClientCommand)
195+ var none ports.SecurityState
196+ o.push(out, ports.FocusWindow{ID: 1}, none, false) // queued at 0
197+ time.Sleep(9 * time.Millisecond)
198+ o.push(out, ports.FocusWindow{ID: 2}, none, false) // queued at 9 ms
199+ go func() {
200+ time.Sleep(time.Millisecond)
201+ <-out // first out at 10 ms
202+ time.Sleep(10 * time.Millisecond)
203+ <-out // second out at 20 ms
204+ }()
205+ if !o.deliver(context.Background(), out, o.take()) {
206+ t.Fatal("deliver stopped")
207+ }
208+ if got := o.takeStats().MaxQueueWait; got != 11*time.Millisecond {
209+ t.Fatalf("max queue wait %v, want 11ms (the second entry's)", got)
210+ }
211+ })
212+}
M internal/core/security_review_test.go +15 -24
@@ -29,28 +29,25 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
2929 var state atomic.Value
3030 state.Store(ports.SecurityState{})
3131 gate := portsmocks.NewMockSessionSecurity(t)
32- entered := make(chan struct{}, 1)
3332 var calls atomic.Int32
3433 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
3534 v := state.Load().(ports.SecurityState)
3635 if calls.Add(1) == 2 {
37- entered <- struct{}{}
36+ // The epoch changes right after the check that admits the command.
37+ state.Store(ports.SecurityState{Generation: 2})
3838 }
3939 return v
4040 })
4141 c.opts.Security = gate
4242 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43- commands := make(chan ports.ClientCommand)
43+ commands := make(chan ports.ClientCommand, 8)
4444 c.ch.Commands = commands
45- done := make(chan error, 1)
46- go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }()
47- reviewReceive(t, entered)
48- state.Store(ports.SecurityState{Generation: 2})
45+ err := c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30})
4946 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
5047 if cmd.State != (ports.SecurityState{}) {
5148 t.Fatalf("old handler adopted new epoch: %+v", cmd)
5249 }
53- if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) {
50+ if !errors.Is(err, errSecurityChanged) {
5451 t.Fatalf("handler not aborted: %v", err)
5552 }
5653 if c.security != (ports.SecurityState{}) {
@@ -88,29 +85,26 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
8885 var state atomic.Value
8986 state.Store(ports.SecurityState{})
9087 gate := portsmocks.NewMockSessionSecurity(t)
91- checked := make(chan struct{}, 1)
9288 var calls atomic.Int32
9389 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
9490 v := state.Load().(ports.SecurityState)
9591 if calls.Add(1) == 2 {
96- checked <- struct{}{}
92+ // The gate locks right after the check that admits the slot.
93+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
9794 }
9895 return v
9996 })
10097 c.opts.Security = gate
101- commands := make(chan ports.ClientCommand)
98+ commands := make(chan ports.ClientCommand, 8)
10299 c.ch.Commands = commands
103100 spawn := make(chan ports.SpawnRequest, 2)
104101 c.ch.Spawn = spawn
105- done := make(chan error, 1)
106- go func() { done <- c.spawnSlots(context.Background(), false) }()
107- reviewReceive(t, checked)
108- state.Store(ports.SecurityState{Generation: 1, Protected: true})
102+ err := c.spawnSlots(context.Background(), false)
109103 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
110104 if _, ok := cmd.Command.(ports.SlotsPending); !ok {
111105 t.Fatal(cmd)
112106 }
113- if err := reviewReceive(t, done); err != nil {
107+ if err != nil {
114108 t.Fatal(err)
115109 }
116110 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
@@ -138,26 +132,23 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
138132 var state atomic.Value
139133 state.Store(ports.SecurityState{})
140134 gate := portsmocks.NewMockSessionSecurity(t)
141- checked := make(chan struct{}, 1)
142135 var calls atomic.Int32
143136 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
144137 v := state.Load().(ports.SecurityState)
145138 if calls.Add(1) == 1 {
146- checked <- struct{}{}
139+ // The gate locks right after the check that admits the spawn.
140+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
147141 }
148142 return v
149143 })
150144 c.opts.Security = gate
151- commands := make(chan ports.ClientCommand)
145+ commands := make(chan ports.ClientCommand, 8)
152146 c.ch.Commands = commands
153147 spawn := make(chan ports.SpawnRequest, 1)
154148 c.ch.Spawn = spawn
155- done := make(chan error, 1)
156- go func() { done <- c.spawnEmpty(context.Background()) }()
157- reviewReceive(t, checked)
158- state.Store(ports.SecurityState{Generation: 1, Protected: true})
149+ err := c.spawnEmpty(context.Background())
159150 reviewReceive(t, commands)
160- if err := reviewReceive(t, done); err != nil {
151+ if err != nil {
161152 t.Fatal(err)
162153 }
163154 if len(spawn) != 0 || c.placement.anyPending() {
@@ -29,28 +29,25 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
29 var state atomic.Value29 var state atomic.Value
30 state.Store(ports.SecurityState{})30 state.Store(ports.SecurityState{})
31 gate := portsmocks.NewMockSessionSecurity(t)31 gate := portsmocks.NewMockSessionSecurity(t)
32- entered := make(chan struct{}, 1)
33 var calls atomic.Int3232 var calls atomic.Int32
34 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {33 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
35 v := state.Load().(ports.SecurityState)34 v := state.Load().(ports.SecurityState)
36 if calls.Add(1) == 2 {35 if calls.Add(1) == 2 {
37- entered <- struct{}{}36+ // The epoch changes right after the check that admits the command.
37+ state.Store(ports.SecurityState{Generation: 2})
38 }38 }
39 return v39 return v
40 })40 })
41 c.opts.Security = gate41 c.opts.Security = gate
42 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}42 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43- commands := make(chan ports.ClientCommand)43+ commands := make(chan ports.ClientCommand, 8)
44 c.ch.Commands = commands44 c.ch.Commands = commands
45- done := make(chan error, 1)45+ err := c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30})
46- go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }()
47- reviewReceive(t, entered)
48- state.Store(ports.SecurityState{Generation: 2})
49 cmd := reviewReceive(t, commands).(ports.SecurityCommand)46 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
50 if cmd.State != (ports.SecurityState{}) {47 if cmd.State != (ports.SecurityState{}) {
51 t.Fatalf("old handler adopted new epoch: %+v", cmd)48 t.Fatalf("old handler adopted new epoch: %+v", cmd)
52 }49 }
53- if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) {50+ if !errors.Is(err, errSecurityChanged) {
54 t.Fatalf("handler not aborted: %v", err)51 t.Fatalf("handler not aborted: %v", err)
55 }52 }
56 if c.security != (ports.SecurityState{}) {53 if c.security != (ports.SecurityState{}) {
@@ -88,29 +85,26 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
88 var state atomic.Value85 var state atomic.Value
89 state.Store(ports.SecurityState{})86 state.Store(ports.SecurityState{})
90 gate := portsmocks.NewMockSessionSecurity(t)87 gate := portsmocks.NewMockSessionSecurity(t)
91- checked := make(chan struct{}, 1)
92 var calls atomic.Int3288 var calls atomic.Int32
93 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {89 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
94 v := state.Load().(ports.SecurityState)90 v := state.Load().(ports.SecurityState)
95 if calls.Add(1) == 2 {91 if calls.Add(1) == 2 {
96- checked <- struct{}{}92+ // The gate locks right after the check that admits the slot.
93+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
97 }94 }
98 return v95 return v
99 })96 })
100 c.opts.Security = gate97 c.opts.Security = gate
101- commands := make(chan ports.ClientCommand)98+ commands := make(chan ports.ClientCommand, 8)
102 c.ch.Commands = commands99 c.ch.Commands = commands
103 spawn := make(chan ports.SpawnRequest, 2)100 spawn := make(chan ports.SpawnRequest, 2)
104 c.ch.Spawn = spawn101 c.ch.Spawn = spawn
105- done := make(chan error, 1)102+ err := c.spawnSlots(context.Background(), false)
106- go func() { done <- c.spawnSlots(context.Background(), false) }()
107- reviewReceive(t, checked)
108- state.Store(ports.SecurityState{Generation: 1, Protected: true})
109 cmd := reviewReceive(t, commands).(ports.SecurityCommand)103 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
110 if _, ok := cmd.Command.(ports.SlotsPending); !ok {104 if _, ok := cmd.Command.(ports.SlotsPending); !ok {
111 t.Fatal(cmd)105 t.Fatal(cmd)
112 }106 }
113- if err := reviewReceive(t, done); err != nil {107+ if err != nil {
114 t.Fatal(err)108 t.Fatal(err)
115 }109 }
116 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {110 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
@@ -138,26 +132,23 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
138 var state atomic.Value132 var state atomic.Value
139 state.Store(ports.SecurityState{})133 state.Store(ports.SecurityState{})
140 gate := portsmocks.NewMockSessionSecurity(t)134 gate := portsmocks.NewMockSessionSecurity(t)
141- checked := make(chan struct{}, 1)
142 var calls atomic.Int32135 var calls atomic.Int32
143 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {136 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
144 v := state.Load().(ports.SecurityState)137 v := state.Load().(ports.SecurityState)
145 if calls.Add(1) == 1 {138 if calls.Add(1) == 1 {
146- checked <- struct{}{}139+ // The gate locks right after the check that admits the spawn.
140+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
147 }141 }
148 return v142 return v
149 })143 })
150 c.opts.Security = gate144 c.opts.Security = gate
151- commands := make(chan ports.ClientCommand)145+ commands := make(chan ports.ClientCommand, 8)
152 c.ch.Commands = commands146 c.ch.Commands = commands
153 spawn := make(chan ports.SpawnRequest, 1)147 spawn := make(chan ports.SpawnRequest, 1)
154 c.ch.Spawn = spawn148 c.ch.Spawn = spawn
155- done := make(chan error, 1)149+ err := c.spawnEmpty(context.Background())
156- go func() { done <- c.spawnEmpty(context.Background()) }()
157- reviewReceive(t, checked)
158- state.Store(ports.SecurityState{Generation: 1, Protected: true})
159 reviewReceive(t, commands)150 reviewReceive(t, commands)
160- if err := reviewReceive(t, done); err != nil {151+ if err != nil {
161 t.Fatal(err)152 t.Fatal(err)
162 }153 }
163 if len(spawn) != 0 || c.placement.anyPending() {154 if len(spawn) != 0 || c.placement.anyPending() {
M internal/core/stash_spawn_internal_test.go +6 -4
@@ -40,10 +40,10 @@ func claimToken(req ports.SpawnRequest) string {
4040 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
4141 }
4242
43-// Wayland reads tokens only while slots are pending, so the flag is sent
44-// before the spawn can reach the launcher. In a synctest bubble the worker
45-// is waited on until it blocks sending the flag on the unread command
46-// channel: the spawn channel must still be empty then.
43+// Wayland reads tokens only while slots are pending, so the flag is in the
44+// command channel before the spawn can reach the launcher, also when the
45+// display reads nothing: in a synctest bubble the sender is waited on until
46+// it blocks on the unread channel, and the spawn channel must still be empty.
4747 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
4848 synctest.Test(t, func(t *testing.T) {
4949 now := claimBase
@@ -52,6 +52,8 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
5252 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
5353 ctx, cancel := context.WithCancel(context.Background())
5454 t.Cleanup(cancel)
55+ c.out.start()
56+ go c.out.run(ctx, commands)
5557 done := make(chan error, 1)
5658 go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
5759 synctest.Wait()
@@ -40,10 +40,10 @@ func claimToken(req ports.SpawnRequest) string {
40 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")40 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41 }41 }
42 42
43-// Wayland reads tokens only while slots are pending, so the flag is sent43+// Wayland reads tokens only while slots are pending, so the flag is in the
44-// before the spawn can reach the launcher. In a synctest bubble the worker44+// command channel before the spawn can reach the launcher, also when the
45-// is waited on until it blocks sending the flag on the unread command45+// display reads nothing: in a synctest bubble the sender is waited on until
46-// channel: the spawn channel must still be empty then.46+// it blocks on the unread channel, and the spawn channel must still be empty.
47 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {47 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48 synctest.Test(t, func(t *testing.T) {48 synctest.Test(t, func(t *testing.T) {
49 now := claimBase49 now := claimBase
@@ -52,6 +52,8 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
52 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))52 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53 ctx, cancel := context.WithCancel(context.Background())53 ctx, cancel := context.WithCancel(context.Background())
54 t.Cleanup(cancel)54 t.Cleanup(cancel)
55+ c.out.start()
56+ go c.out.run(ctx, commands)
55 done := make(chan error, 1)57 done := make(chan error, 1)
56 go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()58 go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57 synctest.Wait()59 synctest.Wait()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close