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>
3.4 KiB
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:
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:
IsOilPumpOn = true— setter writes backing field totrue, then callsOnIsOilPumpOnChanged.- Inside the partial:
dlg.ShowDialog()blocks.SetRelayhas not been called yet, sorelay.Stateis stillfalse. - A refresh tick fires during the nested pump.
RefreshFromTicksees_isOilPumpOn (true) != relayOn (false)and clobbers_isOilPumpOnback tofalse. - Operator clicks Accept.
ShowDialogreturns._bench.SetRelay(OilPump, true)finally runs and commitsrelay.State = true. OnIsOilPumpOnChangedreturns — but_isOilPumpOnis stillfalsefrom step 3.- The caller (
TestsPageViewModel.StartTestAsync) checksif (!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:
_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 —
OnIsOilPumpOnChanged,RefreshFromTick - ViewModels/Pages/TestsPageViewModel.cs —
StartTestAsyncguard that exposed the race - Services/Impl/BenchService.cs —
SetRelay(synchronous update ofrelay.State+ async CAN transmit)