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
33 files · +2369 -304
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 | 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/drm/flip_stats.go +4 -0
| @@ -26,6 +26,10 @@ type flipStats struct { | |||
| 26 | 26 | // vblank the frame targeted (never under VRR). Fence times are only | |
| 27 | 27 | // read while flip tracing is on. | |
| 28 | 28 | 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 | 35 | // 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 only | 26 | // 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 int | 28 | 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 refresh | 35 | // 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 { | |||
| 157 | 157 | // move waits for the next frame (see cursorWaits). | |
| 158 | 158 | lastFrame time.Time | |
| 159 | 159 | 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 | 165 | // traceFlips logs every completion with its timing; lastFlipAt is | |
| 161 | 166 | // the previous frame flip's kernel timestamp (always kept). | |
| 162 | 167 | // flipStats are reported and reset with the periodic stats. wantedAt | |
| @@ -455,6 +460,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 455 | 460 | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active | |
| 456 | 461 | // The gap and the flip interval belonged to the old state. | |
| 457 | 462 | o.flipGapUntil, o.flipGapAt, o.lastFlipAt = time.Time{}, 0, 0 | |
| 463 | + | o.stateAt, o.lastVblankAt = time.Time{}, 0 | |
| 458 | 464 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | |
| 459 | 465 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | |
| 460 | 466 | if o.cursor != nil { | |
| @@ -494,6 +500,8 @@ func (o *Output) powerOff() error { | |||
| 494 | 500 | } | |
| 495 | 501 | o.colorBypassed() | |
| 496 | 502 | 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 | 505 | o.setVRR(false) | |
| 498 | 506 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | |
| 499 | 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 | 729 | o.contentValue = o.contentWanted | |
| 722 | 730 | o.planeRect = rect | |
| 723 | 731 | 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 | 737 | f.frame, f.async = true, async | |
| 725 | 738 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | |
| 726 | 739 | f.gapEnd, o.flipGapAt = o.flipGapAt, 0 | |
| @@ -774,6 +787,14 @@ func (o *Output) commitState(vrr bool) error { | |||
| 774 | 787 | if o.protected { | |
| 775 | 788 | 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 | 798 | req := &o.stateReq | |
| 778 | 799 | req.reset() | |
| 779 | 800 | cur := cursorState{} | |
| @@ -839,6 +860,7 @@ func (o *Output) commitState(vrr bool) error { | |||
| 839 | 860 | } | |
| 840 | 861 | o.setVRR(vrr) | |
| 841 | 862 | o.contentValue = o.contentWanted | |
| 863 | + | o.flipStats.stateCommits++ | |
| 842 | 864 | o.frame.begin(pendingFrame{}, time.Now()) | |
| 843 | 865 | return nil | |
| 844 | 866 | } | |
| @@ -1097,6 +1119,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1097 | 1119 | retryTimer := time.NewTimer(time.Hour) | |
| 1098 | 1120 | vrrTimer := time.NewTimer(time.Hour) | |
| 1099 | 1121 | cursorTimer := time.NewTimer(time.Hour) | |
| 1122 | + | stateTimer := time.NewTimer(time.Hour) | |
| 1123 | + | stateTimer.Stop() | |
| 1124 | + | defer stateTimer.Stop() | |
| 1100 | 1125 | protectTimer := time.NewTimer(time.Hour) | |
| 1101 | 1126 | o.frame.startTimer() | |
| 1102 | 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 | 1291 | } else { | |
| 1267 | 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 | 1302 | // A game frame waiting out the VRR flip gap commits when it ends. | |
| 1270 | 1303 | var gapDue <-chan time.Time | |
| 1271 | 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 | 1371 | stateDirty = true | |
| 1339 | 1372 | case <-cursorDue: | |
| 1340 | 1373 | stateDirty = true | |
| 1374 | + | case <-stateDue: | |
| 1375 | + | stateDirty = true | |
| 1341 | 1376 | case <-cursorWake: | |
| 1342 | 1377 | stateDirty = true | |
| 1343 | 1378 | case <-gapDue: | |
| @@ -1462,6 +1497,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1462 | 1497 | fs := o.takeFlipStats() | |
| 1463 | 1498 | ev = ev.Int("missed_vblanks", fs.missedVblanks).Float64("max_flip_interval_ms", ms(fs.maxInterval)) | |
| 1464 | 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 | 1501 | ev = ev.Int("redrawn_pixels", r.TakeRedrawn()) | |
| 1466 | 1502 | if o.traceFlips { | |
| 1467 | 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 | 1674 | return false | |
| 1639 | 1675 | } | |
| 1640 | 1676 | o.flipDone(ev, f, start, ours, trace, fenceAt) | |
| 1677 | + | if ours { | |
| 1678 | + | o.noteVblank(f, ev.when) | |
| 1679 | + | } | |
| 1641 | 1680 | if o.cursor != nil { | |
| 1642 | 1681 | o.cursor.landed() | |
| 1643 | 1682 | } | |
| @@ -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.Time | 158 | lastFrame time.Time | ||
| 159 | cursorHeld bool | 159 | 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 is | 165 | // 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. wantedAt | 167 | // 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, !active | 460 | 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, 0 | 462 | 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, false | 502 | 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.contentWanted | 729 | o.contentValue = o.contentWanted | ||
| 722 | o.planeRect = rect | 730 | o.planeRect = rect | ||
| 723 | o.lastFrame, o.cursorHeld = time.Now(), false | 731 | 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, async | 737 | f.frame, f.async = true, async | ||
| 725 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | 738 | f.wantedAt, o.wantedAt = o.wantedAt, 0 | ||
| 726 | f.gapEnd, o.flipGapAt = o.flipGapAt, 0 | 739 | 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 errSecurityScene | 788 | 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.stateReq | 798 | 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.contentWanted | 862 | o.contentValue = o.contentWanted | ||
| 863 | + | o.flipStats.stateCommits++ | |||
| 842 | o.frame.begin(pendingFrame{}, time.Now()) | 864 | o.frame.begin(pendingFrame{}, time.Now()) | ||
| 843 | return nil | 865 | 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.Time | 1303 | 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 = true | 1371 | stateDirty = true | ||
| 1339 | case <-cursorDue: | 1372 | case <-cursorDue: | ||
| 1340 | stateDirty = true | 1373 | stateDirty = true | ||
| 1374 | + | case <-stateDue: | |||
| 1375 | + | stateDirty = true | |||
| 1341 | case <-cursorWake: | 1376 | case <-cursorWake: | ||
| 1342 | stateDirty = true | 1377 | 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 false | 1674 | 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) { | |||
| 208 | 208 | t.Fatal("content from a truncated pool") | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | + | ||
| 212 | + | // Forwarding a command allocates nothing on the push path: queueing a | |
| 213 | + | // command, folding a motion into the queued one and draining the ring. (Only | |
| 214 | + | // applying a merged motion boxes the summed value again, once per merged run, | |
| 215 | + | // as the old batch coalescing did once per merge; that is excluded here.) | |
| 216 | + | func TestCommandForwardAllocations(t *testing.T) { | |
| 217 | + | if raceEnabled { | |
| 218 | + | t.Skip("the race detector makes the purego wake callback allocate") | |
| 219 | + | } | |
| 220 | + | s, _, _, _, _, _ := presentServer(t) | |
| 221 | + | r := newCommandRing(s.display, func(queuedCommand) {}) | |
| 222 | + | var button ports.ClientCommand = ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true} | |
| 223 | + | var motion ports.ClientCommand = ports.PointerMotionTo{ID: 1, DX: 1} | |
| 224 | + | state := ports.SecurityState{Generation: 2, Protected: true} | |
| 225 | + | var wrapped ports.ClientCommand = ports.SecurityCommand{State: state, Command: ports.PointerMotionTo{ID: 1, DX: 1}} | |
| 226 | + | ran := make(chan struct{}, 1) | |
| 227 | + | marker := func() { ran <- struct{}{} } | |
| 228 | + | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "reflect" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 6 | 8 | "testing" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| 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) | |
| 23 | 56 | want := []ports.ClientCommand{ | |
| 24 | 57 | // Slow motions add up: none of the sub-pixel deltas is lost. | |
| 25 | 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 | 60 | ports.PointerMotionTo{ID: 1, X: 4}, | |
| 28 | 61 | ports.PointerFocus{ID: 2}, | |
| 29 | 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) { | |
| 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: | |
| 33 | 163 | } | |
| 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") | |
| 37 | 169 | } | |
| 38 | 170 | } | |
| 39 | 171 | ||
| @@ -58,3 +190,122 @@ func TestCommandThroughput(t *testing.T) { | |||
| 58 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/purego-libwayland/server" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // postRing carries values from producer goroutines to the display goroutine | |
| 10 | 11 | // 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. | |
| 13 | 16 | // | |
| 14 | 17 | // It binds its drain once and posts it at most once per batch: Display.Post | |
| 15 | 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 | 21 | display *server.Display | |
| 19 | 22 | apply func(T) | |
| 20 | 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 | 27 | // space is signalled (never blocking) after each drain and after a push | |
| 22 | 28 | // that left room, to wake blocked pushers one after the other. | |
| 23 | 29 | space chan struct{} | |
| @@ -26,11 +32,36 @@ type postRing[T any] struct { | |||
| 26 | 32 | ring, spare []T | |
| 27 | 33 | limit int | |
| 28 | 34 | 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 | 56 | // newPostRing returns a ring of limit values whose values apply runs on the | |
| 32 | 57 | // display goroutine, in order. limit must be at least 1. | |
| 33 | 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 | 65 | r := &postRing[T]{ | |
| 35 | 66 | display: d, | |
| 36 | 67 | apply: apply, | |
| @@ -38,6 +69,7 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||
| 38 | 69 | ring: make([]T, 0, limit), | |
| 39 | 70 | spare: make([]T, 0, limit), | |
| 40 | 71 | limit: limit, | |
| 72 | + | merge: merge, | |
| 41 | 73 | } | |
| 42 | 74 | r.drainFn = r.drain | |
| 43 | 75 | return r | |
| @@ -47,10 +79,21 @@ func newPostRing[T any](d *server.Display, limit int, apply func(T)) *postRing[T | |||
| 47 | 79 | // the caller, which must not be the display goroutine, until a drain made | |
| 48 | 80 | // room; it reports false, with v dropped, once the display has stopped. | |
| 49 | 81 | func (r *postRing[T]) Push(v T) bool { | |
| 82 | + | var blocked time.Duration // total wait of this push on a full ring | |
| 50 | 83 | for { | |
| 51 | 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 | 93 | if len(r.ring) < r.limit { | |
| 53 | 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 | 97 | post := !r.pending | |
| 55 | 98 | r.pending = true | |
| 56 | 99 | room := len(r.ring) < r.limit | |
| @@ -64,8 +107,11 @@ func (r *postRing[T]) Push(v T) bool { | |||
| 64 | 107 | return true | |
| 65 | 108 | } | |
| 66 | 109 | r.mu.Unlock() | |
| 110 | + | // Only the blocking path reads the clock: the fast path stays free. | |
| 111 | + | start := time.Now() | |
| 67 | 112 | select { | |
| 68 | 113 | case <-r.space: | |
| 114 | + | blocked += time.Since(start) | |
| 69 | 115 | case <-r.display.Stopped(): | |
| 70 | 116 | return false | |
| 71 | 117 | } | |
| @@ -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 goroutine | 10 | // postRing carries values from producer goroutines to the display goroutine | ||
| 10 | // without allocating. It is a transport queue, not window state: the mutex | 11 | // 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 | + | // 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.Post | 17 | // 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.Display | 21 | 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 push | 27 | // 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 []T | 32 | ring, spare []T | ||
| 27 | limit int | 33 | limit int | ||
| 28 | pending bool // a drain is posted and has not taken the ring yet | 34 | 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 the | 56 | // 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.drain | 74 | r.drainFn = r.drain | ||
| 43 | return r | 75 | 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 made | 79 | // 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.pending | 97 | post := !r.pending | ||
| 55 | r.pending = true | 98 | r.pending = true | ||
| 56 | room := len(r.ring) < r.limit | 99 | room := len(r.ring) < r.limit | ||
| @@ -64,8 +107,11 @@ func (r *postRing[T]) Push(v T) bool { | |||||
| 64 | return true | 107 | 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 false | 116 | return false | ||
| 71 | } | 117 | } | ||
M internal/adapters/wayland/postring_test.go +49 -0
| @@ -2,6 +2,8 @@ package wayland | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "runtime" | |
| 6 | + | "strings" | |
| 5 | 7 | "sync" | |
| 6 | 8 | "testing" | |
| 7 | 9 | "time" | |
| @@ -158,3 +160,50 @@ func TestPostRingPushStopsWithDisplay(t *testing.T) { | |||
| 158 | 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 | + | } | |
| @@ -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 { | |||
| 131 | 131 | // Screencast (screencast.go): casts by the adapter's ID and by the ID | |
| 132 | 132 | // core knows them by; castRing carries the adapter's commands to the | |
| 133 | 133 | // display goroutine; the castInv fields coalesce inventory publication. | |
| 134 | - | casts ports.CastSink | |
| 135 | - | castSessions map[uint64]*castSession | |
| 136 | - | castByCore map[uint64]*castSession | |
| 137 | - | castRing *postRing[ports.CastCommand] | |
| 134 | + | casts ports.CastSink | |
| 135 | + | castSessions map[uint64]*castSession | |
| 136 | + | castByCore map[uint64]*castSession | |
| 137 | + | castRing *postRing[ports.CastCommand] | |
| 138 | + | // commands carries core's client commands to the display goroutine. | |
| 139 | + | commands *postRing[queuedCommand] | |
| 140 | + | commandStats commandStats | |
| 138 | 141 | castInvFn func() | |
| 139 | 142 | castInvPosted bool | |
| 140 | 143 | castInvSent bool | |
| @@ -389,6 +392,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 389 | 392 | s.casts = opts.Casts | |
| 390 | 393 | s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{} | |
| 391 | 394 | s.castRing = newPostRing(d, castRingSize, s.applyCast) | |
| 395 | + | s.commands = newCommandRing(d, s.applyQueued) | |
| 392 | 396 | s.castInvFn = s.publishCastInventory | |
| 393 | 397 | s.leaseDevices = map[string]*leaseDevice{} | |
| 394 | 398 | s.pendingLeases = map[uint64]*leaseObject{} | |
| @@ -532,7 +536,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 532 | 536 | s.started = time.Now() | |
| 533 | 537 | s.ctx = ctx | |
| 534 | 538 | var wg sync.WaitGroup | |
| 535 | - | wg.Add(13) | |
| 539 | + | wg.Add(14) | |
| 536 | 540 | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | |
| 537 | 541 | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | |
| 538 | 542 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | |
| @@ -555,23 +559,15 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 555 | 559 | if ctx.Err() != nil { | |
| 556 | 560 | return | |
| 557 | 561 | } | |
| 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)) { | |
| 570 | 565 | return | |
| 571 | 566 | } | |
| 572 | 567 | } | |
| 573 | 568 | } | |
| 574 | 569 | }() | |
| 570 | + | go func() { defer wg.Done(); s.logCommandStats(ctx) }() | |
| 575 | 571 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | |
| 576 | 572 | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | |
| 577 | 573 | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | |
| @@ -746,41 +742,6 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 746 | 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 | 745 | func (s *Server) apply(cmd ports.ClientCommand) { | |
| 785 | 746 | if envelope, ok := cmd.(ports.SecurityCommand); ok { | |
| 786 | 747 | 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 ID | 131 | // Screencast (screencast.go): casts by the adapter's ID and by the ID | ||
| 132 | // core knows them by; castRing carries the adapter's commands to the | 132 | // core knows them by; castRing carries the adapter's commands to the | ||
| 133 | // display goroutine; the castInv fields coalesce inventory publication. | 133 | // display goroutine; the castInv fields coalesce inventory publication. | ||
| 134 | - | casts ports.CastSink | 134 | + | casts ports.CastSink |
| 135 | - | castSessions map[uint64]*castSession | 135 | + | castSessions map[uint64]*castSession |
| 136 | - | castByCore map[uint64]*castSession | 136 | + | castByCore map[uint64]*castSession |
| 137 | - | castRing *postRing[ports.CastCommand] | 137 | + | castRing *postRing[ports.CastCommand] |
| 138 | + | // commands carries core's client commands to the display goroutine. | |||
| 139 | + | commands *postRing[queuedCommand] | |||
| 140 | + | commandStats commandStats | |||
| 138 | castInvFn func() | 141 | castInvFn func() | ||
| 139 | castInvPosted bool | 142 | castInvPosted bool | ||
| 140 | castInvSent bool | 143 | castInvSent bool | ||
| @@ -389,6 +392,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 389 | s.casts = opts.Casts | 392 | 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.publishCastInventory | 396 | 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 = ctx | 537 | s.ctx = ctx | ||
| 534 | var wg sync.WaitGroup | 538 | 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 | return | 560 | return | ||
| 557 | } | 561 | } | ||
| 558 | - | // One display round trip per batch: Do waits for the event | 562 | + | // Never wait for the display goroutine: a busy display only |
| 559 | - | // loop, so a round trip per motion capped pointer events at | 563 | + | // 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 | return | 565 | 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 { | |||
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | // 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) { | |
| 220 | 221 | select { | |
| 221 | 222 | case ch <- v: | |
| 222 | 223 | default: | |
| 223 | 224 | select { | |
| 224 | 225 | case <-ch: | |
| 226 | + | replaced = true | |
| 225 | 227 | default: | |
| 226 | 228 | } | |
| 227 | 229 | select { | |
| @@ -229,4 +231,5 @@ func latest[T any](ch chan T, v T) { | |||
| 229 | 231 | default: | |
| 230 | 232 | } | |
| 231 | 233 | } | |
| 234 | + | return replaced | |
| 232 | 235 | } | |
| @@ -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 not | 216 | // 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 | 217 | + | // 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | func TestCaptureUnknownOutputClosesFile(t *testing.T) { | |
| 13 | 13 | replies := make(chan ports.CaptureDone, 1) | |
| 14 | - | set := newOutputSet(context.Background(), replies) | |
| 14 | + | router := newOutputRouter(context.Background(), replies, outputRoutes{}) | |
| 15 | 15 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 16 | 16 | if err != nil { | |
| 17 | 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 | 20 | select { | |
| 21 | 21 | case result := <-replies: | |
| 22 | 22 | if result.ID != 9 || result.Err == nil { | |
| @@ -34,17 +34,17 @@ func TestCaptureFullOutputQueueDoesNotBlockRouting(t *testing.T) { | |||
| 34 | 34 | ctx, cancel := context.WithCancel(context.Background()) | |
| 35 | 35 | defer cancel() | |
| 36 | 36 | replies := make(chan ports.CaptureDone, 1) | |
| 37 | - | set := newOutputSet(ctx, replies) | |
| 37 | + | router := newOutputRouter(ctx, replies, outputRoutes{}) | |
| 38 | 38 | queued := make(chan ports.CaptureRequest, 1) | |
| 39 | 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 | 41 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 42 | 42 | if err != nil { | |
| 43 | 43 | t.Fatal(err) | |
| 44 | 44 | } | |
| 45 | 45 | routed := make(chan struct{}) | |
| 46 | 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 | 48 | close(routed) | |
| 49 | 49 | }() | |
| 50 | 50 | select { | |
| @@ -71,13 +71,13 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) { | |||
| 71 | 71 | outputCtx, stop := context.WithCancel(ctx) | |
| 72 | 72 | stop() | |
| 73 | 73 | 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)} | |
| 76 | 76 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 77 | 77 | if err != nil { | |
| 78 | 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 | 81 | select { | |
| 82 | 82 | case result := <-replies: | |
| 83 | 83 | if result.ID != 7 || result.Err == nil { | |
| @@ -93,17 +93,14 @@ func TestStoppedOutputCaptureStillReplies(t *testing.T) { | |||
| 93 | 93 | ||
| 94 | 94 | func TestFinishedOutputDrainsCapturesWithReplies(t *testing.T) { | |
| 95 | 95 | ctx := context.Background() | |
| 96 | - | outputCtx, stop := context.WithCancel(ctx) | |
| 97 | - | stop() | |
| 98 | 96 | replies := make(chan ports.CaptureDone, 1) | |
| 99 | - | set := newOutputSet(ctx, replies) | |
| 100 | 97 | queued := make(chan ports.CaptureRequest, 1) | |
| 101 | 98 | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 102 | 99 | if err != nil { | |
| 103 | 100 | t.Fatal(err) | |
| 104 | 101 | } | |
| 105 | 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 | 104 | select { | |
| 108 | 105 | case result := <-replies: | |
| 109 | 106 | if result.ID != 8 || result.Err == nil { | |
| @@ -143,16 +140,14 @@ func TestOutputStopWithFullReplies(t *testing.T) { | |||
| 143 | 140 | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | 141 | replies := make(chan ports.CaptureDone, 1) | |
| 145 | 142 | 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) | |
| 151 | 146 | f, err := os.CreateTemp(t.TempDir(), "queued") | |
| 152 | 147 | if err != nil { | |
| 153 | 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 | 151 | cancel() | |
| 157 | 152 | done := make(chan struct{}) | |
| 158 | 153 | 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 ( | |||
| 17 | 17 | func TestOutputSetAttachesAndDetachesContents(t *testing.T) { | |
| 18 | 18 | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | 19 | defer cancel() | |
| 20 | - | set := newOutputSet(ctx, nil) | |
| 20 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 21 | 21 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 22 | 22 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 23 | 23 | attacher := newMockcontentAttacher(t) | |
| @@ -31,7 +31,8 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) { | |||
| 31 | 31 | security ports.SecurityState | |
| 32 | 32 | } | |
| 33 | 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 | 36 | started <- seen{contents, returns, <-security} | |
| 36 | 37 | <-ctx.Done() | |
| 37 | 38 | return nil | |
| @@ -86,13 +87,13 @@ func TestOutputSetAttachesAndDetachesContents(t *testing.T) { | |||
| 86 | 87 | func TestOutputSetWaitDetachesContents(t *testing.T) { | |
| 87 | 88 | ctx, cancel := context.WithCancel(context.Background()) | |
| 88 | 89 | defer cancel() | |
| 89 | - | set := newOutputSet(ctx, nil) | |
| 90 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 90 | 91 | attacher := newMockcontentAttacher(t) | |
| 91 | 92 | set.attach = attacher | |
| 92 | 93 | ret := portsmocks.NewMockContentReturn(t) | |
| 93 | 94 | attacher.EXPECT().AttachOutput("A").Return(nil, ret).Once() | |
| 94 | 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 | 97 | <-ctx.Done() | |
| 97 | 98 | return nil | |
| 98 | 99 | }) | |
| @@ -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.SecurityState | 31 | 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 nil | 38 | 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 = attacher | 92 | 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 nil | 98 | 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 | |||
| 332 | 332 | active := b.seat.Subscribe() | |
| 333 | 333 | defer b.seat.Unsubscribe(active) | |
| 334 | 334 | seatActive := true // openDRM waits for the first seat enable | |
| 335 | - | set := newOutputSet(ctx, ch.captured) | |
| 335 | + | set := newOutputSet(ctx, ch.captured, ch.routes()) | |
| 336 | 336 | set.attach = ch.content | |
| 337 | 337 | set.wireSecurity(ch) | |
| 338 | 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 | 347 | cards := map[string]*drmCard{} | |
| 348 | 348 | outputs := map[string]*drm.Output{} // owner result, read only after done | |
| 349 | 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 | 353 | select { | |
| 351 | 354 | case ch.events <- ev: | |
| 352 | 355 | case <-ctx.Done(): | |
| 353 | 356 | } | |
| 357 | + | set.router.noteEventSend(time.Since(start)) | |
| 354 | 358 | } | |
| 355 | 359 | // stopping holds outputs asked to stop: true when they restart with a | |
| 356 | 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 | 513 | o.NewCaptureRenderer = newRenderer | |
| 510 | - | o.StartOff = set.off[name] | |
| 511 | 514 | progress.started(name, source) | |
| 512 | 515 | if cur := o.Cursor(); cur != nil { | |
| 513 | 516 | curs.set(name, cur) | |
| 514 | 517 | } | |
| 515 | 518 | active := b.seat.Subscribe() | |
| 516 | 519 | 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 | 521 | 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 | |
| 520 | 523 | return safe("output "+name, func() error { | |
| 521 | 524 | defer o.Close() | |
| 522 | 525 | b.addPreparer(o) | |
| @@ -534,7 +537,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 534 | 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 | 542 | }, &o.SecurityEvents) | |
| 540 | 543 | if !started { | |
| @@ -698,12 +701,6 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 698 | 701 | complete(progress.readyEvent(result.name, result.source, readySources[result.name], result.err)) | |
| 699 | 702 | case <-deadline: | |
| 700 | 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 | 704 | case stopped := <-set.stopped: | |
| 708 | 705 | if !set.stoppedCurrent(stopped) { | |
| 709 | 706 | 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 enable | 334 | 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.content | 336 | 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 done | 348 | 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 a | 359 | // 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 = newRenderer | 513 | 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.Realtime | 519 | 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, instance | 522 | + | 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 | continue | 706 | continue | ||
M internal/app/headless.go +4 -10
| @@ -26,7 +26,7 @@ type headlessOptions struct { | |||
| 26 | 26 | // | |
| 27 | 27 | // newRenderer is configured for virtual outputs by newVulkanRenderer. | |
| 28 | 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 | 30 | set.attach = ch.content | |
| 31 | 31 | set.wireSecurity(ch) | |
| 32 | 32 | inventory := ports.OutputHeads{} | |
| @@ -45,9 +45,9 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 45 | 45 | cur := &headless.Cursor{} | |
| 46 | 46 | curs.set(name, cur) | |
| 47 | 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 { | |
| 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) | |
| 51 | 51 | }, &opts.SecurityEvents) | |
| 52 | 52 | if !started { | |
| 53 | 53 | return set.wait() | |
| @@ -90,12 +90,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 90 | 90 | apply.replySent() | |
| 91 | 91 | case heads <- headsNext: | |
| 92 | 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 | 93 | case stopped := <-set.stopped: | |
| 100 | 94 | if !set.stoppedCurrent(stopped) { | |
| 101 | 95 | 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.content | 30 | 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, instance | 49 | + | 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 | continue | 95 | continue | ||
M internal/app/outputrouting.go +5 -0
| @@ -30,3 +30,8 @@ type outputChannels struct { | |||
| 30 | 30 | requests <-chan ports.OutputApply | |
| 31 | 31 | 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 | + | } | |
| @@ -30,3 +30,8 @@ type outputChannels struct { | |||||
| 30 | requests <-chan ports.OutputApply | 30 | requests <-chan ports.OutputApply | ||
| 31 | replies chan<- ports.OutputApplied | 31 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "os" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 8 | 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 | 37 | func TestOutputRouting(t *testing.T) { | |
| 12 | 38 | ctx, cancel := context.WithCancel(context.Background()) | |
| 13 | 39 | defer cancel() | |
| 14 | 40 | replies := make(chan ports.CaptureDone, 2) | |
| 15 | - | set := newOutputSet(ctx, replies) | |
| 41 | + | in := newRouteInputs() | |
| 42 | + | set := newOutputSet(ctx, replies, in.routes()) | |
| 16 | 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 { | |
| 18 | - | <-ctx.Done() | |
| 19 | - | return nil | |
| 20 | - | }) | |
| 44 | + | set.start(ctx, name, idleRun) | |
| 21 | 45 | <-set.outs[name].cursor // initial cursor | |
| 22 | 46 | } | |
| 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 | |
| 25 | 50 | if got := <-set.outs["A"].scenes; got.Seq != 3 { | |
| 26 | 51 | t.Fatalf("A scene: %+v", got) | |
| 27 | 52 | } | |
| 28 | 53 | if got := <-set.outs["B"].scenes; got.Seq != 2 { | |
| 29 | 54 | t.Fatalf("B scene: %+v", got) | |
| 30 | 55 | } | |
| 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 | |
| 33 | 59 | for _, name := range []string{"A", "B"} { | |
| 34 | 60 | if got := <-set.outs[name].cursor; got.Shape != "pointer" { | |
| 35 | 61 | t.Fatalf("%s cursor: %+v", name, got) | |
| @@ -39,64 +65,172 @@ func TestOutputRouting(t *testing.T) { | |||
| 39 | 65 | if err != nil { | |
| 40 | 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 | 69 | if got := <-set.outs["B"].captures; got.ID != 7 { | |
| 44 | 70 | t.Fatalf("B capture: %+v", got) | |
| 45 | 71 | } | |
| 46 | 72 | if err := f.Close(); err != nil { | |
| 47 | 73 | t.Fatal(err) | |
| 48 | 74 | } | |
| 49 | - | set.outs["A"].stop() | |
| 75 | + | a := set.outs["A"] | |
| 76 | + | a.stop() | |
| 50 | 77 | if err := set.finish("A"); err != nil { | |
| 51 | 78 | t.Fatal(err) | |
| 52 | 79 | } | |
| 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"} | |
| 58 | 82 | if got := <-replies; got.ID != 8 || got.Err == nil { | |
| 59 | 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 | 90 | if err := set.wait(); err != nil { | |
| 62 | 91 | t.Fatal(err) | |
| 63 | 92 | } | |
| 64 | 93 | } | |
| 65 | 94 | ||
| 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) | |
| 77 | 129 | } | |
| 78 | 130 | } | |
| 79 | 131 | ||
| 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) { | |
| 82 | 151 | ctx, cancel := context.WithCancel(context.Background()) | |
| 83 | 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 { | |
| 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() | |
| 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 | 225 | scenes := []ports.Scene{{Output: "A"}} | |
| 89 | 226 | cursor := ports.CursorChange{Shape: "pointer"} | |
| 90 | 227 | capture := ports.CaptureRequest{Output: "A"} | |
| 91 | 228 | 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 | |
| 96 | 233 | }); got != 0 { | |
| 97 | 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 | } | |
| @@ -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 cursor | 45 | <-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 are | 95 | + | // 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 nil | 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() | |||
| 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"].captures | 232 | + | <-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 ( | |||
| 13 | 13 | ||
| 14 | 14 | // outputRun drives one output until ctx ends: it renders the scenes and | |
| 15 | 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) 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 | + | } | |
| 17 | 31 | ||
| 18 | 32 | // contentAttacher connects an output to the surface contents: wayland feeds | |
| 19 | 33 | // every attached output batches of window contents, starting with the latest | |
| @@ -29,19 +43,20 @@ type contentAttacher interface { | |||
| 29 | 43 | DetachOutput(feed ports.ContentReturn) | |
| 30 | 44 | } | |
| 31 | 45 | ||
| 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. | |
| 37 | 53 | 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 | |
| 39 | 58 | // 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 | |
| 45 | 60 | stopped chan outputStopped | |
| 46 | 61 | captured chan<- ports.CaptureDone | |
| 47 | 62 | ctx context.Context | |
| @@ -61,24 +76,25 @@ type outputStopped struct { | |||
| 61 | 76 | 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 | 81 | type runningOutput struct { | |
| 65 | - | instance ports.OutputInstance | |
| 82 | + | *mailbox | |
| 66 | 83 | security chan ports.SecurityState | |
| 67 | 84 | 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 | 85 | err error | |
| 74 | - | done chan struct{} | |
| 75 | 86 | // feed is the content feed AttachOutput returned (nil: none), the handle | |
| 76 | 87 | // to detach once the output stopped. | |
| 77 | 88 | feed ports.ContentReturn | |
| 78 | 89 | } | |
| 79 | 90 | ||
| 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 | |
| 82 | 98 | } | |
| 83 | 99 | ||
| 84 | 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 | 114 | s.setSecurity(s.security.Snapshot()) | |
| 99 | 115 | } | |
| 100 | 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 | 118 | r.security <- s.state // available before any content replay | |
| 103 | 119 | r.securityEvents = s.forwardSecurity() | |
| 104 | 120 | for _, destination := range wire { | |
| 105 | 121 | *destination = r.securityEvents | |
| 106 | 122 | } | |
| 107 | 123 | // 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} | |
| 110 | 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 = returns | |
| 136 | + | feed.StartOff = off | |
| 114 | 137 | s.outs[name] = r | |
| 115 | 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 | 140 | if r.securityEvents != nil { | |
| 118 | 141 | close(r.securityEvents) | |
| 119 | 142 | } | |
| @@ -124,7 +147,6 @@ func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire | |||
| 124 | 147 | case <-s.quit: | |
| 125 | 148 | } | |
| 126 | 149 | }() | |
| 127 | - | r.cursor <- s.cursor | |
| 128 | 150 | return true | |
| 129 | 151 | } | |
| 130 | 152 | ||
| @@ -200,11 +222,7 @@ func (s *outputSet) setSecurity(state ports.SecurityState) bool { | |||
| 200 | 222 | } | |
| 201 | 223 | s.state = state | |
| 202 | 224 | for _, r := range s.outs { | |
| 203 | - | select { | |
| 204 | - | case <-r.security: | |
| 205 | - | default: | |
| 206 | - | } | |
| 207 | - | r.security <- state | |
| 225 | + | latest(r.security, state) | |
| 208 | 226 | } | |
| 209 | 227 | return true | |
| 210 | 228 | } | |
| @@ -215,36 +233,6 @@ func (s *outputSet) stoppedCurrent(stopped outputStopped) bool { | |||
| 215 | 233 | 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 | 236 | // finish forgets a stopped output and returns its error. | |
| 249 | 237 | func (s *outputSet) finish(name string) error { | |
| 250 | 238 | r := s.outs[name] | |
| @@ -253,7 +241,9 @@ func (s *outputSet) finish(name string) error { | |||
| 253 | 241 | } | |
| 254 | 242 | <-r.done | |
| 255 | 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 | 247 | s.detach(r) | |
| 258 | 248 | delete(s.outs, name) | |
| 259 | 249 | return r.err | |
| @@ -276,10 +266,13 @@ func (s *outputSet) wait() error { | |||
| 276 | 266 | } | |
| 277 | 267 | close(s.quit) | |
| 278 | 268 | s.forwarders.Wait() | |
| 269 | + | // No capture is routed past this point. | |
| 270 | + | s.stopRouter() | |
| 271 | + | <-s.router.done | |
| 279 | 272 | for name, r := range s.outs { | |
| 280 | 273 | r.stop() | |
| 281 | 274 | <-r.done | |
| 282 | - | s.failQueued(r) | |
| 275 | + | failQueued(s.ctx, r.mailbox, s.captured) | |
| 283 | 276 | s.detach(r) | |
| 284 | 277 | if first == nil && r.err != nil { | |
| 285 | 278 | first = r.err | |
| @@ -427,29 +420,12 @@ func (c *cursors) move(output string, x, y float64, motion bool) { | |||
| 427 | 420 | } | |
| 428 | 421 | ||
| 429 | 422 | // 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) { | |
| 449 | 425 | for { | |
| 450 | 426 | 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) | |
| 453 | 429 | default: | |
| 454 | 430 | return | |
| 455 | 431 | } | |
| @@ -13,7 +13,21 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | // outputRun drives one output until ctx ends: it renders the scenes and | 14 | // 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) 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 | + | } | |||
| 17 | 31 | ||||
| 18 | // contentAttacher connects an output to the surface contents: wayland feeds | 32 | // contentAttacher connects an output to the surface contents: wayland feeds | ||
| 19 | // every attached output batches of window contents, starting with the latest | 33 | // 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 scene | 46 | + | // outputSet owns the output lifetimes: start and stop, security |
| 33 | - | // per output; each goes to its output, latest first. Surface contents reach | 47 | + | // registration and content feeds. Only the backend loop touches it. Scenes, |
| 34 | - | // every output (windows move between outputs) straight from wayland through | 48 | + | // 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]*runningOutput | 54 | + | 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 contentAttacher | 59 | + | 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 outputStopped | 60 | stopped chan outputStopped | ||
| 46 | captured chan<- ports.CaptureDone | 61 | captured chan<- ports.CaptureDone | ||
| 47 | ctx context.Context | 62 | ctx context.Context | ||
| @@ -61,24 +76,25 @@ type outputStopped struct { | |||||
| 61 | instance ports.OutputInstance | 76 | 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.OutputInstance | 82 | + | *mailbox |
| 66 | security chan ports.SecurityState | 83 | security chan ports.SecurityState | ||
| 67 | securityEvents chan ports.SecurityBackendEvent | 84 | 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 error | 85 | err error | ||
| 74 | - | done chan struct{} | |||
| 75 | // feed is the content feed AttachOutput returned (nil: none), the handle | 86 | // 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.ContentReturn | 88 | 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 replay | 118 | 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.securityEvents | 121 | *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.ContentBatch | 124 | + | 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 = returns | 136 | + | feed.StartOff = off |
| 114 | s.outs[name] = r | 137 | 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 true | 150 | return true | ||
| 129 | } | 151 | } | ||
| 130 | 152 | ||||
| @@ -200,11 +222,7 @@ func (s *outputSet) setSecurity(state ports.SecurityState) bool { | |||||
| 200 | } | 222 | } | ||
| 201 | s.state = state | 223 | 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 true | 227 | return true | ||
| 210 | } | 228 | } | ||
| @@ -215,36 +233,6 @@ func (s *outputSet) stoppedCurrent(stopped outputStopped) bool { | |||||
| 215 | return r != nil && r.instance == stopped.instance | 233 | 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.done | 242 | <-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.err | 249 | 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.done | 274 | <-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.err | 278 | 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 | return | 430 | return | ||
| 455 | } | 431 | } | ||
M internal/app/outputs_test.go +6 -4
| @@ -12,12 +12,14 @@ import ( | |||
| 12 | 12 | func TestOutputSetCursor(t *testing.T) { | |
| 13 | 13 | ctx, cancel := context.WithCancel(context.Background()) | |
| 14 | 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 | 17 | got := map[string]chan ports.CursorChange{} | |
| 17 | 18 | run := func(name string) outputRun { | |
| 18 | 19 | got[name] = make(chan ports.CursorChange, 8) | |
| 19 | 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 | 23 | for { | |
| 22 | 24 | select { | |
| 23 | 25 | case <-ctx.Done(): | |
| @@ -41,11 +43,11 @@ func TestOutputSetCursor(t *testing.T) { | |||
| 41 | 43 | } | |
| 42 | 44 | set.start(ctx, "A", run("A")) | |
| 43 | 45 | expect("A", ports.CursorChange{}) | |
| 44 | - | set.setCursor(ports.CursorChange{Shape: "text"}) | |
| 46 | + | cursors <- ports.CursorChange{Shape: "text"} | |
| 45 | 47 | expect("A", ports.CursorChange{Shape: "text"}) | |
| 46 | 48 | set.start(ctx, "B", run("B")) | |
| 47 | 49 | expect("B", ports.CursorChange{Shape: "text"}) | |
| 48 | - | set.setCursor(ports.CursorChange{Hidden: true}) | |
| 50 | + | cursors <- ports.CursorChange{Hidden: true} | |
| 49 | 51 | expect("A", ports.CursorChange{Hidden: true}) | |
| 50 | 52 | expect("B", ports.CursorChange{Hidden: true}) | |
| 51 | 53 | 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 | |||
| 25 | 25 | t.Fatal(err) | |
| 26 | 26 | } | |
| 27 | 27 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 28 | - | set := newOutputSet(context.Background(), nil) | |
| 28 | + | set := newOutputSet(context.Background(), nil, outputRoutes{}) | |
| 29 | 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 | 31 | <-ctx.Done() | |
| 32 | 32 | return nil | |
| 33 | 33 | } | |
| @@ -93,7 +93,7 @@ func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T | |||
| 93 | 93 | ||
| 94 | 94 | func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) { | |
| 95 | 95 | events := make(chan ports.SecurityBackendEvent, 2) | |
| 96 | - | set := newOutputSet(context.Background(), nil) | |
| 96 | + | set := newOutputSet(context.Background(), nil, outputRoutes{}) | |
| 97 | 97 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 98 | 98 | set.nextInstance = ^ports.OutputInstance(0) - 1 | |
| 99 | 99 | 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 nil | 32 | 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) - 1 | 98 | 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) { | |||
| 45 | 45 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 46 | 46 | defer cancel() | |
| 47 | 47 | events := make(chan ports.SecurityBackendEvent, 8) | |
| 48 | - | set := newOutputSet(ctx, nil) | |
| 48 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 49 | 49 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 50 | 50 | defer func() { | |
| 51 | 51 | if err := set.wait(); err != nil { | |
| @@ -66,7 +66,8 @@ func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) { | |||
| 66 | 66 | commands := make(chan []ports.SecurityBackendEvent, 1) | |
| 67 | 67 | closed := make(chan bool, 1) | |
| 68 | 68 | 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 | 71 | defer func() { closed <- closeSuccess }() | |
| 71 | 72 | state := <-security | |
| 72 | 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 | 194 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 194 | 195 | defer cancel() | |
| 195 | 196 | events := make(chan ports.SecurityBackendEvent) // block display after registration | |
| 196 | - | set := newOutputSet(ctx, nil) | |
| 197 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 197 | 198 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 198 | 199 | defer func() { | |
| 199 | 200 | if err := set.wait(); err != nil { | |
| @@ -205,7 +206,8 @@ func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) { | |||
| 205 | 206 | startDone := make(chan bool, 1) | |
| 206 | 207 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 207 | 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 | 211 | for i := range 40 { | |
| 210 | 212 | var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance} | |
| 211 | 213 | if i%2 == 0 { | |
| @@ -285,7 +287,7 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) { | |||
| 285 | 287 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 286 | 288 | defer cancel() | |
| 287 | 289 | events := make(chan ports.SecurityBackendEvent) | |
| 288 | - | set := newOutputSet(ctx, nil) | |
| 290 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 289 | 291 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 290 | 292 | defer func() { | |
| 291 | 293 | if err := set.wait(); err != nil { | |
| @@ -295,7 +297,8 @@ func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) { | |||
| 295 | 297 | startDone := make(chan bool, 1) | |
| 296 | 298 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 297 | 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 | 302 | for i := range 16 { | |
| 300 | 303 | event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance} | |
| 301 | 304 | select { | |
| @@ -343,14 +346,15 @@ func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) { | |||
| 343 | 346 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 344 | 347 | defer cancel() | |
| 345 | 348 | events := make(chan ports.SecurityBackendEvent) | |
| 346 | - | set := newOutputSet(ctx, nil) | |
| 349 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 347 | 350 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 348 | 351 | accepted := make(chan int, 40) | |
| 349 | 352 | ownerDone := make(chan struct{}) | |
| 350 | 353 | startDone := make(chan bool, 1) | |
| 351 | 354 | var ownerEvents chan<- ports.SecurityBackendEvent | |
| 352 | 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 | 358 | defer close(ownerDone) | |
| 355 | 359 | for i := range 40 { | |
| 356 | 360 | 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.SecurityBackendEvent | 68 | 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 := <-security | 72 | 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 registration | 196 | 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.SecurityBackendEvent | 207 | 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.SecurityBackendEvent | 298 | 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.SecurityBackendEvent | 354 | 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) { | |||
| 15 | 15 | ctx, cancel := context.WithCancel(context.Background()) | |
| 16 | 16 | defer cancel() | |
| 17 | 17 | events := make(chan ports.SecurityBackendEvent) | |
| 18 | - | set := newOutputSet(ctx, nil) | |
| 18 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 19 | 19 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 20 | 20 | set.setSecurity(ports.SecurityState{Generation: 7, Protected: true}) | |
| 21 | 21 | started := make(chan ports.OutputInstance, 1) | |
| 22 | 22 | result := make(chan bool, 1) | |
| 23 | 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 | 26 | state := <-security | |
| 26 | 27 | if state != (ports.SecurityState{Generation: 7, Protected: true}) { | |
| 27 | 28 | return errors.New("missing startup protection") | |
| @@ -65,9 +66,9 @@ func TestSecurityRegistrationBeforeStart(t *testing.T) { | |||
| 65 | 66 | func TestSecurityReplacementAndStaleState(t *testing.T) { | |
| 66 | 67 | ctx := context.Background() | |
| 67 | 68 | events := make(chan ports.SecurityBackendEvent, 4) | |
| 68 | - | set := newOutputSet(ctx, nil) | |
| 69 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 69 | 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 | 72 | <-ctx.Done() | |
| 72 | 73 | return nil | |
| 73 | 74 | } | |
| @@ -163,11 +164,11 @@ func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) { | |||
| 163 | 164 | } | |
| 164 | 165 | ||
| 165 | 166 | func TestSecurityInstanceExhaustion(t *testing.T) { | |
| 166 | - | set := newOutputSet(context.Background(), nil) | |
| 167 | + | set := newOutputSet(context.Background(), nil, outputRoutes{}) | |
| 167 | 168 | events := make(chan ports.SecurityBackendEvent, 2) | |
| 168 | 169 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 169 | 170 | 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 | 172 | <-ctx.Done() | |
| 172 | 173 | return nil | |
| 173 | 174 | } | |
| @@ -200,11 +201,11 @@ func TestSecurityInstanceExhaustion(t *testing.T) { | |||
| 200 | 201 | func TestSecurityFullEventsAllowLeaseBarrier(t *testing.T) { | |
| 201 | 202 | ctx, cancel := context.WithCancel(context.Background()) | |
| 202 | 203 | defer cancel() | |
| 203 | - | set := newOutputSet(ctx, nil) | |
| 204 | + | set := newOutputSet(ctx, nil, outputRoutes{}) | |
| 204 | 205 | events := make(chan ports.SecurityBackendEvent, 1) | |
| 205 | 206 | set.wireSecurity(outputChannels{securityEvents: events}) | |
| 206 | 207 | 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 | 209 | <-ctx.Done() | |
| 209 | 210 | return nil | |
| 210 | 211 | }, &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 := <-security | 26 | 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 nil | 73 | 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) - 1 | 170 | 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 nil | 173 | 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.SecurityBackendEvent | 207 | 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 nil | 210 | 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)) | |||
| 342 | 342 | }() | |
| 343 | 343 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | |
| 344 | 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 | 347 | if xdisplay != nil { | |
| 346 | 348 | workers.Add(1) | |
| 347 | 349 | // 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 { | |||
| 142 | 142 | // (clientgesture.go). | |
| 143 | 143 | gesture clientGesture | |
| 144 | 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 | 148 | frameC <-chan time.Time | |
| 147 | 149 | frameTimer ports.Timer | |
| 148 | 150 | // 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 | 352 | // their windows can map. | |
| 351 | 353 | func (c *Core) publishPending(ctx context.Context) error { | |
| 352 | 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 | 361 | return err | |
| 355 | 362 | } | |
| 356 | 363 | c.sent.pending = p | |
| @@ -405,7 +412,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) { | |||
| 405 | 412 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | |
| 406 | 413 | } | |
| 407 | 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 | 416 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | |
| 410 | 417 | if err := c.apply(cfg); err != nil { | |
| 411 | 418 | return nil, err | |
| @@ -442,22 +449,60 @@ func (c *Core) now() time.Time { | |||
| 442 | 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 | 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 | 474 | if c.inputEpochChanged() { | |
| 447 | 475 | return errSecurityChanged | |
| 448 | 476 | } | |
| 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 | |
| 451 | 485 | } | |
| 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 | |
| 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 | 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 | 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 | 1128 | c.syncSecurity() | |
| 1063 | 1129 | defer c.stopFrame() | |
| 1064 | 1130 | defer c.stopCaptureTimer() | |
| @@ -142,7 +142,9 @@ type Core struct { | |||||
| 142 | // (clientgesture.go). | 142 | // (clientgesture.go). | ||
| 143 | gesture clientGesture | 143 | 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.Time | 148 | frameC <-chan time.Time | ||
| 147 | frameTimer ports.Timer | 149 | frameTimer ports.Timer | ||
| 148 | // shots is the snapshot of what the screens show before an action | 150 | // 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 err | 361 | return err | ||
| 355 | } | 362 | } | ||
| 356 | c.sent.pending = p | 363 | 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, err | 418 | 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 errSecurityChanged | 475 | 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 errSecurityChanged | 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 | |||
| 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) { | |||
| 29 | 29 | var state atomic.Value | |
| 30 | 30 | state.Store(ports.SecurityState{}) | |
| 31 | 31 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 32 | - | entered := make(chan struct{}, 1) | |
| 33 | 32 | var calls atomic.Int32 | |
| 34 | 33 | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |
| 35 | 34 | v := state.Load().(ports.SecurityState) | |
| 36 | 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 | 39 | return v | |
| 40 | 40 | }) | |
| 41 | 41 | c.opts.Security = gate | |
| 42 | 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 | 44 | 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}) | |
| 49 | 46 | cmd := reviewReceive(t, commands).(ports.SecurityCommand) | |
| 50 | 47 | if cmd.State != (ports.SecurityState{}) { | |
| 51 | 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 | 51 | t.Fatalf("handler not aborted: %v", err) | |
| 55 | 52 | } | |
| 56 | 53 | if c.security != (ports.SecurityState{}) { | |
| @@ -88,29 +85,26 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) { | |||
| 88 | 85 | var state atomic.Value | |
| 89 | 86 | state.Store(ports.SecurityState{}) | |
| 90 | 87 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 91 | - | checked := make(chan struct{}, 1) | |
| 92 | 88 | var calls atomic.Int32 | |
| 93 | 89 | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |
| 94 | 90 | v := state.Load().(ports.SecurityState) | |
| 95 | 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 | 95 | return v | |
| 99 | 96 | }) | |
| 100 | 97 | c.opts.Security = gate | |
| 101 | - | commands := make(chan ports.ClientCommand) | |
| 98 | + | commands := make(chan ports.ClientCommand, 8) | |
| 102 | 99 | c.ch.Commands = commands | |
| 103 | 100 | spawn := make(chan ports.SpawnRequest, 2) | |
| 104 | 101 | 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) | |
| 109 | 103 | cmd := reviewReceive(t, commands).(ports.SecurityCommand) | |
| 110 | 104 | if _, ok := cmd.Command.(ports.SlotsPending); !ok { | |
| 111 | 105 | t.Fatal(cmd) | |
| 112 | 106 | } | |
| 113 | - | if err := reviewReceive(t, done); err != nil { | |
| 107 | + | if err != nil { | |
| 114 | 108 | t.Fatal(err) | |
| 115 | 109 | } | |
| 116 | 110 | if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) { | |
| @@ -138,26 +132,23 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T | |||
| 138 | 132 | var state atomic.Value | |
| 139 | 133 | state.Store(ports.SecurityState{}) | |
| 140 | 134 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 141 | - | checked := make(chan struct{}, 1) | |
| 142 | 135 | var calls atomic.Int32 | |
| 143 | 136 | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |
| 144 | 137 | v := state.Load().(ports.SecurityState) | |
| 145 | 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 | 142 | return v | |
| 149 | 143 | }) | |
| 150 | 144 | c.opts.Security = gate | |
| 151 | - | commands := make(chan ports.ClientCommand) | |
| 145 | + | commands := make(chan ports.ClientCommand, 8) | |
| 152 | 146 | c.ch.Commands = commands | |
| 153 | 147 | spawn := make(chan ports.SpawnRequest, 1) | |
| 154 | 148 | 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()) | |
| 159 | 150 | reviewReceive(t, commands) | |
| 160 | - | if err := reviewReceive(t, done); err != nil { | |
| 151 | + | if err != nil { | |
| 161 | 152 | t.Fatal(err) | |
| 162 | 153 | } | |
| 163 | 154 | if len(spawn) != 0 || c.placement.anyPending() { | |
| @@ -29,28 +29,25 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) { | |||||
| 29 | var state atomic.Value | 29 | 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.Int32 | 32 | 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 v | 39 | return v | ||
| 40 | }) | 40 | }) | ||
| 41 | c.opts.Security = gate | 41 | 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 = commands | 44 | 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.Value | 85 | 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.Int32 | 88 | 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 v | 95 | return v | ||
| 99 | }) | 96 | }) | ||
| 100 | c.opts.Security = gate | 97 | c.opts.Security = gate | ||
| 101 | - | commands := make(chan ports.ClientCommand) | 98 | + | commands := make(chan ports.ClientCommand, 8) |
| 102 | c.ch.Commands = commands | 99 | c.ch.Commands = commands | ||
| 103 | spawn := make(chan ports.SpawnRequest, 2) | 100 | spawn := make(chan ports.SpawnRequest, 2) | ||
| 104 | c.ch.Spawn = spawn | 101 | 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.Value | 132 | 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.Int32 | 135 | 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 v | 142 | return v | ||
| 149 | }) | 143 | }) | ||
| 150 | c.opts.Security = gate | 144 | c.opts.Security = gate | ||
| 151 | - | commands := make(chan ports.ClientCommand) | 145 | + | commands := make(chan ports.ClientCommand, 8) |
| 152 | c.ch.Commands = commands | 146 | c.ch.Commands = commands | ||
| 153 | spawn := make(chan ports.SpawnRequest, 1) | 147 | spawn := make(chan ports.SpawnRequest, 1) | ||
| 154 | c.ch.Spawn = spawn | 148 | 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 { | |||
| 40 | 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 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. | |
| 47 | 47 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |
| 48 | 48 | synctest.Test(t, func(t *testing.T) { | |
| 49 | 49 | now := claimBase | |
| @@ -52,6 +52,8 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |||
| 52 | 52 | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | |
| 53 | 53 | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | 54 | t.Cleanup(cancel) | |
| 55 | + | c.out.start() | |
| 56 | + | go c.out.run(ctx, commands) | |
| 55 | 57 | done := make(chan error, 1) | |
| 56 | 58 | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() | |
| 57 | 59 | 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 sent | 43 | + | // 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 worker | 44 | + | // 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 command | 45 | + | // 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 := claimBase | 49 | 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() | ||