Skip to content

Commit aab59c3

Browse files
Stress fixes: framerate/PerGroup timeouts, overlay tick race, DataGrid scroll poll (#397)
Three independent stress-flake fixes (Clusters T, O, C) per INVESTIGATION.md follow-up to PR #396: Cluster T — bump FixtureTimeout to 30s for three render-pump-heavy fixtures whose budgets are tight on loaded CI runners but not pathological: - DataGridParityFixtures.HookPagingFramerateScroll - AsyncResourceFramerateFixtures.DataGridEditMutation - NativeDockingSmokeFixture.PerGroupDropTargetVisualDemo Cluster O — fix race in ReconcileHighlightOverlay.RefreshOrAdd. DispatcherQueueTimer.Stop() can't dequeue a Tick that the dispatcher queue has already dispatched, so a stale tick can tear down a sprite the refresh still wants alive. Now each refresh swaps in a fresh timer with its own Tick lambda; the stale tick checks ReferenceEquals on ah.Timer and bails when its identity no longer matches. Cluster C — DataGrid_ScrollPopulatesData was relying on a fixed 800ms Render(800) for the scroll-settle → fetch → realization chain (not tracked by _renderPending). Now waits 800ms baseline (preserves the fetch-trigger window so ScrollPop_MultipleFetches still triggers) then polls cells for up to 3s. Validated locally: all 27 OverlayLifecycle_* fixtures pass, all 5 ScrollPop_* sub-checks pass, 15x local stress on both fixtures together is clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9b33e44 commit aab59c3

5 files changed

Lines changed: 61 additions & 17 deletions

File tree

src/Reactor/Hosting/ReconcileHighlightOverlay.cs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,17 @@ private void RefreshOrAdd(UIElement host, UIElement target, CompositionBrush bru
141141
existing.Sprite.Opacity = opacity;
142142
existing.Sprite.Size = size;
143143
existing.Sprite.Offset = offset;
144+
// Dispatcher-queue contract: DispatcherQueueTimer.Stop() does NOT
145+
// dequeue a Tick that was already enqueued before the call. If the
146+
// original interval elapsed concurrently with this refresh, the old
147+
// Tick can fire AFTER Start() and tear down a sprite that the user
148+
// still expects to see. Swap in a fresh timer with its own Tick
149+
// lambda so the old tick — if it fires — finds the active entry
150+
// no longer owns it, and bails (see the identity check below).
144151
try { existing.Timer.Stop(); } catch { }
145-
try { existing.Timer.Start(); } catch { }
152+
var refreshedTimer = CreateExpiryTimer(target, existing);
153+
existing.Timer = refreshedTimer;
154+
refreshedTimer.Start();
146155
return;
147156
}
148157

@@ -157,17 +166,31 @@ private void RefreshOrAdd(UIElement host, UIElement target, CompositionBrush bru
157166
sprite.Brush = brush;
158167
_container.Children.InsertAtTop(sprite);
159168

169+
var entry = new ActiveHighlight { Sprite = sprite };
170+
var timer = CreateExpiryTimer(target, entry);
171+
entry.Timer = timer;
172+
_active[target] = entry;
173+
timer.Start();
174+
newBudget--;
175+
}
176+
177+
private DispatcherQueueTimer CreateExpiryTimer(UIElement capturedTarget, ActiveHighlight owner)
178+
{
160179
var timer = _dispatcherQueue.CreateTimer();
161180
timer.Interval = TimeSpan.FromMilliseconds(_holdDurationMs);
162181
timer.IsRepeating = false;
163182

164-
var capturedTarget = target;
165183
var container = _container;
166184
timer.Tick += (s, _) =>
167185
{
168186
try
169187
{
170-
if (_active.TryGetValue(capturedTarget, out var ah))
188+
// Identity guard: a Refresh between this tick's enqueue and its
189+
// dispatch will have swapped in a new timer; the stale tick must
190+
// not tear down the sprite that the new timer still owns.
191+
if (_active.TryGetValue(capturedTarget, out var ah)
192+
&& ReferenceEquals(ah, owner)
193+
&& ReferenceEquals(ah.Timer, s))
171194
{
172195
try { container.Children.Remove(ah.Sprite); } catch { }
173196
try { ah.Sprite.Dispose(); } catch { }
@@ -179,10 +202,7 @@ private void RefreshOrAdd(UIElement host, UIElement target, CompositionBrush bru
179202
try { ((DispatcherQueueTimer)s).Stop(); } catch { }
180203
}
181204
};
182-
183-
_active[target] = new ActiveHighlight { Sprite = sprite, Timer = timer };
184-
timer.Start();
185-
newBudget--;
205+
return timer;
186206
}
187207

188208
// ─────────────────────────────────────────────────────────────────────

tests/Reactor.AppTests.Host/SelfTest/Fixtures/AsyncResourceFramerateFixtures.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ public override Element Render()
417417

418418
internal class DataGridEditMutation(Harness h) : SelfTestFixtureBase(h)
419419
{
420+
// 60-frame mutation pump + 30-frame drain; same wall-clock-floor reasoning
421+
// as HookPagingFramerateScroll. See INVESTIGATION.md Cluster T2.
422+
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);
423+
420424
public override async Task RunAsync()
421425
{
422426
await Task.Run(() =>

tests/Reactor.AppTests.Host/SelfTest/Fixtures/DataGridParityFixtures.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ private async Task RunInner()
262262
/// </summary>
263263
internal class HookPagingFramerateScroll(Harness h) : SelfTestFixtureBase(h)
264264
{
265+
// 60-frame programmatic scroll has a ~3.4 s mandatory Render(ms) wall-clock
266+
// floor; loaded CI runners slow per-frame work 2–4x and trip the default 15 s
267+
// budget without anything being wedged. See INVESTIGATION.md Cluster T2.
268+
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);
269+
265270
public override Task RunAsync() => WithHookPaging(true, RunInner);
266271

267272
private async Task RunInner()

tests/Reactor.AppTests.Host/SelfTest/Fixtures/DataGridScrollFixtures.cs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,35 @@ public override async Task RunAsync()
113113
H.Check("ScrollPop_ScrollViewerFound", sv is not null);
114114
if (sv is null) return;
115115

116-
// 2. Scroll to a position deep in the list (row ~200)
116+
// 2. Scroll to a position deep in the list (row ~200).
117+
// Baseline wait (preserves the scroll-settle window so the
118+
// ScrollPop_MultipleFetches sanity assertion still triggers fetches),
119+
// then poll cells in case the realization runs long. The original
120+
// fixed 800ms Render flaked as `cells=0` under CI load — see
121+
// INVESTIGATION.md Cluster C.
117122
sv.ChangeView(null, 7200, null, disableAnimation: true);
118-
// Wait for: scroll event → EnsureRangeLoaded → async fetch → settle timer → render
119123
await Harness.Render(800);
120-
121-
// 3. The key assertion: rows at the scroll target should have real data,
122-
// not placeholders. Look for "Emp-" prefix in visible TextBlocks.
123-
var visibleEmpCells = H.FindAllControls<TextBlock>(
124-
tb => tb.Text?.StartsWith("Emp-") == true);
124+
var visibleEmpCells = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
125+
var deadline1 = Environment.TickCount64 + 3_000;
126+
while (visibleEmpCells.Count < 4 && Environment.TickCount64 < deadline1)
127+
{
128+
await Harness.Render(100);
129+
visibleEmpCells = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
130+
}
125131

126132
H.Check($"ScrollPop_DataVisible (cells={visibleEmpCells.Count})",
127133
visibleEmpCells.Count >= 4);
128134

129135
// 4. Scroll to a completely different position
130136
sv.ChangeView(null, 14400, null, disableAnimation: true);
131137
await Harness.Render(800);
132-
133-
var visibleEmpCells2 = H.FindAllControls<TextBlock>(
134-
tb => tb.Text?.StartsWith("Emp-") == true);
138+
var visibleEmpCells2 = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
139+
var deadline2 = Environment.TickCount64 + 3_000;
140+
while (visibleEmpCells2.Count < 4 && Environment.TickCount64 < deadline2)
141+
{
142+
await Harness.Render(100);
143+
visibleEmpCells2 = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
144+
}
135145

136146
H.Check($"ScrollPop_DataVisibleAfterSecondScroll (cells={visibleEmpCells2.Count})",
137147
visibleEmpCells2.Count >= 4);

tests/Reactor.AppTests.Host/SelfTest/Fixtures/NativeDockingSmokeFixture.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,11 @@ public override async Task RunAsync()
10021002
/// </summary>
10031003
internal class PerGroupDropTargetVisualDemo(Harness h) : SelfTestFixtureBase(h)
10041004
{
1005+
// 20 mount cycles × ~700 ms each on loaded CI runners can overshoot the
1006+
// default 15 s budget; the demo is not pathological, just paced for human
1007+
// visibility. See INVESTIGATION.md Cluster T1.PerGroup.
1008+
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);
1009+
10051010
public override async Task RunAsync()
10061011
{
10071012
var host = H.CreateHost();

0 commit comments

Comments
 (0)