Skip to content

fix(common): isolate every event multicast across the codebase#188

Merged
PatrickRitchie merged 37 commits into
TrakHound:masterfrom
ottobolyos:fix/event-multicast-isolation
Jun 6, 2026
Merged

fix(common): isolate every event multicast across the codebase#188
PatrickRitchie merged 37 commits into
TrakHound:masterfrom
ottobolyos:fix/event-multicast-isolation

Conversation

@ottobolyos
Copy link
Copy Markdown
Contributor

@ottobolyos ottobolyos commented Jun 3, 2026

Summary

PR #185 introduced per-delegate isolation for the DeviceReceived event so a throwing subscriber no longer cuts off downstream handlers, with swallowed faults routed through InternalError. This PR closes the same bug class atomically across the entire codebase: the HTTP client surface (outer client and every sub-client), the HTTP server surface, both MQTT clients, the SHDR adapter and client, the device finder, and the agent / broker.

Changes

Shared helper

libraries/MTConnect.NET-Common/MulticastIsolation.cs — adds the MulticastIsolation static class with three Raise shapes:

  • Raise<T>(this EventHandler<T>, object, T, EventHandler<Exception> = null) — extension method on EventHandler<T> events.
  • Raise(this EventHandler, object, EventArgs, EventHandler<Exception> = null) — extension method on non-generic EventHandler events.
  • Raise<TDelegate>(TDelegate, Action<TDelegate>, EventHandler<Exception> = null, object = null) where TDelegate : Delegate — static call for any custom delegate shape; the caller supplies the per-subscriber invocation lambda so no per-signature overload is required. Stays a static call because extension-method syntax does not compose cleanly with the where TDelegate : Delegate constraint at the call site.

All three shapes share the same contract: a throwing subscriber cannot starve later subscribers; subscriber faults are routed through the caller-supplied internalError sink, which is itself iterated per-delegate so a throwing fault reporter cannot starve later fault reporters; secondary faults from the internalError handler are terminal and swallowed.

Call sites for the typed overloads read as:

ObservationAdded.Raise(this, observation);
ConnectionError.Raise(this, ex, InternalError);

HTTP clients

libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs, MTConnectHttpProbeClient.cs, MTConnectHttpCurrentClient.cs, MTConnectHttpSampleClient.cs, MTConnectHttpAssetClient.cs, MTConnectHttpClientStream.cs — every ?.Invoke raise site replaced with the appropriate Raise extension-method call across the outer client and every sub-client.

MQTT clients

  • libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs — 16 raise sites: ClientStarting, ClientStopping, ClientStarted, ClientStopped, ConnectionError, InternalError, DeviceReceived, ProbeReceived, ResponseReceived (×2), CurrentReceived, ObservationReceived (×2), SampleReceived, AssetsReceived, AssetReceived.
  • libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs — 9 raise sites: ClientStarting, ClientStopping, ClientStarted, ClientStopped, ConnectionError, InternalError, ObservationReceived (×2), ConnectionStatusChanged (×2), DeviceReceived, AssetReceived.

SHDR adapter and client

  • libraries/MTConnect.NET-SHDR/Adapters/ShdrAdapter.cs — 8 raise sites: AgentConnected, AgentDisconnected, PingReceived, PongSent, ConnectionError, LineSent (×2), SendError (×2).
  • libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs — 20 raise sites: Connected, PingSent (×2), ConnectionError (×2), Disconnected (×2), Listening, PongReceived, ProtocolReceived (×11).

Device finder

  • libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs — 8 raise sites: PingSent, PingReceived, SearchCompleted, PortOpened, PortClosed, ProbeSent, DeviceFound, ProbeSuccessful.
  • libraries/MTConnect.NET-DeviceFinder/PingQueue.cs — 3 raise sites: PingSent, PingReceived, Completed.

These classes declare every event with a custom delegate shape (DeviceHandler, PingSentHandler, RequestStatusHandler, etc.); each site uses the generic-delegate MulticastIsolation.Raise(handler, invoke) static call with the per-subscriber invocation passed inline.

Agent and broker

  • libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs — 15 raise sites total: the EventHandler<T> events DeviceAdded, ObservationReceived, ObservationAdded (×4), AssetAdded use the extension-method form; the custom-delegate validation events InvalidComponentAdded, InvalidCompositionAdded, InvalidDataItemAdded, InvalidObservationAdded (×3), InvalidDeviceAdded, InvalidAssetAdded (×2) use the generic-delegate static call.
  • libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs — 35 raise sites total: the EventHandler event StreamsResponseSent (×12) uses the extension-method form; the custom-delegate request / response events DevicesRequestReceived (×2), DevicesResponseSent (×2), StreamsRequestReceived (×12), AssetsRequestReceived, DeviceAssetsRequestReceived, AssetsResponseSent (×2), ErrorResponseSent (×2) use the generic-delegate static call.

