Bundles several feature streams that have been iterating on the working tree: - Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the identification card, manual KWP write + transaction log, ROM/EEPROM dump card with progress banner and completion message, persisted custom-commands library, persisted EEPROM passwords library. New service primitives: IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync. Persistence mirrors the Clients XML pattern in two new files (custom_commands.xml, eeprom_passwords.xml). - Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and progress dialog VM, gated on dashboard alarms. - BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at 0x0106 via IKwpService.ReadBipStatusAsync; status definitions in BipStatusDefinition. - Tests page redesign: TestSectionCard + PhaseTileView replacing the old TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/ TestSectionView controls and their VMs. - Pump command sliders: Fluent thick-track style with overhang thumb, click-anywhere-and-drag, mouse-wheel adjustment. - Window startup: app.manifest declares PerMonitorV2 DPI awareness, MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and maximizes there (after the hook is in place) so the app fits the work area exactly on any display configuration. - Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and dump-functions reference docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
59 lines
3.4 KiB
Markdown
59 lines
3.4 KiB
Markdown
# 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)
|