Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/Reactor/Hosting/ReconcileHighlightOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,17 @@ private void RefreshOrAdd(UIElement host, UIElement target, CompositionBrush bru
existing.Sprite.Opacity = opacity;
existing.Sprite.Size = size;
existing.Sprite.Offset = offset;
// Dispatcher-queue contract: DispatcherQueueTimer.Stop() does NOT
// dequeue a Tick that was already enqueued before the call. If the
// original interval elapsed concurrently with this refresh, the old
// Tick can fire AFTER Start() and tear down a sprite that the user
// still expects to see. Swap in a fresh timer with its own Tick
// lambda so the old tick — if it fires — finds the active entry
// no longer owns it, and bails (see the identity check below).
try { existing.Timer.Stop(); } catch { }
try { existing.Timer.Start(); } catch { }
var refreshedTimer = CreateExpiryTimer(target, existing);
existing.Timer = refreshedTimer;
refreshedTimer.Start();
Comment on lines 151 to +154
return;
}

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

var entry = new ActiveHighlight { Sprite = sprite };
var timer = CreateExpiryTimer(target, entry);
entry.Timer = timer;
_active[target] = entry;
timer.Start();
newBudget--;
}

private DispatcherQueueTimer CreateExpiryTimer(UIElement capturedTarget, ActiveHighlight owner)
{
var timer = _dispatcherQueue.CreateTimer();
timer.Interval = TimeSpan.FromMilliseconds(_holdDurationMs);
timer.IsRepeating = false;

var capturedTarget = target;
var container = _container;
timer.Tick += (s, _) =>
{
try
{
if (_active.TryGetValue(capturedTarget, out var ah))
// Identity guard: a Refresh between this tick's enqueue and its
// dispatch will have swapped in a new timer; the stale tick must
// not tear down the sprite that the new timer still owns.
if (_active.TryGetValue(capturedTarget, out var ah)
&& ReferenceEquals(ah, owner)
&& ReferenceEquals(ah.Timer, s))
{
try { container.Children.Remove(ah.Sprite); } catch { }
try { ah.Sprite.Dispose(); } catch { }
Expand All @@ -179,10 +202,7 @@ private void RefreshOrAdd(UIElement host, UIElement target, CompositionBrush bru
try { ((DispatcherQueueTimer)s).Stop(); } catch { }
}
};

_active[target] = new ActiveHighlight { Sprite = sprite, Timer = timer };
timer.Start();
newBudget--;
return timer;
}

// ─────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ public override Element Render()

internal class DataGridEditMutation(Harness h) : SelfTestFixtureBase(h)
{
// 60-frame mutation pump + 30-frame drain; same wall-clock-floor reasoning
// as HookPagingFramerateScroll. See INVESTIGATION.md Cluster T2.
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);

public override async Task RunAsync()
{
await Task.Run(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ private async Task RunInner()
/// </summary>
internal class HookPagingFramerateScroll(Harness h) : SelfTestFixtureBase(h)
{
// 60-frame programmatic scroll has a ~3.4 s mandatory Render(ms) wall-clock
// floor; loaded CI runners slow per-frame work 2–4x and trip the default 15 s
// budget without anything being wedged. See INVESTIGATION.md Cluster T2.
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);

public override Task RunAsync() => WithHookPaging(true, RunInner);

private async Task RunInner()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,25 +113,35 @@ public override async Task RunAsync()
H.Check("ScrollPop_ScrollViewerFound", sv is not null);
if (sv is null) return;

// 2. Scroll to a position deep in the list (row ~200)
// 2. Scroll to a position deep in the list (row ~200).
// Baseline wait (preserves the scroll-settle window so the
// ScrollPop_MultipleFetches sanity assertion still triggers fetches),
// then poll cells in case the realization runs long. The original
// fixed 800ms Render flaked as `cells=0` under CI load — see
// INVESTIGATION.md Cluster C.
sv.ChangeView(null, 7200, null, disableAnimation: true);
// Wait for: scroll event → EnsureRangeLoaded → async fetch → settle timer → render
await Harness.Render(800);

// 3. The key assertion: rows at the scroll target should have real data,
// not placeholders. Look for "Emp-" prefix in visible TextBlocks.
var visibleEmpCells = H.FindAllControls<TextBlock>(
tb => tb.Text?.StartsWith("Emp-") == true);
var visibleEmpCells = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
var deadline1 = Environment.TickCount64 + 3_000;
while (visibleEmpCells.Count < 4 && Environment.TickCount64 < deadline1)
{
await Harness.Render(100);
visibleEmpCells = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
}

H.Check($"ScrollPop_DataVisible (cells={visibleEmpCells.Count})",
visibleEmpCells.Count >= 4);
Comment on lines 132 to 133

// 4. Scroll to a completely different position
sv.ChangeView(null, 14400, null, disableAnimation: true);
await Harness.Render(800);

var visibleEmpCells2 = H.FindAllControls<TextBlock>(
tb => tb.Text?.StartsWith("Emp-") == true);
var visibleEmpCells2 = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
var deadline2 = Environment.TickCount64 + 3_000;
while (visibleEmpCells2.Count < 4 && Environment.TickCount64 < deadline2)
{
await Harness.Render(100);
visibleEmpCells2 = H.FindAllControls<TextBlock>(tb => tb.Text?.StartsWith("Emp-") == true);
}

H.Check($"ScrollPop_DataVisibleAfterSecondScroll (cells={visibleEmpCells2.Count})",
visibleEmpCells2.Count >= 4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,11 @@ public override async Task RunAsync()
/// </summary>
internal class PerGroupDropTargetVisualDemo(Harness h) : SelfTestFixtureBase(h)
{
// 20 mount cycles × ~700 ms each on loaded CI runners can overshoot the
// default 15 s budget; the demo is not pathological, just paced for human
// visibility. See INVESTIGATION.md Cluster T1.PerGroup.
public override TimeSpan FixtureTimeout => TimeSpan.FromSeconds(30);

public override async Task RunAsync()
{
var host = H.CreateHost();
Expand Down
Loading