HTTP server

  • libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpResponseHandler.cs — 5 raise sites: ClientConnected, ResponseSent, ClientDisconnected (×3), ClientException (×2).
  • libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServerStream.cs — 5 raise sites: StreamStarted, HeartbeatReceived, DocumentReceived, StreamException, StreamStopped.

Test coverage

  • tests/MTConnect.NET-Common-Tests/MulticastIsolationTests.cs — helper unit tests for all three shapes, including the generic-delegate path.
  • tests/MTConnect.NET-Common-Tests/MqttClientMulticastIsolationTests.cs — helper contract tests for the delegate shapes used by both MQTT clients.
  • tests/MTConnect.NET-Common-Tests/DeviceFinderMulticastIsolationTests.cs — helper contract tests for every custom-delegate shape declared on MTConnectDeviceFinder and PingQueue.
  • tests/MTConnect.NET-Common-Tests/Agents/AgentMulticastIsolationTests.cs — helper contract tests for every event on MTConnectAgent and MTConnectAgentBroker, including the validation and request / response custom delegates.
  • tests/MTConnect.NET-SHDR-Tests/ShdrMulticastIsolationTests.cs — helper contract tests for EventHandler<string>, EventHandler<AdapterEventArgs<string>>, EventHandler<AdapterEventArgs<Exception>>, and EventHandler<Exception> shapes.
  • tests/MTConnect.NET-HTTP-Tests/Clients/MulticastIsolationTests.cs, NonGenericMulticastIsolationTests.cs, SubClientMulticastIsolationTests.cs — positive (multi-subscriber fan-out) and negative (InternalError-itself-throws) tests per raise site on the HTTP client surface.
  • tests/MTConnect.NET-HTTP-Tests/Integration/DevicesAndDeviceReceivedIntegrationTests.cs — end-to-end test for the probe → Devices cache → DeviceReceived flow.
  • tests/MTConnect.NET-HTTP-Tests/Servers/HttpServerMulticastIsolationTests.cs — helper contract tests for the HTTP server delegate shapes.

Verification

  • dotnet build MTConnect.NET.sln — green (0 warnings, 0 errors).
  • dotnet test MTConnect.NET.sln --filter "Category!=E2E&Category!=RequiresDocker&Category!=XsdLoadStrict" — green (4831 / 4831).
  • dotnet test tests/MTConnect.NET-Common-Tests --filter "FullyQualifiedName~MulticastIsolation" — green (80 / 80).

@ottobolyos ottobolyos force-pushed the fix/event-multicast-isolation branch from 09277af to f7dd36e Compare June 3, 2026 05:55
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 3, 2026
@ottobolyos ottobolyos changed the title fix(http): isolate every event multicast across the HTTP client fix(common): isolate every event multicast across the codebase Jun 3, 2026
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 3, 2026
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 3, 2026
@ottobolyos ottobolyos force-pushed the fix/event-multicast-isolation branch from f6bc213 to 4ccfc69 Compare June 3, 2026 15:12
@ottobolyos ottobolyos force-pushed the fix/event-multicast-isolation branch from 4ccfc69 to 82c1bc9 Compare June 4, 2026 04:24
@ottobolyos ottobolyos marked this pull request as ready for review June 4, 2026 04:31
@ottobolyos ottobolyos marked this pull request as draft June 4, 2026 05:03
@ottobolyos ottobolyos marked this pull request as ready for review June 4, 2026 05:08
@PatrickRitchie PatrickRitchie moved this from In Progress to Reviewing in MTConnect.NET-Development Jun 5, 2026
@ottobolyos ottobolyos marked this pull request as draft June 5, 2026 02:40
ottobolyos and others added 15 commits June 5, 2026 04:41
Each HTTP sub-client class -- MTConnectHttpProbeClient,
MTConnectHttpCurrentClient, MTConnectHttpSampleClient,
MTConnectHttpAssetClient, and MTConnectHttpClientStream -- now iterates
the multicast invocation list at every event raise site so one throwing
subscriber cannot starve later subscribers. Faults are forwarded through
the class's own InternalError event; a faulting InternalError handler is
also swallowed so the originating fan-out keeps going. Mirrors the
existing per-class helper pattern already applied to the outer
MTConnectHttpClient.

The sub-classes do not share a base type, so a per-class private
RaiseEvent helper keeps each class's InternalError routing
self-contained and adds no inheritance pressure. MTConnectHttpClientStream
gets both the generic RaiseEvent<T> and a non-generic RaiseEvent
sibling for the lifecycle events (Starting, Started, Stopping, Stopped).
Bare Raise{T} in the class-level summaries of
MqttClientMulticastIsolationTests, HttpServerMulticastIsolationTests,
and ShdrMulticastIsolationTests is ambiguous between the
EventHandler{T} overload (phase 1) and the generic-delegate overload
(phase 2b). docfx metadata with TreatWarningsAsErrors=true promotes
the resulting CS0419 to a build error, breaking the "Prepare generated
docs" CI job.

