Skip to content

Separate the OPC UA logic of the Workshop clients from their windows - #866

Merged
romanett merged 15 commits into
masterfrom
romanett/winforms-samples-architecture-1375ef
Sep 4, 2026
Merged

romanett merged 15 commits into
masterfrom
romanett/winforms-samples-architecture-1375ef

Conversation

@romanett

@romanett romanett commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Every Workshop sample client kept its whole OPC UA logic inside MainForm.cs, in async void handlers next to the ListView updates. That is why the samples could only be tested through a never-shown form on an STA thread driven by reflection, and why defects in the sample logic (the RoleManagement Reset button, the HistoricalEvents DateTimeUtc unbox, the AlarmCondition reentrancy) survived until a user pressed a button. The servers already went through this split (#856); this PR does the clients.

What changes

  • New library Samples/Client.Common (Opc.Ua.Samples.Client), the UI-free half of a sample client: the SampleClientModel base class (attach/detach lifecycle, reconnect entry points, events posted to the thread the model was created on, a detach that never throws because the connect control disposes its session before it reports the disconnect), SampleSession (the helpers moved out of ClientUtils, which keeps forwarders so nothing else changes), SubscriptionPump, SerialNotificationPump<T>, OperationResult and SampleSessionFactory. See its README.
  • All 18 Workshop clients get a Model/<X>ClientModel.cs which never references Windows Forms, and a thin MainForm.cs which hands the connect control's session to the model and renders its events. The Boiler client is the reference implementation, the Empty client the template. Every name tier 2 reflects on survived verbatim; the only tier 2 source edit is the ConditionSnapshot cast in SampleClientActionTests.
  • New test tier 1.7, Tests/SampleClientModels.Tests: a fixture per model against the in-process sample server over a managed session, no STA thread, no message loop, no reflection. ModelContractTests enforces the rule by reflection: any field, property, event, parameter or return type from System.Windows.Forms, System.Drawing or the control library in a Model namespace fails, and so does a client without a model.
  • AlarmCondition reentrancy fixed by design: the V2 event callback only posts into a SerialNotificationPump, one consumer does the awaits in arrival order; the fixture asserts that handlers are never entered concurrently and that the list is cleared before a refresh replays.
  • Docs: docs/TESTING.md (the tier, the fourth "why this works" property, a fixture column in the per-client table), the library README, links from the Hosting README and the root README.

Verification

All five tiers on the merged branch, both solutions building with zero errors:

Tier Result
0 Configuration 183 passed
1 Servers 115 passed
1.5 Node managers 160 passed, 3 skipped (existing known issues)
1.7 Client models 318 passed, 3 skipped (known issues, fail when they start passing)
2 Client smoke 61 passed

Worth knowing

  • The AlarmCondition model uses the item-level condition refresh, adds the RefreshStart/RefreshEnd types to its event filter, and removes the old monitored item before adding the new one on a filter swap. All three were measured against preview.4: the subscription-level refresh replays nothing, the markers are run through the where clause, and add-then-remove leaves the new item without live events.
  • Two HistoricalEvents behaviours the original form got wrong are fixed on the way: the "first event time" read sent no end bound and always fell back, and two events could interleave on the UI thread.
  • The follow-ups this PR leaves open (the Reference/Sample/GDS clients, the control library, DI registration of the models, Workshop/Common) are collected in Client models: the follow-ups left open by the Workshop client split #865.

🤖 Generated with Claude Code

romanett and others added 14 commits September 4, 2026 08:43
… the foundation

Adds the UI-free half of the sample clients as a library, Opc.Ua.Samples.Client:
the SampleClientModel base class (attach/detach lifecycle, reconnect entry points,
events posted to the thread the model was created on), the session helpers moved
out of ClientUtils (which keeps forwarders), the subscription pumps, and a managed
session factory. Adds the headless model tier, Tests/SampleClientModels.Tests, with
its fixture base, event sink and the contract test which keeps every model free of
Windows Forms. Splits the Empty client (the template) and the Boiler client (the
reference implementation) into a Model/<X>ClientModel and a thin window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ents into a model and a thin window

Each client keeps its OPC UA logic in Model/<X>ClientModel, built on SampleClientModel;
the window hands it the session of the connect control and renders what it reports. The
model tier gains a fixture per client; Aggregation is declared as its one gap, as in
tier 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sfer clients into a model and a thin window

Each client gets a UI-free Model/<Sample>ClientModel.cs on SampleClientModel
which owns the session-dependent state and answers OperationResult/Outcome
records for the status bar; the forms keep ConnectServerCTRL, hand the session
over in Server_ConnectCompleteAsync and render what the model found. Every
handler and control name tier 2 reflects on survives verbatim. One headless
fixture per sample drives the model against the in-process sample server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s clients into a model and a thin window

Each client keeps its OPC UA logic in a Model/<Sample>ClientModel on the
shared SampleClientModel base: StateMachines streams both machines through a
SubscriptionPump and serialises the UserExecutable reads so PermittedCauses
is final when the attach returns; NodeManagement answers every service with
an OperationResult and pumps the model change events; PerfTest wraps the
Tester, which moves to Model/; HistoricalAccess keeps the selection and the
two questions the history control answers for itself. The windows only map
controls to model calls and render what the model reports. A headless fixture
per sample drives the models against the in-process sample servers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…thin window

DataAccessClientModel owns the lazy subscription, the monitored item entries,
browse/read/write and the paged history reads; HistoricalEventsClientModel owns
the area, the filter, the paged event history and an EventStream which runs
the streaming enumeration on a SubscriptionPump and computes the display texts
in order. The forms, EventListView and the dialogs only render and call one
model method per handler. Two headless fixtures drive the models against the
in-process sample servers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ist the model fixtures in the testing guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AlarmConditionClientModel owns the event subscription, the condition table
and the Part 9 calls; AuditTrailModel owns the streaming audit subscription;
MainForm and AuditEventForm only render snapshots and forward clicks. Every
event notification now goes through one SerialNotificationPump consumer,
which is what fixes the documented reentrancy of the old async void handler.

The filter asks for the RefreshStart and RefreshEnd events explicitly (the
stack runs them through the where clause), the refresh is addressed to the
monitored item (a subscription refresh replays nothing on preview.4), and a
filter swap removes the old item before it creates the new one, because the
other order leaves the new item without live events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@romanett

romanett commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Merged master (through #864) into the branch. The four clients master extended after the split (RoleManagement's Part 18 items, StateMachines' sub state machines, NodeManagement's ModelChangeTracker, the Kerberos removal in UserAuthentication) have their new features in the models, not back in the windows, and the model fixtures cover them. On the merged tree: solution builds with 0 errors; tier 1 116 passed; tier 1.7 346 passed, 3 skipped; tier 2 62 passed with one timing flake (PerfTest Stop under load) which passes on rerun.

Master extended four of the clients this branch had already split, so their new
features are ported into the models rather than back into the windows:

- RoleManagement (#864): the identity criteria (UserName/Thumbprint/X509Subject) and
  the criteria strings this client can be matched by, the CustomConfiguration flag,
  the AccessRestrictions and Endpoints columns, and the audit trail stream all live in
  RoleManagementClientModel; the window renders them.
- StateMachines (#860): the materialized Part 16 model (available states and
  transitions), the sub state machine below Running with its StartBatch cause, and the
  effective state stream live in StateMachinesClientModel.
- NodeManagement: the hand written model change pump is replaced by the stack's
  ModelChangeTracker inside the model.
- UserAuthentication: the Kerberos and SAML stubs are gone from the model and the
  window, as on master.

The model fixtures gained cases for the new surface; the testing guide keeps the
model fixture column with master's updated StateMachines row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@romanett
romanett merged commit 60fccb5 into master Sep 4, 2026
5 of 9 checks passed
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.

1 participant