|
| 1 | +# Work Order: HighPrecisionTimer Rework (Animation Timing) |
| 2 | + |
| 3 | +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) |
| 4 | +**Files:** |
| 5 | +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs` |
| 6 | +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs` |
| 7 | +- `src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs` |
| 8 | +- Consumer: `src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs` |
| 9 | + |
| 10 | +This work order is self-contained; it results from a code review of the current implementation. |
| 11 | +Read the current sources first, verify each finding against the code as it stands (the branch may have |
| 12 | +moved), then implement. |
| 13 | + |
| 14 | +**Keep as-is (explicitly not up for redesign):** the registration model — per-registration |
| 15 | +`SynchronizationContext` capture, `InFlight` CAS-based frame coalescing with a `DroppedFrames` counter |
| 16 | +surfaced in `HighPrecisionTimerTick`, and the id-based `TimerRegistration` disposable struct |
| 17 | +(double-dispose safe, `default` safe). The *clock/pacing core* is what gets replaced. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## Finding 1 (critical): fixed-cadence pacer + spin causes a sawtooth burning ~50% of a core |
| 22 | + |
| 23 | +Current design: `PeriodicTimer` at 14 ms (60 Hz path), then `SpinToTarget` spins to the 16.667 ms |
| 24 | +frame target with `SpinOnce(sleep1Threshold: -1)` (never sleeps). |
| 25 | + |
| 26 | +`PeriodicTimer` fires on its own **fixed** cadence (14, 28, 42, 56, …) and does not re-phase per |
| 27 | +`WaitForNextTickAsync` call, while frame targets are 16.67, 33.33, 50, 66.67, … The phase slips |
| 28 | +2.67 ms per frame, so spin duration grows every frame — wake 28 → spin 5.3 ms; wake 42 → spin 8 ms; |
| 29 | +wake 56 → spin 10.7 ms — until phases wrap and the sawtooth restarts. Average spin ≈ half a frame |
| 30 | +≈ 8 ms of every 16.67 ms ⇒ ~50% of one core, continuously, for as long as **any** animation is |
| 31 | +registered. Infinite cycles (e.g. a pulsing focus indicator) make this permanent. The 30 Hz path |
| 32 | +(30 ms tick vs 33.33 ms frame) has the identical slip. |
| 33 | + |
| 34 | +**Required fix (architecture, not constant-tuning):** absolute schedule. Due times are |
| 35 | +`epoch + frameIndex * period` against a single clock; wait on a mechanism that can hit them |
| 36 | +(Finding 3), with at most a sub-millisecond residual spin. Overshoot must be amortized against the |
| 37 | +absolute schedule (no `lastTick + period` relative scheduling — that accumulates and runs slow). |
| 38 | + |
| 39 | +## Finding 2: integer-millisecond arithmetic throughout |
| 40 | + |
| 41 | +`stopwatch.ElapsedMilliseconds` (truncated `long`) feeds `lastTickTimestamp`, `elapsed`, the spin |
| 42 | +target, drift detection, and the `Timestamp`/`Elapsed` values delivered to consumers — ±1 ms |
| 43 | +quantization (6% of a 16.667 ms budget) plus systematic truncation bias. Use `ElapsedTicks` / |
| 44 | +`Elapsed.TotalMilliseconds` (double) end to end; `HighPrecisionTimerTick` fields stay `TimeSpan`, |
| 45 | +constructed from ticks. |
| 46 | + |
| 47 | +## Finding 3: replace `timeBeginPeriod(1)` with a high-resolution waitable timer |
| 48 | + |
| 49 | +The code gates on `windows10.0.17134` — which `timeBeginPeriod` (ancient) does not need, but which is |
| 50 | +exactly the build (1803) that introduced `CreateWaitableTimerExW` with |
| 51 | +`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`. Use it: |
| 52 | + |
| 53 | +- absolute due times (negative-relative or absolute FILETIME) with sub-ms accuracy, |
| 54 | +- no process-wide timer-resolution raise (current code holds 1 ms resolution for the entire lifetime |
| 55 | + of any animation — a documented power/battery anti-pattern, and post-Win11 the effective resolution |
| 56 | + changes for occluded windows, silently shifting the timing floor), |
| 57 | +- composes directly with Finding 1's absolute schedule and eliminates the spin loop almost entirely. |
| 58 | + |
| 59 | +Fallback below 17134: keep a coarse path (30 Hz, plain waits, no `timeBeginPeriod`) — document that |
| 60 | +sub-frame precision is not attempted there. Remove `TimeBeginPeriod`/`TimeEndPeriod` P/Invokes if no |
| 61 | +longer referenced. |
| 62 | + |
| 63 | +## Finding 4: `Register`/`Unregister` vs `StopTimer` race strands registrations |
| 64 | + |
| 65 | +`Unregister` does `TryRemove` → `IsEmpty?` → `StopTimer()` without coordinating with `Register`. |
| 66 | +Interleaving: A removes the last entry and observes empty; B adds a registration and `EnsureRunning` |
| 67 | +sees `s_loopTask != null` (still running) and returns; A stops the timer. Result: live registration, |
| 68 | +dead timer — animation frozen until an unrelated `Register` restarts the loop. |
| 69 | +**Fix:** perform the emptiness check + stop decision under `s_lock` together with loop-state |
| 70 | +transitions, or introduce a generation counter that `StopTimer` validates before actually stopping. |
| 71 | + |
| 72 | +## Finding 5: per-frame, per-registration closure allocations on the hot path |
| 73 | + |
| 74 | +`SyncContext.Post(_ => _ = InvokeCallbackAsync(registration, tick, cancellationToken), null)` |
| 75 | +allocates closure + delegate per registration per frame (60 Hz × N renderers of steady GC pressure). |
| 76 | +**Fix:** one cached `static SendOrPostCallback`; pass state via a per-registration state object — |
| 77 | +`InFlight` guarantees exclusivity, so tick data can be written into a reusable per-registration slot |
| 78 | +before posting. Target: zero allocations per frame in steady state. |
| 79 | + |
| 80 | +## Finding 6: drift `Debug.Assert` is an assert storm |
| 81 | + |
| 82 | +Drift >20% for 10 frames is normal under a debugger, breakpoints, or CI load. Once tripped, |
| 83 | +`consecutiveDriftFrames` keeps incrementing, so the assert fires **every subsequent frame**. |
| 84 | +**Fix:** replace with tracing/EventSource counters (drift, dropped frames, spin time). If any assert |
| 85 | +remains, reset the counter after firing once. |
| 86 | + |
| 87 | +## Finding 7: lifecycle edges |
| 88 | + |
| 89 | +- `StopTimer` never observes `s_loopTask`; a stop/start pair can transiently run two loops, and a |
| 90 | + stopping loop can dispatch one final frame with a canceled token. Decide and document: either join |
| 91 | + the old loop (bounded) or make late dispatch provably benign. |
| 92 | +- Post-unregister ticks can still be in flight toward a disposed consumer; `AnimationManager` / |
| 93 | + `AnimatedControlRenderer` must tolerate late callbacks — add a test. |
| 94 | +- `Reset()` (test hook) mutates state without locking — document the serialization requirement or |
| 95 | + lock it. |
| 96 | +- Dead code: `Registration.Id` is never read. |
| 97 | +- `InvokeCallbackAsync` swallows non-OCE exceptions with `Debug.Fail` and keeps invoking the same |
| 98 | + callback forever; consider auto-unregistering a registration after N consecutive faults. |
| 99 | + |
| 100 | +## Finding 8: single-SyncContext funnel in `AnimationManager` (design note) |
| 101 | + |
| 102 | +`HighPrecisionTimer` correctly captures a `SynchronizationContext` **per registration**, but |
| 103 | +`AnimationManager` is a process-wide singleton with a single registration, so every animation in a |
| 104 | +multi-message-loop application marshals to the first UI thread — and dies with it. Minimum: document |
| 105 | +the constraint. Better: make the manager per-UI-thread (e.g. `[ThreadStatic]` instance keyed off the |
| 106 | +message-loop thread), preserving one timer-registration-per-UI-thread. Also note the duplicate |
| 107 | +timeline: the manager keeps its own `Stopwatch` instead of deriving progress from |
| 108 | +`HighPrecisionTimerTick.Timestamp/Elapsed`; animation progress should use the tick's timeline so frame |
| 109 | +coalescing (`DroppedFrames`) is accounted for consistently. |
| 110 | + |
| 111 | +## Finding 9: 60 Hz is a settled ceiling; the period is a pacer-owned runtime value for *downshift* |
| 112 | + |
| 113 | +**Decision (do not relitigate):** the timer targets a hard 60 Hz ceiling (30 Hz fallback). Do not |
| 114 | +add refresh-rate matching or raise the cadence. Rationale, for the record: |
| 115 | + |
| 116 | +- Under DWM, windowed GDI/GDI+ apps do not tear (composition is tear-free from the redirection |
| 117 | + surface); the only artifact of an unsynced 60 Hz timer is judder, which is imperceptible for this |
| 118 | + content class (focus pulses, hover fades, toggle transitions — not motion/scrolling). |
| 119 | +- GDI+ raster cost scales linearly with rate; driving N animated controls at 120–144 Hz multiplies |
| 120 | + compute for no perceptible gain (e.g. batched MVVM-driven updates across many controls). |
| 121 | +- A process-wide timer cannot refresh-match on mixed-rate multi-monitor setups ("the" refresh rate |
| 122 | + is ill-defined), and `DwmFlush`-style vblank pacing blocks per frame, binds to one monitor, and |
| 123 | + misbehaves under RDP — unfit for a process-wide UI timer by construction. |
| 124 | + |
| 125 | +**Required now (structural):** the frame period must be a runtime value owned by the pacer |
| 126 | +(queryable/settable internally), not compile-time constants woven through the loop. The 30 Hz |
| 127 | +fallback already makes the period variable; the forward-looking motivation is **downshifting**, not |
| 128 | +matching: future power-driven reductions (30 Hz or full pause for occluded/minimized windows — where |
| 129 | +Windows 11 timer coalescing already alters the effective cadence — and battery-saver scenarios) |
| 130 | +must be addable without another rework. |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## Acceptance criteria |
| 135 | + |
| 136 | +1. Steady-state CPU of the timer loop with one registered infinite animation: **< 2% of one core** |
| 137 | + (measure; the current implementation is the ~50% baseline per Finding 1). |
| 138 | +2. No `timeBeginPeriod` while animations run (verify via `powercfg /energy` or timer-resolution |
| 139 | + query on a Win10 1803+ box). |
| 140 | +3. Frame delivery: mean interval within ±0.5 ms of target over a 10 s run on an idle machine at |
| 141 | + 60 Hz; no monotonic slow drift (absolute-schedule check: 600th frame due time within one frame of |
| 142 | + `epoch + 600 × period`). |
| 143 | +4. Zero per-frame heap allocations in steady state (verify with an allocation-tracking test or |
| 144 | + `GC.GetAllocatedBytesForCurrentThread` bracketing). |
| 145 | +5. Race test for Finding 4: concurrent register/unregister stress leaves no registration without a |
| 146 | + running loop. |
| 147 | +6. Existing `HighPrecisionTimerTests` updated/extended accordingly; late-callback tolerance test for |
| 148 | + consumers (Finding 7). |
| 149 | +7. `HighPrecisionTimerTick` surface unchanged (internal consumers depend on it); all other churn is |
| 150 | + internal to the pacer. |
| 151 | + |
| 152 | +## Constraints |
| 153 | + |
| 154 | +- Everything stays `internal`; no public API review implications. |
| 155 | +- Follow repo conventions (`LibraryImport`, nullable enabled, existing XML-doc voice). |
| 156 | +- Windows-only paths gated with `[SupportedOSPlatform]` / `OperatingSystem.IsWindowsVersionAtLeast` |
| 157 | + as currently practiced in the file. |
0 commit comments