Skip to content

v2026.8.28 - Reliability, validation & housekeeping (596 tests, warning-free build) - #158

Open
nokkies wants to merge 20 commits into
masterfrom
app-improvement-suggestions-2cf19
Open

v2026.8.28 - Reliability, validation & housekeeping (596 tests, warning-free build)#158
nokkies wants to merge 20 commits into
masterfrom
app-improvement-suggestions-2cf19

Conversation

@nokkies

@nokkies nokkies commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Comprehensive housekeeping pass based on three full code reviews (core, UI, infra/CI) plus repo hygiene, built on the branch's original keyboard-shortcuts and trend-monitoring work. Every actionable finding was addressed - fixed at root cause, or deliberately kept with a documented rationale.

User-visible changes

  • Unresponsive devices no longer freeze the app - TCP reads/writes have a bounded 5 s transport timeout and bounded waits everywhere (previously infinite; a stuck device hung every operation, disconnect, and app exit)
  • Failed polls are visible - a non-answering device used to be reported as a successful blank read; errors now show in the status bar and the per-area failure counter
  • Auto-reconnect is reliable - reconnects at the configured interval after a drop; resource leak in the reconnect loop fixed
  • Script rules now actually trigger - numeric Equals/NotEquals comparisons always evaluated false (.NET type boxing); reentrancy guard stops double-firing during slow reads
  • Server mode is spec-compliant - unknown Unit IDs rejected with a clear error (previously returned the primary unit's data silently), broadcast (unit 0) writes apply to all units with no response, FC05 values validated, out-of-range writes report clearly
  • Input validation before connect - invalid IP/hostname, port, unit ID or server unit-ID list is flagged immediately instead of failing cryptically
  • Frame inspector - RTU frames no longer misclassified as TCP; frame timing correct under concurrent logging
  • UI polish - tab visibility on feature toggle, duplicate Frame Inspector menu entry removed, console/debug clear buttons and bounded history, clean shutdown
  • Headless (Linux) - reconnects on connection loss, refuses to start with invalid configuration, correct log path
  • Keyboard shortcuts and trend auto-monitoring (original branch scope)

CI / release pipeline

  • release.yml: version-agreement gate (tag must match the of all three csproj files), fatal CalVer check, 90 min timeout, per-ref concurrency, headless test suite before publish, code-signing fixed (64-bit signtool, HTTPS timestamp, signs the headless exe too), Inno pinned to 6.4.1
  • all workflows: GitHub Actions pinned to commit SHAs (supply chain)
  • ci.yml / avalonia.yml: full-solution build plus core, headless and Avalonia suites on windows and ubuntu (core suite is net8.0 now, so it finally runs on Linux CI)
  • scripts hardened: create_release.ps1 (token from environment, git -C, real failure handling, 3-csproj version gate), build.ps1 (, throws on missing iscc, version agreement), publish-avalonia.ps1 (iscc PATH fallback), ModbusForge.iss (MinVersion 10.0.0.0, version synced)
  • AI_RELEASE_WORKFLOW.md rewritten to match what the pipeline actually does

Code

  • MainViewModel (4,370 lines) split into seven responsibility-scoped partial files - behavior unchanged, member movement verified with a Roslyn extractor; dead legacy dual-state properties removed
  • Core root-cause fixes: retry classification by exception type (previously retried on message text), PollingEngine stop margin, TagService rollback restores in-place mutations, typed failure paths in ScriptRunner / chunked executor, multi-unit server spec compliance, server-mode address/unit validation
  • Tests: 585 -> 596 (new FlaUI smoke project ModbusForge.UITests); suites use real loopback ports, never 0.0.0.0, and no longer touch the user's %APPDATA% profiles; tautologies removed
  • Docs: stale planning docs archived, AGENTS.md TFMs corrected

Verification

  • dotnet build: 0 warnings, 0 errors
  • 596/596 tests (511 core + 34 Avalonia + 45 Headless + 6 UI smoke)
  • Manual GUI pass by the owner: server mode, grid write round-trip, unit-ID switching, dead-device read, input validation, clean shutdown

Notes

  • Version bumped to 2026.8.28 in all three projects; no tag pushed - after merge, tag v2026.8.28 to trigger the release pipeline
  • The Modbus UI 0/1 address alias is deliberately unchanged (no behavior change for existing users)

@nokkies

nokkies commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Code review:

  • The .gitignore rewrite drops too many protections (.vs/, *.suo, *.user, publish/, installers/, etc.) and could let build/IDE artifacts back into the repo. I'd revert the .gitignore change or only add the specific patterns you need.

  • MainWindow.axaml: 3 of the 6 new shortcuts reference non-existent commands:

    • AddCustomBulkEntryCommand should be AddBulkCustomEntryCommand
    • OpenDeviceScannerWindowCommand does not exist
    • ToggleMonitoringCommand does not exist
      Only ToggleConnectionCommand, OpenWatchWindowCommand, and AddCustomEntryCommand are valid.
  • CustomEntry.Trend auto-enabling Monitor is a UX change. It may be intentional, but it should be documented in the PR.

  • Trend = false initializers in MainViewModel are redundant.

  • The PR title/body are auto-generated and don't describe the changes.

@nokkies nokkies changed the title Update from task 0beee97e-5345-4cdc-ad48-99f41f72cf19 Add keyboard shortcuts and trend auto-monitor Aug 13, 2026
@nokkies

nokkies commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

I checked out the branch and fixed the blockers:

  • .gitignore restored to the original comprehensive file.
  • MainWindow.axaml key bindings corrected:
    • Ctrl+B now binds to the existing AddBulkCustomEntryCommand.
    • Removed Ctrl+D and Ctrl+M because the matching commands don't exist in MainViewModel.
  • Build and all tests pass.

I kept the CustomEntry.Trend auto-enabling Monitor and the redundant Trend = false initializers. If you want those adjusted too, let me know.

nokkies added 15 commits August 15, 2026 18:51
…ap links

- Move 8 obsolete docs (v2.x improvement notes, unexecuted coordinator
  refactoring plans, v3.4.x one-shot release instructions, completed
  Unit ID isolation plan, obsolete metallic theme guide, stale feature
  roadmap) into docs/archive/ with an ARCHIVE.md index explaining each
- Point README roadmap links at the living VERSIONED_ROADMAP.md
- Fix README versioning section (CalVer, not semver) and stale ISCC example version
- UpdateService: guard nullable ReleaseUrl assignment (CS8601)
- ModbusFrameLog: drop unused INotifyPropertyChanged/PropertyChanged
  event (CS0067) - frames are immutable once logged, the owning
  ObservableCollection notifies the UI
- VisualNodeEditorViewModel.ConnectionLine: explicitly initialize
  PathFigure.Segments/PathGeometry.Figures (CS8602) instead of relying
  on lazy null collections

Solution now builds with 0 warnings; 482/482 tests pass.
…e version

From the UI/ViewModel code review (batch A):
- Marshal LastErrorTime/HasConnectionError writes that ran on the poll
  thread (HandleAreaReadFailureAsync, ClearMonitorFailure) to the UI
  dispatcher - fixes cross-thread PropertyChanged on first read failure
- PromptAddress (write path) now enforces the 0..65535 Modbus address
  range via shared ModbusAddressValidator constants, with status feedback
- Project metadata Version no longer hardcodes 2026.7.24; reads the
  executing assembly version so it cannot drift
- ConnectionManagerViewModel.BaudRates is now static readonly (was
  allocating a sorted list on every ItemsSource access)
- Remove dead _fallbackConsoleMessages field, dead
  AdvancedFunctions_Click/DeviceScanner_Click handlers in MainView
  (live copies live in MainWindow code-behind) + orphaned usings
- VisualNodeEditorView pan start point: drop the duplicate assignment
  that would NRE when the canvas scroll viewer is null

0 build warnings; 482/482 tests pass.
… harden startup

From the headless/tests/CI code review:
- HeadlessPollingService: detect connection loss (IsConnected or 3
  consecutive failed reads) and reconnect with backoff instead of
  zombie-polling forever; backoff configurable via Polling:ReconnectBackoffMs
- HeadlessCustomService: same reconnect handling; stop the host
  (StopApplication) when the watch file is missing/empty or malformed
  instead of idling forever; guard shutdown disconnects
- Program: reject unknown -- options and options missing values;
  validate Polling:Count/IntervalMs/StartAddress and Custom:TickMs
  at startup; case-insensitive Serilog level parsing (incl. Trace);
  resolve relative log paths against the content root
- HeadlessProfileFactory: headless MQTT ClientId defaults to
  ModbusForge-Headless (matches shipped appsettings and help text)
- New ModbusForge.Headless.Tests project (net8.0, 45 tests): profile
  factory transport/defaults, argument + config validation, log level
  parsing, polling reconnect behavior, and custom-watch failure paths
  (the headless project previously had zero test coverage)
- ModbusTcpService: bound socket + transport I/O at 5s (NModbus defaults to
  infinite, so a silent device hung every operation holding the I/O lock);
  disable NModbus transport-level retries (TCP retransmits at protocol level;
  a dead peer now fails in ~5s instead of ~21s and blocks the poll queue);
  dispose previous client/socket on reconnect (leak fix); chunk coil and
  discrete reads >2000 points instead of throwing; single writes now
  rethrow SlaveException/IO failures instead of silently reporting success
- ModbusSerialService: same chunking fix for bit areas; single writes rethrow;
  timeouts promoted to a named constant
- PollingEngine: a null read (no device response) is now surfaced as an error
  instead of a successful empty result
- ScriptRunner: null reads reported as command failure, not (success, null)
- ScriptRuleService: Equals/NotEquals now compare semantically (boxed ushort vs
  parsed double never matched before); reentrancy guard so overlapping timer
  ticks cannot double-fire a rule action; dispose-safe evaluation; named timer
  constant
- ModbusServerService: reads for an unconfigured unit ID no longer silently
  return the primary unit's data
- ConnectionManager: RemoveProfile disconnects synchronously before disposing
  (no more fire-and-forget race); implements IDisposable so remaining profile
  services are released on shutdown
- MqttGatewayService: gated client creation vs disconnect (reconnect no longer
  leaks a client after disconnect); bounded synchronous dispose instead of
  fire-and-forget; named reconnect-delay constant
- RetryPolicyService: Random.Shared (was per-instance, non-thread-safe),
  overflow-safe exponential backoff, jitter range clamped (Next(0,0) could
  throw)
- CircuitBreakerService: exactly one half-open recovery probe at a time (was
  every concurrent caller); probe failure reopens for a fresh period
- ApiApplicationService: GetStatus no longer sync-over-async blocks the API
  thread; interface/callers/tests updated to GetStatusAsync
- TagService.DeleteGroupAsync: rollback now restores in-place re-parenting
  mutations (list snapshots shared object references) and root groups from a
  pre-mutation snapshot; MovedTagCount no longer counts tags that were not
  actually relocated
- DeviceIdentificationReader: FC43 MoreFollows pagination with a 16-transaction
  safety cap (long vendor names pushed other objects into follow-up responses)
- Tests: regression coverage for the TCP timeout fix, PollingEngine error
  surfacing, and ScriptRuleService value comparison
- ModbusMultiUnitServer: validate the MBAP protocol ID (0x0000) and close
  non-compliant connections (the field was read but never checked)
- FC05 now strictly accepts only 0xFF00/0x0000; any other 16-bit value
  returns Illegal Data Value instead of silently writing OFF
- Unit ID 0 (broadcast) writes are applied to every configured unit's data
  store and receive NO response, per the Modbus spec (previously a data
  store was created for unit 0 and answered); broadcast reads are ignored
- Cap FC43 object payload at 246 bytes so the response PDU stays within
  the 253-byte protocol limit (250 would produce an oversize frame)
- Reject new connections beyond MaxClients (10) and drop clients that go
  idle for 10 minutes (per-request read timeout); active client count is
  tracked so rejected/closed clients release their slot
- FC processing errors log at Warning instead of Debug
- FC23: client (TCP + serial) and server services now enforce the spec's
  121-register write cap (was FC16's 123)
- Tests: protocol-compliance suite for FC05 strict values, broadcast
  semantics, and MBAP protocol ID rejection
- LoggingStreamResource: parse Unit ID / function code deterministically per
  transport type (the old heuristic treated any frame with raw[2..3]==0x00 as
  an MBAP header and misparsed legitimate RTU frames, e.g. reads at low
  addresses); construction sites now pass their TransportType
- ModbusFrameLogger: delta-timestamp update and ring-buffer insert now happen
  under a single lock (two separate lock sections let concurrent logs
  interleave and corrupt deltas); new FrameLogged event fired outside the lock
  for UI subscribers that must marshal to the UI thread
- Atomic persistence: new AtomicFileWriter (same-directory temp file + rename)
  used by SettingsService, ConnectionManager.SaveProfiles, and
  RegisterTemplateStore so a crash mid-write no longer corrupts settings or
  connection profiles
- ModbusSerialService.RunDiagnosticsAsync now runs on the thread pool (it
  opens a COM port and does blocking I/O; previously it blocked the calling
  thread); diagnostic port close/dispose log instead of swallowing
- SerialPortStreamResource.InfiniteTimeout returns SerialPort.InfiniteTimeout
  (255) - the sentinel NModbus compares against - instead of the current
  ReadTimeout value
- Empty catch blocks in disconnect/diagnostics cleanup now log at Debug
- SerialSettingsDetector probe timeouts and ModbusSerialService diagnostic
  timeouts promoted to named constants

Deferred to the UI batch: AutoReconnect preference is still settings-only;
the reconnect behavior lives in MainViewModel and will be implemented there.
- Dispose now unhooks the custom-entry PropertyChanged hooks, clears the
  MQTT gateway SnapshotProvider, and unsubscribes the VisualNodeEditor
  PropertyChanged - the gateway is a long-lived singleton whose captured
  delegate kept the whole view-model graph alive
- Implemented the previously dead AutoReconnect preference: on unexpected
  connection loss the VM retries ConnectProfileAsync on the configured
  interval; deliberate user disconnects, profile switches, project reloads,
  and dispose all stop it
- Connection events (ActiveProfileChanged / ProfileConnected /
  ProfileDisconnected / ActiveProfile.PropertyChanged) are now marshaled to
  the UI thread before touching view-model state - they are raised from
  thread-pool threads by ConnectionManager
- Poll loop restart after an error now backs off 1 s instead of spinning a
  tight error -> immediate-restart loop; clean stops restart immediately
- Left navigation list is now data-driven (NavigationItems with per-item
  TabIndex and IsVisible mirroring the tab flags): hidden tabs disappear
  from the nav list, the fragile static NavigationIndexConverter and its
  hardcoded index tables are deleted, and the two ScriptEditor_Click
  code-behind handlers plus the ActiveUnitIdComboBox handler become direct
  command/two-way bindings (OpenScriptEditorCommand)
- Tab indices are named constants; IsTabIndexVisible / EnsureSelectedTabIsVisible
  use them
- Console tab now binds to the shared IConsoleLoggerService collection (the
  same sink the Modbus services, script engine, and API already log into),
  honoring the MaxConsoleMessages setting; repeated identical status
  messages (poll cycles) no longer spam the console
- String writes are capped at the FC16 limit (123 registers) with clear
  user feedback in both the register grid and custom watch paths
- ApplyRegisterValues no longer throws on duplicate grid addresses
  (TryAdd, first entry wins); metadata deduped the same way
- OpenUrl validates http(s) URLs and logs/announces failures instead of
  swallowing them
- async-void event handlers in RegisterTemplateImportDialog,
  TagBrowserWindow, WatchWindow, and VisualNodeEditorView now surface
  exceptions instead of silently swallowing them
- TrendViewModel: samples now queue and flush to the UI in one batch
  (per-sample blocking dispatcher.Invoke per series per poll); retention
  trimming and live-window alignment run once per batch; queue is bounded
  and lost-wakeup safe
- ScriptEditorViewModel: now IDisposable (unsubscribes the shared script
  runner events and the script Commands collection), resubscribes to the
  Commands list when a script is loaded, Run never leaves IsRunning stuck
  on runner errors, and load/save errors are logged
- SignalGeneratorViewModel: dropped the no-op 'await Task.CompletedTask',
  a generation counter stops a superseded run from clobbering status
  messages, and a disposed CTS can no longer spin the error-retry loop
- FrameInspectorViewModel: now IDisposable; the DataGrid binds to a new
  UI-thread mirror collection fed by ModbusFrameLogger.FrameLogged in
  batches instead of the cross-thread ObservableCollection; ModbusFrameLogger
  gained a thread-safe Snapshot() for history import; pcap import keeps
  working through the same pipeline
- Custom Watch grid: Address / Write Period / Read Period are now bounded
  NumericUpDown cells (0-65535 / 1-600000 ms) so invalid input is clamped
  visibly instead of silently discarded by ConvertBack
- ConnectionProfile implements IDataErrorInfo (IP or host name, port,
  unit ID, server unit ID list incl. ranges, baud rate) and the connection
  manager validates the edited fields before connecting; 37 new regression
  tests cover the validators
- Navigation list is now a visible-subset ObservableCollection rebuilt on
  tab-visibility changes (hidden tabs are absent, not invisible)
- Compiled Avalonia bindings (x:CompileBindings) are now enabled in all
  views except the code-behind-only register template import dialog, which
  has no x:DataType for its row bindings; the removal surfaced and fixed
  the container-level IsVisible binding in the nav list
- Menu: removed the duplicate Frame Inspector entry from Tools; renamed
  the ambiguous '_Trend' checkbox to '_Trends Tab'
- AvaloniaMessageBoxService: returns None immediately in a headless
  context and activates the fallback window when no owner exists
…estruction

- ModbusServerPublishingPortTests: data-path tests now bind 127.0.0.1;
  exactly one test deliberately exercises the 0.0.0.0 publishing bind (the
  merged assertions), so a writable Modbus server is no longer exposed on
  all network interfaces for the duration of every test
- ApiServerServiceTests: 15 hard-coded loopback ports (15080-15094) replaced
  with an OS-assigned free port per test; Server_BindsOnlyToLoopback now also
  probes a non-loopback interface and asserts the server is unreachable there
- ConnectionManager: profiles file path is injectable (default unchanged);
  ConnectionManagerTests / PerformanceTests / MainViewModel(Integration|Parity)Tests
  all persist to a unique %TEMP% dir per test instance instead of moving the
  developer's real %APPDATA%\\ModbusForge\\connection-profiles.json to .bak
- ModbusMultiUnitServerTests: fixed 500ms sleep replaced with a deadline-bounded
  poll for the server's close-of-connection reaction
- PollingThroughputTests: the hard 1s wall-clock assert is now a reported
  baseline with a generous 10s ceiling (CI runners vary)
- ModbusServiceTests: rewritten around a new internal ModbusTcpService test-seam
  constructor (no more reflection that silently no-ops on a renamed field);
  writes are now positively verified at the mocked NModbus master with the
  0-based protocol address, while the sensitive-value logging assertion stays
- ModbusTcpServicePerformanceTests: removed the always-true elapsed-time assert
- ModbusSerialServiceTests: removed the tautological address-formula test
- deleted the empty UnitTest1 placeholder
- TFM split: ModbusForge.Tests is now net8.0 (cross-platform, ready for Linux
  CI); the FlaUI-based Avalonia smoke tests moved to a new net8.0-windows
  ModbusForge.UITests project - all 6 smoke tests pass against the app
- release.yml: new version-agreement gate - the tag version must match the
  <Version> element of all three csproj files or the release fails (a
  mistag used to ship binaries whose assembly version disagrees with the
  repo); CalVer pattern check is now fatal instead of a warning; job gains
  timeout-minutes: 90 and a per-ref concurrency group; the Headless test
  suite now runs before publish; code signing now uses the 64-bit signtool
  (32-bit from a 64-bit host can fail), an https timestamp URL, verifies
  each signtool exit code, and signs the headless Windows exe in addition
  to the Avalonia one; Inno Setup pinned to choco 6.4.1
- all three workflows: actions pinned to commit SHAs instead of mutable
  major tags (checkout v4.4.0, setup-dotnet v4.3.1, upload-artifact v4.6.2,
  action-gh-release v2.6.2 - SHAs resolved from the GitHub API)
- avalonia.yml: windows + ubuntu legs now build the full solution and run
  the core, headless and Avalonia suites (the core suite is net8.0 after the
  TFM split, so it finally runs on Linux CI); the headless smoke test asks
  the OS for a free port instead of hard-coding 1502; job timeout added
- create_release.ps1: token read from GITHUB_TOKEN (no longer a mandatory
  command-line parameter visible to other processes), git -C \ so
  the commit sha cannot come from the wrong repo, real failure handling
  (exit 1, safe response-body read), success banner only on success, and
  the same three-csproj version-agreement check as the CI gate
- build.ps1: anchored at \ (works from any CWD), fails with a
  clear error when iscc.exe is missing instead of warning and reporting
  success, checks iscc exit codes, and asserts all three csproj versions
  agree before doing anything
- publish-avalonia.ps1: Inno Setup lookup now falls back to PATH and the
  alternate install root, like build.ps1
- setup/ModbusForge.iss: AppVersion default updated 2026.8.24 -> 2026.8.27
  and MinVersion=10.0.0.0 added (.NET 8 requires Windows 10)
- AI_RELEASE_WORKFLOW.md rewritten to match reality: three-csproj versioning
  policy, the version gate, all six release assets (headless zips were
  missing), README-based release notes (generate_release_notes is false),
  choco-installed Inno Setup (the old \ download no longer exists)
- ModbusForge.csproj: ImplicitUsings deliberately left disabled, with a
  comment recording why (Avalonia System.IO.Path ambiguity)
The 4,370-line MainViewModel was a god class: connection, polling, register
grid editing, custom watch, project persistence, navigation, and window
commands all lived in one file. It is now seven partial files (same class,
zero behavior change, all 585 tests green):

- MainViewModel.cs (core) - lifecycle, DI fields, unit-configuration state
  and per-area config plumbing, child view-model properties, status
- MainViewModel.Connection.cs - connect/disconnect, auto-reconnect, profile
  events, unit-id parsing, connection status and failure counters
- MainViewModel.Polling.cs - the monitor poll loop, due-queue, area reads
- MainViewModel.Registers.cs - register/coil grid read/write, prompts,
  per-area metadata, legacy dual-state properties
- MainViewModel.CustomWatch.cs - custom entries, bulk add, watch loop
- MainViewModel.Project.cs - project save/load, unit-id import/export,
  workspace snapshots
- MainViewModel.Navigation.cs - tab visibility, navigation list, console
  and debug messages, theme
- MainViewModel.Windows.cs - menu/dialog commands

Decorative #region markers were dropped (they would have been orphaned).
The split was produced by a Roslyn member extractor so every member moved
verbatim (393 members in == 393 out, verified); the splitter script lives
in a scratch project and is not committed.
StartAddress, RegisterCount, GlobalType, SwapBytes, SwapWords, SelectedAreaIndex and IsContinuousRead were a second, selected-area-mirrored copy of the per-area configuration. No view binds to any of them (the UI has used HoldingRegisterStart/Count/RegistersGlobalType/RegistersSwapBytes/... since the per-area rework) and no code reads them - they only existed in a two-way sync cycle with themselves. Removing them eliminates a trap for future bindings that would silently apply to one area only.

MainViewModelIntegrationTests now configures the holding-register area directly.
ModbusForge.Tests is net8.0 after the TFM split (FlaUI smoke tests moved to the new ModbusForge.UITests project), and the Headless test project is now listed as well.
- CA2013 (real bug): AutoReconnectLoopAsync compared its CancellationToken
  to the current source's token with ReferenceEquals. Both operands are
  structs, so the comparison boxed two fresh instances and was always false
  - the loop's cleanup therefore never ran and the auto-reconnect
  CancellationTokenSource was never disposed (each connect/disconnect cycle
  leaked a CTS plus its registration table). Now a value comparison, which is
  also the original intent (true only when this loop still owns the CTS).
- xUnit1031: the bad-protocol-ID compliance test read a completed task via
  .Result; it now awaits it (the preceding assert guarantees completion).
/// MainViewModel - core members (lifecycle, unit configuration, status, child view models).
/// </summary>
public partial class MainViewModel : ObservableObject, IDisposable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening braces should not be preceded by blank line.

Suggested change

…tants

- RetryPolicyService: retryability is now decided by exception TYPE only. The
  previous fallback retried whenever the exception MESSAGE contained words
  like 'connection', 'network' or 'timeout' - which silently re-ran
  non-transient failures (e.g. invalid configuration) up to maxRetries.
  New regression tests cover: non-retryable types run exactly once,
  message-sounding failures are not retried, transient IO failures retry
  until success, and exhausted retries rethrow the original exception.
- PollingEngine.Stop: the worker wait was a hard-coded 5 s, exactly the
  transport I/O timeout - a cancel arriving mid-read could time out the wait
  while the worker was still alive, with no trace. It now waits 6 s (5 s I/O
  timeout + margin, documented) and logs a warning if the worker ever fails
  to exit.
- ScriptRuleService: the silent catch in CompareValues now logs the failed
  comparison at debug level instead of swallowing it.
- ModbusMultiUnitServer: removed the four pre-seed Add loops in
  GetOrCreateDataStore (ModbusDataCollection.Add is a documented no-op on the
  fixed-size store, so the loops only cost 4*65536 no-op calls per unit) and
  the now-unused DefaultDataStoreSize constant, which also did not match the
  actual store size.
- TagService: removed the dead private ErrorPreview helper (never called).
- ModbusTcpService/ModbusSerialService: diagnostics used bare 5000 literals
  for connect/transport/port timeouts; they now reference the shared
  IoTimeoutMs constant.
ModbusServerService (the in-process 'server mode' data source) had a patchwork
of bounds checks: WriteSingleCoil rejected address 0 but WriteSingleRegister
and the multi-write paths accepted it (then blew up deep inside
ModbusDataCollection, which treats index 0 as an unsettable placeholder),
unknown unit IDs threw different exception types per method with no message,
and error messages never said what the valid range was.

Unified rules, all with clear messages:
- Writes: address must be 1..Count-1 (the placeholder at 0 is not settable).
- Reads: address 0..Count-1 (index 0 remains readable as its default value -
  the UI's server-mode convention, covered by the existing parity test, and
  mirroring client mode where addresses 0 and 1 alias the first register).
- Unknown unit IDs: ArgumentOutOfRangeException('Data store not initialized
  for unit N') on every read and write path (no silent fallback to the
  primary unit's store).

Regression tests: zero-address writes (single register, single coil, coil
range) throw with the new messages, zero-address reads return the
placeholder default, valid 1-based ranges round-trip, and unknown unit IDs
are rejected on both the read and write paths.
- Bumped version to 2026.8.28 in all three projects (ModbusForge,
  ModbusForge.Core, ModbusForge.Headless).
- README: new changelog entry summarizing the release, title and
  installer-example version synced.

Contents of the branch: bounded TCP transport timeouts (no more frozen
app on dead devices), visible poll failures, reliable auto-reconnect,
script-rule comparison/reentrancy fixes, spec-compliant server mode with
strict unit/address validation, input validation before connect, frame
inspector fixes, UI lifecycle polish, headless runtime reconnect, MainViewModel
partial split, SHA-pinned CI with a release version-agreement gate, and a
596-test warning-free suite.
@nokkies nokkies changed the title Add keyboard shortcuts and trend auto-monitor v2026.8.28 - Reliability, validation & housekeeping (596 tests, warning-free build) Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants