# Gotcha: Oil-pump confirmation dialog vs. `RefreshFromTick` race ## Symptom On the Tests page, pressing **Start Test** shows the oil-pump leak-check dialog. After the operator clicks **Accept**, the tests **do not start** — the operator has to press **Start Test** a second time. The second press works. ## Why it happens `BenchControlViewModel.OnIsOilPumpOnChanged` uses `dlg.ShowDialog()`, which runs a **nested dispatcher message pump** on the UI thread. While that pump is draining, the `MainViewModel` refresh timer keeps ticking and calls `BenchControlViewModel.RefreshFromTick()`, which reads the relay state from config and writes it back into the `_isOilPumpOn` backing field: ```csharp bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State; if (_isOilPumpOn != relayOn) { _isOilPumpOn = relayOn; OnPropertyChanged(nameof(IsOilPumpOn)); } ``` The ordering on the first press is: 1. `IsOilPumpOn = true` — setter writes backing field to `true`, then calls `OnIsOilPumpOnChanged`. 2. Inside the partial: `dlg.ShowDialog()` blocks. **`SetRelay` has not been called yet, so `relay.State` is still `false`.** 3. A refresh tick fires during the nested pump. `RefreshFromTick` sees `_isOilPumpOn (true) != relayOn (false)` and **clobbers `_isOilPumpOn` back to `false`**. 4. Operator clicks Accept. `ShowDialog` returns. `_bench.SetRelay(OilPump, true)` finally runs and commits `relay.State = true`. 5. `OnIsOilPumpOnChanged` returns — but `_isOilPumpOn` is still `false` from step 3. 6. The caller (`TestsPageViewModel.StartTestAsync`) checks `if (!Root.BenchControl.IsOilPumpOn) return;` — guard trips, **test never starts**. On the second press, `relay.State` is already `true`, so `RefreshFromTick` is a no-op during the second dialog and the flow completes. ## The fix After `SetRelay` commits the real state at the bottom of `OnIsOilPumpOnChanged`, re-assert the backing field: ```csharp _bench.SetRelay(RelayNames.OilPump, value); if (_isOilPumpOn != value) { _isOilPumpOn = value; OnPropertyChanged(nameof(IsOilPumpOn)); } ``` Writing through the backing field (not the setter) avoids re-triggering the confirmation dialog. ## General lesson — nested message pumps **Any `ShowDialog()` call is a nested dispatcher pump.** While it blocks, timers, CAN callbacks marshalled to the UI thread, and property-change handlers keep running. Mutable state that other handlers may "correct" based on transient external readings can be rewritten under you before your synchronous code resumes. When mixing a modal dialog with a periodic state-sync task: - Either **suspend the sync task** while the dialog is open, or - **Re-assert local state after the dialog returns** once the ground truth (relay, register, etc.) has actually been committed. Symptoms of this class of bug: - An operation "works the second time but not the first" - A property setter appears to silently revert - A guard on a property right after a dialog accept evaluates the opposite of what the user chose ## Files involved - [ViewModels/BenchControlViewModel.cs](../ViewModels/BenchControlViewModel.cs) — `OnIsOilPumpOnChanged`, `RefreshFromTick` - [ViewModels/Pages/TestsPageViewModel.cs](../ViewModels/Pages/TestsPageViewModel.cs) — `StartTestAsync` guard that exposed the race - [Services/Impl/BenchService.cs](../Services/Impl/BenchService.cs) — `SetRelay` (synchronous update of `relay.State` + async CAN transmit)