Replace each bare Raise{T} cref with the fully-qualified parameter
list that matches the overload the test class exercises. Also
disambiguate the Raise(EventHandler, ...) cross-reference in
MulticastIsolation.cs line 48 (non-generic overload summary).
The two `SampleReceived...WhenOneThrows` tests pushed an
UNAVAILABLE -> AVAILABLE pair once after the seed Current arrived and
then waited 30 s for the streaming sample loop to deliver. On the
Windows CI runner the pushed observations can land between two streaming
GETs: the prior request closes for the heartbeat round trip and the next
one resumes past the pushed sequences, leaving the assertion timing out
even though the multicast-isolation behavior is correct.

Hoist the push-and-wait into a `WaitForSampleReceivedWithTransitions`
helper that repushes every second until either the recorded handler
fires or the EventWaitTimeoutMs budget elapses. Each repush is a fresh
UNAVAILABLE -> AVAILABLE pair, so a new observation lands in the buffer
regardless of where the streaming loop is in its reconnect cycle.

The helper preserves the original semantics: a single repush in the
happy path returns within ~1 s, and the overall wait still tops out at
EventWaitTimeoutMs so a genuinely broken multicast still fails the test.
@ottobolyos ottobolyos force-pushed the fix/event-multicast-isolation branch from 3378cfd to 6a32532 Compare June 5, 2026 02:41
@ottobolyos ottobolyos marked this pull request as ready for review June 5, 2026 02:45
@PatrickRitchie
Copy link
Copy Markdown
Contributor

I'm wondering if it might be better to make the MulticastIsolation.Raise() method into an extension method. May also want to make the internalError parameter optional. This might make the usage a little cleaner. Let me know your thoughts.

Example:

public static void Raise<T>(this EventHandler<T> handler, object sender, T arg, EventHandler<Exception> internalError = null)

public static void Raise(this EventHandler handler, object sender, EventArgs arg, EventHandler<Exception> internalError = null)

Example Usage:

ObservationAdded.Raise(this, observation);

@ottobolyos ottobolyos marked this pull request as draft June 6, 2026 00:40
Make the typed EventHandler and EventHandler<T> overloads extension methods
and default internalError to null, so call sites read as

    MyEvent.Raise(this, args);
    MyEvent.Raise(this, args, InternalError);

instead of

    MulticastIsolation.Raise(MyEvent, this, args, InternalError);

The generic-delegate overload keeps its static-call form: extension-method
syntax does not compose cleanly with a where TDelegate : Delegate constraint
at the call site.
Switch every typed MulticastIsolation.Raise(...) call in the HTTP client
surface to the new extension-method form (event.Raise(this, args, sink)).
No behavioural change.
Switch every typed MulticastIsolation.Raise(...) call in the HTTP server
surface to the new extension-method form (event.Raise(this, args, sink)).
No behavioural change.
Switch every typed MulticastIsolation.Raise(...) call in the MQTT client
surface to the new extension-method form (event.Raise(sender, args, sink)).
No behavioural change.
Switch every typed MulticastIsolation.Raise(...) call in the SHDR adapter
and client surfaces to the new extension-method form (event.Raise(this,
args, sink)). No behavioural change.
Switch typed MulticastIsolation.Raise(...) calls in MTConnectAgent and
MTConnectAgentBroker to the new extension-method form (event.Raise(this,
args, sink)). Custom-delegate events keep the generic-delegate static call
because extension-method syntax does not compose with the helper's
where TDelegate : Delegate constraint. No behavioural change.
Update every typed-overload call site in the multicast-isolation tests to
match the new extension-method form (event.Raise(this, args, sink)).
Custom-delegate tests keep the generic-delegate static call. The XML cref
references in the test class summaries still resolve because the method
signature (parameter types) is unchanged by the this modifier.
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 6, 2026
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 6, 2026
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Jun 6, 2026
@ottobolyos
Copy link
Copy Markdown
Contributor Author

Good call — extension-method syntax reads much cleaner at the call sites. Applied across the typed EventHandler and EventHandler<T> overloads and made internalError optional. The generic-delegate overload (the one that takes an Action<TDelegate> invocation lambda for custom delegate signatures) stays as a static call: extension-method syntax doesn't compose cleanly with a where TDelegate : Delegate constraint at the call site. Sites in MTConnectAgent, MTConnectAgentBroker, the HTTP and MQTT clients, the HTTP server stream, the SHDR adapter and client, and the multicast-isolation test suites now read as event.Raise(this, args) / event.Raise(this, args, InternalError).

@ottobolyos ottobolyos marked this pull request as ready for review June 6, 2026 00:57
@PatrickRitchie PatrickRitchie moved this from Reviewing to Ready to Merge in MTConnect.NET-Development Jun 6, 2026
@PatrickRitchie PatrickRitchie merged commit d29f68a into TrakHound:master Jun 6, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from Ready to Merge to Done in MTConnect.NET-Development Jun 6, 2026
@ottobolyos ottobolyos deleted the fix/event-multicast-isolation branch June 6, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

2 participants