Files
HC_APTBS/docs/gotcha-oil-pump-dialog-race.md
LucianoDev 827b811b39 feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
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>
2026-05-07 13:59:50 +02:00

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:

  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:

_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