microsoft / microsoft/microsoft-ui-reactor
[Bug] Issue487_SV2* fail deterministically on non-interactive sessions — InteractionTracker completes scrolls without moving
- Dominant language
- C#
- Stars
- 646
- Forks
- 54
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 84
Description
### What happened?`Issue487_SV2ParkedAtBottom` and `Issue487_SV2ClampObserved` fail **deterministically** on non-interactive sessions (CI agents, RDP-disconnected, locked, headless, virtual displays). They are not flaky and they are not a scrolling regression — the fixtures assert a claim the environment cannot evaluate.A third check in the same fixture, `Issue487_SV2OffsetRestoredAfterMutation`, **passes vacuously** in the same conditions.Reproduced independently on two machines (this one and PR #984's), both single virtual displays.### The mechanism — visible in the fixture's own source`tests/Reactor.AppTests.Host/SelfTest/Fixtures/Issue487ScrollAnchorFixtures.cs` contains four fixtures. Exactly one uses the InteractionTracker-backed control, and it is the only one that fails:| line | control | fixture | outcome ||---|---|---|---|| 206 | `ScrollViewer` | `Issue487_ScrollOffsetRestoredAfterRunMutation` | passes || 259 | `ScrollViewer` | `Issue487_RepeatedMutationDoesNotDrift` (`Drift_*`) | passes || **340** | **`ScrollView`** | **`Issue487_ScrollViewOffsetRestoredAfterRunMutation` (`SV2_*`)** | **fails** || 393 | `ScrollViewer` | `Issue487_GenuineUserScrollAfterArmingNotFought` | passes |**Confound check — there are two nominal variables here, not one.** The `ScrollView` fixture also uses a different content builder: `BuildContentScrollView` (L298) rather than `BuildContent` (L56). Diffing them, they are identical except for (1) the wrapping control — `ScrollViewer(` L78 vs `ScrollView(` L316, the intended variable; (2) the button key `"MutateRun"` vs `"MutateRunSV"`, cosmetic; and (3) `BuildContent` sets `HorizontalScrollBarVisibility = Disabled` / `VerticalScrollBarVisibility = Auto` (L83-84) while `BuildContentScrollView` sets neither.
Only (3) could matter on paper, and **it is ruled out by measurement**: the diagnostic shows `extent=655, viewport=240, scrollable=415` on the `ScrollView`. The builder produced a correctly-scrollable tree with correct geometry (655 - 240 = 415). A content-shape confound would surface as *wrong geometry*; the geometry is right. `ScrollView`'s `VerticalScrollBarVisibility` also defaults to `Auto`, so (3) is a no-op regardless.
So the accurate claim is **two nominal variables, one eliminated empirically** — the builder demonstrably yields a scrollable extent, and the only thing that fails is the tracker's ability to move within it.
The file already documents why that split matters, at lines 142-156:> *"the classic ScrollViewer clamps synchronously inside the first layout pass (so this returns on pass 1), but the modern ScrollView is [InteractionTracker-backed]"*Classic `ScrollViewer` keeps offsets as UI-thread state. `ScrollView` is compositor/InteractionTracker-backed. On a session with no realized visual composition, the tracker **acknowledges the scroll request and reports it complete without ever moving**:```# DIAG pre scrollable=415.00 extent=655.00 viewport=240.00 offset=0.00 state=Idle# DIAG ScrollTo returned corr=1# DIAG ScrollCompleted corr=1 offset=0.00# DIAG after ScrollTo offset=0.00 state=Idle completed=1 stateChanges=0# DIAG ScrollBy corr2=2 -> ScrollCompleted corr=2 offset=0.00```**What this rules out:**- **Not a timing race** — `ScrollTo` retried 5× with 720 ms waits (3.6 s total); offset stayed `0.00` every time. Longer waits do nothing.- **Not bad geometry** — `extent 655 − viewport 240 = scrollable 415`, internally consistent, and `Issue487_SV2HasScrollableHeight` passes.- **Not a dropped request** — a valid correlation id is returned and `ScrollCompleted` fires for it.- **Not one API** — `ScrollBy` behaves identically, so it is the whole tracker path.The signature is `ScrollCompleted` firing while `StateChanged` never fires at all: the tracker never enters `Scrolling`/`Animating`. It satisfies the request as a no-op.This is the same family as `CenterOnCurrent_UsesCursorMonitor` / `PersistPlacement_FallbackWhenEmpty` (`GetCursorPos` → `ACCESS_DENIED`, fixed in #971): **an API that silently degrades on a non-interactive session, and a fixture that treats "cannot evaluate" as "failed".**### The vacuous assertion```csharp353: double preOffset = sv.VerticalOffset; // 0.00 — never moved367: H.Check("Issue487_SV2OffsetRestoredAfterMutation",368: Math.Abs(sv.VerticalOffset - preOffset) <= 3.0); // |0 - 0| <= 3, trivially true```This reports **green for the wrong reason**. It is also the one case ordinary mutation testing cannot catch, because it passes in both the healthy and the degraded environment — so it has to be handled structurally, by the same guard, rather than verified behaviourally.### Steps to reproduce1. On a non-interactive session (or any box where `GetCursorPos` returns `ACCESS_DENIED (err 5)` — the two conditions have so far co-occurred), run: `dotnet run --project tests/Reactor.AppTests.Host --no-build -c Debug -p:Platform=x64 -- --self-test --filter Issue487`2. `Issue487_SV2ParkedAtBottom` and `Issue487_SV2ClampObserved` fail every time; `Issue487_Drift_*` pass every time.To confirm the mechanism rather than the symptom, splice this in after the `WaitFor(() => sv.ScrollableHeight > InlineHeight, ...)` at ~L345, replacing the `ScrollTo` + `WaitFor` block:```csharpint completedCount = 0; int stateChanges = 0;sv.ScrollCompleted += (s, e) => { completedCount++; Console.WriteLine($"# DIAG ScrollCompleted corr={e.CorrelationId} offset={s.VerticalOffset:F2}"); };sv.StateChanged += (s, e) => { stateChanges++; Console.WriteLine($"# DIAG StateChanged -> {s.State}"); };Console.WriteLine($"# DIAG pre scrollable={sv.ScrollableHeight:F2} extent={sv.ExtentHeight:F2} " + $"viewport={sv.ViewportHeight:F2} offset={sv.VerticalOffset:F2} state={sv.State}");var corr = sv.ScrollTo(0, sv.ScrollableHeight, noAnim);await Harness.WaitFor(() => sv.VerticalOffset + 0.5 >= sv.ScrollableHeight, maxPasses: 120, perPassMs: 12);Console.WriteLine($"# DIAG after ScrollTo offset={sv.VerticalOffset:F2} state={sv.State} " + $"completed={completedCount} stateChanges={stateChanges}");````ExtentHeight`/`ViewportHeight` earn their place — they rule out bad geometry without a second run.### Suggested fix, and the trap in itSkip when the tracker demonstrably cannot drive, mirroring the fix applied to the cursor pair in #971:```csharpbool trackerInert = completedCount > 0 // the request WAS acknowledged && stateChanges == 0 // ...but never entered Scrolling/Animating && sv.VerticalOffset < 0.5; // ...and nothing moved```**All three conjuncts matter, and the middle one is load-bearing.** `offset unchanged after completion` is on its own *indistinguishable* from a genuine scroll regression — a skip gated on that alone would **swallow the exact bug the fixture exists to catch**, converting a real regression into a silent green. That is strictly worse than today's deterministic failure, which at least announces itself.`stateChanges == 0` discriminates because a real scroll bug would still **transition state** — the tracker enters `Scrolling`/`Animating` and lands wrong, rather than never leaving `Idle`.**Required verification before this is trusted:**- Mutation-test the guard itself: force `stateChanges = 1` and confirm the result is `not ok`, not a skip. This proves the guard cannot swallow a live regression.- Ideally run that on a box where the tracker *does* work. Neither machine that diagnosed this qualifies, which is worth stating plainly.- Fold `Issue487_SV2OffsetRestoredAfterMutation` into the same guard — it cannot be validated behaviourally.### Reactor version / commit`main` as of 2026-07-31. Diagnosed on PR #984's worktree and corroborated on PR #971's; neither branch modifies this fixture.### NotesDiagnostic capture and the guard shape are from PR #984's session. Deliberately filed rather than folded into #971, which is green and awaiting manual merge — and because the guard needs its own mutation verification, which is not work to rush inside a merge window.Related: #971 (same defect class, cursor pair, fixed), #988 (selftest suite over its 300 s watchdog — **not** this; these runs complete with the TAP trailer present).
Contributor guide
Research direction
Start in tests/Reactor.AppTests.Host/SelfTest/Fixtures/Issue487ScrollAnchorFixtures.cs and run the Issue487 self-tests with the provided dotnet command on a non-interactive session. Review the ScrollView InteractionTracker diagnostics and apply the guard consistently to the SV2 checks, including the vacuous offset-restoration check. Verify the guard with mutation testing by forcing stateChanges = 1 and confirming the result is not skipped.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- desktop, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100