diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index c61cadeec..e1276ec00 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -1314,7 +1314,7 @@ private Device NormalizeDevice(IDevice device) var validationResults = new ValidationResult(false, $"Invalid Component : \"{genericComponent.Type}\" Not Found"); if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) { - if (InvalidComponentAdded != null) InvalidComponentAdded.Invoke(obj.Uuid, genericComponent, validationResults); + MulticastIsolation.Raise(InvalidComponentAdded, h => h(obj.Uuid, genericComponent, validationResults)); // Remove Component from Device if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComponent(genericComponent.Id); @@ -1334,7 +1334,7 @@ private Device NormalizeDevice(IDevice device) var validationResults = new ValidationResult(false, $"Invalid Composition : \"{genericComposition.Type}\" Not Found"); if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) { - if (InvalidCompositionAdded != null) InvalidCompositionAdded.Invoke(obj.Uuid, genericComposition, validationResults); + MulticastIsolation.Raise(InvalidCompositionAdded, h => h(obj.Uuid, genericComposition, validationResults)); // Remove Compsition from Device if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComposition(genericComposition.Id); @@ -1354,7 +1354,7 @@ private Device NormalizeDevice(IDevice device) var validationResults = new ValidationResult(false, $"Invalid DataItem : \"{genericDataItem.Type}\" Not Found"); if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) { - if (InvalidDataItemAdded != null) InvalidDataItemAdded.Invoke(obj.Uuid, genericDataItem, validationResults); + MulticastIsolation.Raise(InvalidDataItemAdded, h => h(obj.Uuid, genericDataItem, validationResults)); // Remove DataItem from Device if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveDataItem(genericDataItem.Id); @@ -1522,7 +1522,7 @@ private bool AddDeviceAddedObservation(IDevice device, long timestamp = 0) new ObservationValue(ValueKeys.Result, device.Uuid) }); - ObservationAdded?.Invoke(this, observation); + ObservationAdded.Raise(this, observation, null); return true; } @@ -1553,7 +1553,7 @@ private bool AddDeviceChangedObservation(IDevice device, long timestamp = 0) new ObservationValue(ValueKeys.Result, device.Uuid) }); - ObservationAdded?.Invoke(this, observation); + ObservationAdded.Raise(this, observation, null); return true; } @@ -1583,7 +1583,7 @@ private bool AddDeviceRemovedObservation(IDevice device, long timestamp = 0) new ObservationValue(ValueKeys.Result, device.Uuid) }); - ObservationAdded?.Invoke(this, observation); + ObservationAdded.Raise(this, observation, null); return true; } @@ -1717,7 +1717,7 @@ public IDevice AddDevice(IDevice device, bool initializeDataItems = true) _updateInformation = true; } - DeviceAdded?.Invoke(this, obj); + DeviceAdded.Raise(this, obj, null); return obj; } @@ -2124,7 +2124,7 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, { if (observationInput != null) { - ObservationReceived?.Invoke(this, observationInput); + ObservationReceived.Raise(this, observationInput, null); IObservationInput input = new ObservationInput(); input.DeviceKey = deviceKey; @@ -2259,16 +2259,17 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, else success = true; // Return true if no update needed } - if (!validationResult.IsValid && InvalidObservationAdded != null) + if (!validationResult.IsValid) { - InvalidObservationAdded.Invoke(deviceUuid, input.DataItemKey, validationResult); + MulticastIsolation.Raise(InvalidObservationAdded, h => h(deviceUuid, input.DataItemKey, validationResult)); } return success; } - else if (InvalidObservationAdded != null) + else { - InvalidObservationAdded.Invoke(deviceUuid, input.DataItemKey, new ValidationResult(false, $"DataItemKey \"{input.DataItemKey}\" not Found in Device")); + var missingKeyResult = new ValidationResult(false, $"DataItemKey \"{input.DataItemKey}\" not Found in Device"); + MulticastIsolation.Raise(InvalidObservationAdded, h => h(deviceUuid, input.DataItemKey, missingKeyResult)); } } @@ -2333,28 +2334,19 @@ protected virtual ulong OnAddObservation(string deviceUuid, IDataItem dataItem, /// public void OnObservationAdded(IObservation observation) { - if (ObservationAdded != null) - { - ObservationAdded?.Invoke(this, observation); - } + ObservationAdded.Raise(this, observation, null); } /// public void OnInvalidObservationAdded(string deviceUuid, string dataItemId, ValidationResult result) { - if (InvalidObservationAdded != null) - { - InvalidObservationAdded?.Invoke(deviceUuid, dataItemId, result); - } + MulticastIsolation.Raise(InvalidObservationAdded, h => h(deviceUuid, dataItemId, result)); } /// public void OnInvalidDeviceAdded(IDevice device, ValidationResult result) { - if (InvalidDeviceAdded != null) - { - InvalidDeviceAdded?.Invoke(device, result); - } + MulticastIsolation.Raise(InvalidDeviceAdded, h => h(device, result)); } #endregion @@ -2453,16 +2445,16 @@ public bool AddAsset(string deviceKey, IAsset asset, bool? ignoreTimestamp = nul if (!validationResults.IsValid && _configuration.InputValidationLevel > InputValidationLevel.Ignore) { - if (InvalidAssetAdded != null) InvalidAssetAdded.Invoke(asset, validationResults); + MulticastIsolation.Raise(InvalidAssetAdded, h => h(asset, validationResults)); } - AssetAdded?.Invoke(this, asset); + AssetAdded.Raise(this, asset, null); return true; } } else { - if (InvalidAssetAdded != null) InvalidAssetAdded.Invoke(asset, validationResults); + MulticastIsolation.Raise(InvalidAssetAdded, h => h(asset, validationResults)); } } } diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs index f1662b9c9..7095a10cb 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs @@ -662,7 +662,7 @@ private static string FormatHeaderVersion(Version mtconnectVersion) /// MTConnectDevices Response Document public IDevicesResponseDocument GetDevicesResponseDocument(Version mtconnectVersion = null, string deviceType = null) { - DevicesRequestReceived?.Invoke(null); + MulticastIsolation.Raise(DevicesRequestReceived, h => h(null)); var version = mtconnectVersion != null ? mtconnectVersion : MTConnectVersion; @@ -675,7 +675,7 @@ public IDevicesResponseDocument GetDevicesResponseDocument(Version mtconnectVers doc.Header = GetDevicesHeader(version); doc.Devices = ProcessDevices(devices, version); - DevicesResponseSent?.Invoke(doc); + MulticastIsolation.Raise(DevicesResponseSent, h => h(doc)); return doc; } @@ -691,7 +691,7 @@ public IDevicesResponseDocument GetDevicesResponseDocument(Version mtconnectVers /// MTConnectDevices Response Document public IDevicesResponseDocument GetDevicesResponseDocument(string deviceKey, Version mtconnectVersion = null) { - DevicesRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(DevicesRequestReceived, h => h(deviceKey)); if (!string.IsNullOrEmpty(deviceKey)) { @@ -706,7 +706,7 @@ public IDevicesResponseDocument GetDevicesResponseDocument(string deviceKey, Ver doc.Header = GetDevicesHeader(version); doc.Devices = ProcessDevices(new List { device }, version); - DevicesResponseSent?.Invoke(doc); + MulticastIsolation.Raise(DevicesResponseSent, h => h(doc)); return doc; } @@ -756,7 +756,7 @@ private IObservationBufferResults GetObservations(IEnumerable bufferKeys, u /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -773,7 +773,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(uint coun var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -792,7 +792,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(uint coun /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong at, uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -809,7 +809,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong at, var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -828,7 +828,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong at, /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerable dataItemIds, uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -850,7 +850,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -870,7 +870,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerable dataItemIds, ulong at, uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -892,7 +892,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -912,7 +912,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong from, ulong to, uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -929,7 +929,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong fro var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -950,7 +950,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(ulong fro /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerable dataItemIds, ulong from, ulong to, uint count = 0, Version mtconnectVersion = null, string deviceType = null) { - StreamsRequestReceived?.Invoke(null); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(null)); if (_observationBuffer != null) { @@ -967,7 +967,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab var document = CreateDeviceStreamsDocument(devices, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -986,7 +986,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(IEnumerab /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1003,7 +1003,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1022,7 +1022,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, ulong at, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1039,7 +1039,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1058,7 +1058,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, IEnumerable dataItemIds, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1075,7 +1075,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1095,7 +1095,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, IEnumerable dataItemIds, ulong at, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1112,7 +1112,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1132,7 +1132,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, ulong from, ulong to, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1149,7 +1149,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1170,7 +1170,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de /// MTConnectStreams Response Document public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string deviceKey, IEnumerable dataItemIds, ulong from, ulong to, uint count = 0, Version mtconnectVersion = null) { - StreamsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(StreamsRequestReceived, h => h(deviceKey)); if (_observationBuffer != null) { @@ -1187,7 +1187,7 @@ public IStreamsResponseOutputDocument GetDeviceStreamsResponseDocument(string de var document = CreateDeviceStreamsDocument(device, ref results, mtconnectVersion); if (document != null) { - StreamsResponseSent?.Invoke(this, new EventArgs()); + StreamsResponseSent.Raise(this, EventArgs.Empty, null); return document; } } @@ -1418,7 +1418,7 @@ private IObservationOutput CreateObservation(IDataItem dataItem, ref BufferObser /// MTConnectAssets Response Document public IAssetsResponseDocument GetAssetsResponseDocument(string deviceKey = null, string type = null, bool removed = false, uint count = 0, Version mtconnectVersion = null) { - DeviceAssetsRequestReceived?.Invoke(deviceKey); + MulticastIsolation.Raise(DeviceAssetsRequestReceived, h => h(deviceKey)); if (_assetBuffer != null) { @@ -1452,7 +1452,7 @@ public IAssetsResponseDocument GetAssetsResponseDocument(string deviceKey = null document.Header = header; document.Assets = processedAssets; - AssetsResponseSent?.Invoke(document); + MulticastIsolation.Raise(AssetsResponseSent, h => h(document)); return document; } @@ -1468,7 +1468,7 @@ public IAssetsResponseDocument GetAssetsResponseDocument(string deviceKey = null /// MTConnectAssets Response Document public IAssetsResponseDocument GetAssetsResponseDocument(IEnumerable assetIds, Version mtconnectVersion = null) { - AssetsRequestReceived?.Invoke(assetIds); + MulticastIsolation.Raise(AssetsRequestReceived, h => h(assetIds)); if (_assetBuffer != null) { @@ -1499,7 +1499,7 @@ public IAssetsResponseDocument GetAssetsResponseDocument(IEnumerable ass document.Header = header; document.Assets = processedAssets; - AssetsResponseSent?.Invoke(document); + MulticastIsolation.Raise(AssetsResponseSent, h => h(document)); return document; } @@ -1771,7 +1771,7 @@ public IErrorResponseDocument GetErrorResponseDocument(ErrorCode errorCode, stri new Error(errorCode, value) }; - ErrorResponseSent?.Invoke(doc); + MulticastIsolation.Raise(ErrorResponseSent, h => h(doc)); return doc; } @@ -1792,7 +1792,7 @@ public IErrorResponseDocument GetErrorResponseDocument(IEnumerable error doc.Header = GetErrorHeader(version); doc.Errors = errors != null ? errors.ToList() : null; - ErrorResponseSent?.Invoke(doc); + MulticastIsolation.Raise(ErrorResponseSent, h => h(doc)); return doc; } diff --git a/libraries/MTConnect.NET-Common/MulticastIsolation.cs b/libraries/MTConnect.NET-Common/MulticastIsolation.cs new file mode 100644 index 000000000..603000a52 --- /dev/null +++ b/libraries/MTConnect.NET-Common/MulticastIsolation.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; + +namespace MTConnect +{ + /// + /// Shared event multicast helper that iterates the invocation list with + /// per-delegate fault isolation. A throwing subscriber cannot starve later + /// subscribers of the same event; 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 either. If the internalError handler itself throws, the + /// secondary fault is terminal and swallowed — there is no further sink to + /// route it to without risking the same starvation loop. + /// + /// + /// The typed and + /// overloads are exposed as extension methods so call sites read as + /// MyEvent.Raise(this, arg) rather than + /// MulticastIsolation.Raise(MyEvent, this, arg). The generic-delegate + /// overload remains a regular static call: extension-method syntax does + /// not compose cleanly with a where TDelegate : Delegate constraint + /// at the call site. + /// + public static class MulticastIsolation + { + /// + /// Raises a generic event with per-delegate fault isolation. If any + /// subscriber throws, the fault is routed through + /// without interrupting fan-out to + /// subsequent subscribers. The handler + /// itself is iterated with the same per-delegate try/catch so a + /// throwing fault-reporter cannot starve later fault subscribers + /// either. Exposed as an extension method so call sites read as + /// MyEvent.Raise(this, arg). + /// + /// The event payload type. + /// The event handler whose invocation list is iterated; a null handler is a safe no-op covering the no-subscriber case. + /// The sender object passed to each subscriber and to when routing a fault. + /// The typed event payload passed to each subscriber. + /// The fault-routing sink. Each subscriber fault is routed through every delegate on this sink; pass null (or omit) to swallow faults at the per-delegate boundary. + public static void Raise(this EventHandler handler, object sender, T arg, + EventHandler internalError = null) + { + if (handler == null) return; + + foreach (var subscriber in handler.GetInvocationList()) + { + try + { + ((EventHandler)subscriber).Invoke(sender, arg); + } + catch (Exception ex) + { + RaiseInternalError(internalError, sender, ex); + } + } + } + + /// + /// Non-generic overload of for + /// events that carry no typed payload. + /// Same contract: a throwing subscriber cannot starve later subscribers + /// and a faulting handler cannot break + /// the fan-out either. Exposed as an extension method so call sites + /// read as MyEvent.Raise(this, EventArgs.Empty). + /// + /// The event handler whose invocation list is iterated; a null handler is a safe no-op covering the no-subscriber case. + /// The sender object passed to each subscriber and to when routing a fault. + /// The event payload passed to each subscriber. + /// The fault-routing sink. Each subscriber fault is routed through every delegate on this sink; pass null (or omit) to swallow faults at the per-delegate boundary. + public static void Raise(this EventHandler handler, object sender, EventArgs arg, + EventHandler internalError = null) + { + if (handler == null) return; + + foreach (var subscriber in handler.GetInvocationList()) + { + try + { + ((EventHandler)subscriber).Invoke(sender, arg); + } + catch (Exception ex) + { + RaiseInternalError(internalError, sender, ex); + } + } + } + + /// + /// Raises an event of any delegate shape with per-delegate fault + /// isolation. The caller supplies the per-subscriber invocation lambda + /// so no per-signature overload is required; this lets the helper cover + /// custom delegate types (e.g. delegate void Foo(IPAddress), + /// delegate void Bar(Source, IDevice)) that the typed + /// / overloads + /// cannot. Same contract as those overloads: a throwing subscriber + /// cannot starve later subscribers, the + /// sink is itself iterated + /// per-delegate so a faulting fault-reporter cannot starve later fault + /// reporters either, and a secondary throw from an internalError + /// subscriber is terminal. + /// + /// The delegate type of the event being raised. Must derive from . + /// The event handler whose invocation list is iterated; a null handler is a safe no-op covering the no-subscriber case. + /// The per-subscriber invocation lambda. Called once per delegate in 's invocation list, inside the per-delegate try/catch. + /// The fault-routing sink. Each subscriber fault is routed through every delegate on this sink; pass null to swallow faults at the per-delegate boundary (consistent with the pre-isolation null-conditional behaviour). + /// The sender object passed to when routing a fault. Pass the class instance whose event is being raised, or null if no sender is associated. + /// + /// Prefer the typed + /// or overloads + /// when the event uses or ; + /// they avoid the call-site cast and the lambda allocation. Use this overload + /// only for events declared with a custom delegate signature (i.e. + /// public event MyHandler Foo; where MyHandler is not an + /// / ). + /// + public static void Raise(TDelegate handler, Action invoke, + EventHandler internalError = null, + object sender = null) + where TDelegate : Delegate + { + if (handler == null) return; + + foreach (var subscriber in handler.GetInvocationList()) + { + try + { + invoke((TDelegate)(object)subscriber); + } + catch (Exception ex) + { + RaiseInternalError(internalError, sender, ex); + } + } + } + + // Iterate the InternalError invocation list so a throwing fault + // reporter cannot starve later fault reporters. A secondary throw from + // an InternalError subscriber itself is terminal: there is no further + // sink to route it to without resurrecting the same starvation bug. + private static void RaiseInternalError(EventHandler internalError, + object sender, Exception ex) + { + if (internalError == null) return; + + foreach (var subscriber in internalError.GetInvocationList()) + { + try + { + ((EventHandler)subscriber).Invoke(sender, ex); + } + catch + { + // Terminal: cannot route a fault about the fault without + // risking the starvation loop this helper exists to close. + } + } + } + } +} diff --git a/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs b/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs index 71e77c25f..de4d2d049 100644 --- a/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs +++ b/libraries/MTConnect.NET-DeviceFinder/MTConnectDeviceFinder.cs @@ -244,12 +244,12 @@ private void PingQueue_Start() private void PingQueue_PingSent(IPAddress address) { - PingSent?.Invoke(this, address); + MulticastIsolation.Raise(PingSent, h => h(this, address)); } private void Queue_PingReceived(IPAddress address, PingReply reply) { - PingReceived?.Invoke(this, address, reply); + MulticastIsolation.Raise(PingReceived, h => h(this, address, reply)); } private void Queue_Completed(List successfulAddresses) @@ -299,7 +299,7 @@ private void CheckRequestsStatus() } if (ScanInterval == 0 && stop != null) stop.Set(); - SearchCompleted?.Invoke(this, m); + MulticastIsolation.Raise(SearchCompleted, h => h(this, m)); } } @@ -313,12 +313,12 @@ private bool TestPort(IPAddress address, int port) var success = result.AsyncWaitHandle.WaitOne(Timeout); if (!success) { - PortClosed?.Invoke(this, address, port); + MulticastIsolation.Raise(PortClosed, h => h(this, address, port)); return false; } else { - PortOpened?.Invoke(this, address, port); + MulticastIsolation.Raise(PortOpened, h => h(this, address, port)); } client.EndConnect(result); @@ -343,7 +343,7 @@ private void SendProbe(IPAddress address, int port) probe.InternalError += ProbeExceptionError; // Notify that a new Probe request has been sent - ProbeSent?.Invoke(this, address, port); + MulticastIsolation.Raise(ProbeSent, h => h(this, address, port)); var document = probe.Get(); if (document != null) @@ -370,11 +370,11 @@ private void SendProbe(IPAddress address, int port) } // Notify that a Device was found - DeviceFound?.Invoke(this, foundDevice); + MulticastIsolation.Raise(DeviceFound, h => h(this, foundDevice)); } // Notify that the Probe reqeuest was successful - ProbeSuccessful?.Invoke(this, address, port); + MulticastIsolation.Raise(ProbeSuccessful, h => h(this, address, port)); } } catch { } diff --git a/libraries/MTConnect.NET-DeviceFinder/PingQueue.cs b/libraries/MTConnect.NET-DeviceFinder/PingQueue.cs index d2b415e7a..ecaa91c7f 100644 --- a/libraries/MTConnect.NET-DeviceFinder/PingQueue.cs +++ b/libraries/MTConnect.NET-DeviceFinder/PingQueue.cs @@ -132,7 +132,7 @@ private void Worker(object obj) { foreach (var address in addresses) { - PingSent?.Invoke(address); + MulticastIsolation.Raise(PingSent, h => h(address)); var ping = new Ping(); lock (_lock) activeRequests.Add(ping); @@ -157,7 +157,8 @@ private void Ping_PingCompleted(object sender, PingCompletedEventArgs e) { var address = (IPAddress)e.UserState; - PingReceived?.Invoke(address, e.Reply); + var reply = e.Reply; + MulticastIsolation.Raise(PingReceived, h => h(address, reply)); if (e.Reply.Status == IPStatus.Success) successful.Add(address); @@ -181,7 +182,7 @@ private void CheckCompleted() successfulAddresses = successful; } - if (completed) Completed?.Invoke(successfulAddresses); + if (completed) MulticastIsolation.Raise(Completed, h => h(successfulAddresses)); } } } \ No newline at end of file diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpAssetClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpAssetClient.cs index e8484adb9..4c734d946 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpAssetClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpAssetClient.cs @@ -192,16 +192,16 @@ public IAssetsResponseDocument Get() } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -246,16 +246,16 @@ public async Task GetAsync(CancellationToken cancellati } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -346,7 +346,7 @@ private IAssetsResponseDocument HandleResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -364,7 +364,7 @@ private async Task HandleResponseAsync(HttpResponseMess { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -407,19 +407,19 @@ private IAssetsResponseDocument ReadDocument(HttpResponseMessage response, Strea if (errorFormatResult.Success) { var errorDocument = errorFormatResult.Content; - if (errorDocument != null) MTConnectError?.Invoke(this, errorDocument); + if (errorDocument != null) MTConnectError.Raise(this, errorDocument, InternalError); } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, errorFormatResult); + FormatError.Raise(this, errorFormatResult, InternalError); } } } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, formatResult); + FormatError.Raise(this, formatResult, InternalError); } } diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs index 13a029cc9..caa38aeaa 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs @@ -347,7 +347,7 @@ public void Start() { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = false; _lastInstanceId = 0; @@ -364,7 +364,7 @@ public void Start(string path) { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = false; _lastInstanceId = 0; @@ -382,7 +382,7 @@ public void Start(CancellationToken cancellationToken, string path = null) _stop = new CancellationTokenSource(); cancellationToken.Register(() => { Stop(); }); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = false; _lastInstanceId = 0; @@ -399,7 +399,7 @@ public void StartFromSequence(ulong instanceId, ulong sequence, string path = nu { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = true; _lastInstanceId = instanceId; @@ -417,7 +417,7 @@ public void StartFromSequence(ulong instanceId, ulong sequence, CancellationToke _stop = new CancellationTokenSource(); cancellationToken.Register(() => { Stop(); }); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = true; _lastInstanceId = instanceId; @@ -434,7 +434,7 @@ public void StartFromBuffer(string path = null) { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = true; _lastInstanceId = 0; @@ -452,7 +452,7 @@ public void StartFromBuffer(CancellationToken cancellationToken, string path = n _stop = new CancellationTokenSource(); cancellationToken.Register(() => { Stop(); }); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _initializeFromBuffer = true; _lastInstanceId = 0; @@ -467,7 +467,7 @@ public void StartFromBuffer(CancellationToken cancellationToken, string path = n /// public void Stop() { - ClientStopping?.Invoke(this, new EventArgs()); + ClientStopping.Raise(this, EventArgs.Empty, InternalError); if (_stop != null) _stop.Cancel(); } @@ -494,10 +494,10 @@ public IDevicesResponseDocument GetProbe() client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return client.Get(); } @@ -518,10 +518,10 @@ public async Task GetProbeAsync(CancellationToken canc client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return await client.GetAsync(cancellationToken); } @@ -535,10 +535,10 @@ public IStreamsResponseDocument GetCurrent(long at = 0, string path = null) client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return client.Get(); } @@ -559,10 +559,10 @@ public async Task GetCurrentAsync(CancellationToken ca client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return await client.GetAsync(cancellationToken); } @@ -576,10 +576,10 @@ public IStreamsResponseDocument GetSample(long from = 0, long to = 0, int count client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return client.Get(); } @@ -600,10 +600,10 @@ public async Task GetSampleAsync(CancellationToken can client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return await client.GetAsync(cancellationToken); } @@ -617,10 +617,10 @@ public IAssetsResponseDocument GetAssets(long count = 100) client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return client.Get(); } @@ -641,10 +641,10 @@ public async Task GetAssetsAsync(CancellationToken canc client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return await client.GetAsync(cancellationToken); } @@ -658,10 +658,10 @@ public IAssetsResponseDocument GetAsset(string assetId) client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return client.Get(); } @@ -682,10 +682,10 @@ public async Task GetAssetAsync(string assetId, Cancell client.Timeout = Timeout; client.ContentEncodings = ContentEncodings; client.ContentType = ContentType; - client.MTConnectError += (s, doc) => MTConnectError?.Invoke(this, doc); - client.FormatError += (s, r) => FormatError?.Invoke(this, r); - client.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - client.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + client.MTConnectError += (s, doc) => MTConnectError.Raise(this, doc, InternalError); + client.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + client.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + client.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); return await client.GetAsync(cancellationToken); } @@ -697,7 +697,7 @@ private async Task Worker() { var initialRequest = true; - ClientStarted?.Invoke(this, new EventArgs()); + ClientStarted.Raise(this, EventArgs.Empty, InternalError); do { @@ -708,7 +708,7 @@ private async Task Worker() if (probe != null) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); ProcessProbeDocument(probe); @@ -719,9 +719,9 @@ private async Task Worker() if (assets != null) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); - AssetsReceived?.Invoke(this, assets); + AssetsReceived.Raise(this, assets, InternalError); } } @@ -732,7 +732,7 @@ private async Task Worker() if (current != null) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); // Raise CurrentReceived Event ProcessCurrentDocument(current, _stop.Token); @@ -777,15 +777,15 @@ private async Task Worker() _stream.Timeout = Heartbeat * 3; _stream.ContentEncodings = ContentEncodings; _stream.ContentType = ContentType; - _stream.Starting += (s, o) => StreamStarting?.Invoke(this, url); - _stream.Started += (s, o) => StreamStarted?.Invoke(this, url); - _stream.Stopping += (s, o) => StreamStopping?.Invoke(this, url); - _stream.Stopped += (s, o) => StreamStopped?.Invoke(this, url); + _stream.Starting += (s, o) => StreamStarting.Raise(this, url, InternalError); + _stream.Started += (s, o) => StreamStarted.Raise(this, url, InternalError); + _stream.Stopping += (s, o) => StreamStopping.Raise(this, url, InternalError); + _stream.Stopped += (s, o) => StreamStopped.Raise(this, url, InternalError); _stream.DocumentReceived += (s, doc) => ProcessSampleDocument(doc, _stop.Token); _stream.ErrorReceived += (s, doc) => ProcessSampleError(doc); - _stream.FormatError += (s, r) => FormatError?.Invoke(this, r); - _stream.ConnectionError += (s, ex) => ConnectionError?.Invoke(this, ex); - _stream.InternalError += (s, ex) => InternalError?.Invoke(this, ex); + _stream.FormatError += (s, r) => FormatError.Raise(this, r, InternalError); + _stream.ConnectionError += (s, ex) => ConnectionError.Raise(this, ex, InternalError); + _stream.InternalError += (s, ex) => InternalError.Raise(this, ex, InternalError); // Run Stream (Blocking call) await _stream.Run(_stop.Token); @@ -812,7 +812,7 @@ private async Task Worker() if (current != null) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); // Raise CurrentReceived Event ProcessCurrentDocument(current, _stop.Token); @@ -871,12 +871,12 @@ private async Task Worker() catch (TaskCanceledException) { } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } } while (!_stop.Token.IsCancellationRequested); - ClientStopped?.Invoke(this, new EventArgs()); + ClientStopped.Raise(this, EventArgs.Empty, InternalError); } private void ProcessProbeDocument(IDevicesResponseDocument document) @@ -907,47 +907,18 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) // Isolate subscriber exceptions per delegate so one bad handler cannot abort the // populate loop, suppress ProbeReceived, or short-circuit later subscribers in the // invocation list; route each fault through InternalError instead. - RaiseDeviceReceived(outputDevice); + DeviceReceived.Raise(this, outputDevice, InternalError); } // Raise ProbeReceived Event - ProbeReceived?.Invoke(this, document); - } - } - - // Iterate the invocation list so one throwing subscriber cannot short-circuit the - // multicast and starve later subscribers. Each fault is forwarded through - // InternalError; if InternalError itself faults, swallow that secondary fault so the - // populate loop and remaining DeviceReceived subscribers still get every device. - private void RaiseDeviceReceived(IDevice device) - { - var handler = DeviceReceived; - if (handler == null) return; - - foreach (var subscriber in handler.GetInvocationList()) - { - try - { - ((EventHandler)subscriber).Invoke(this, device); - } - catch (Exception ex) - { - try - { - InternalError?.Invoke(this, ex); - } - catch - { - // A faulting InternalError handler must not break DeviceReceived fan-out. - } - } + ProbeReceived.Raise(this, document, InternalError); } } private void ProcessCurrentDocument(IStreamsResponseDocument document, CancellationToken cancel) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); if (document != null) { @@ -965,7 +936,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document, Cancellat response.Streams = deviceStreams; - CurrentReceived?.Invoke(this, response); + CurrentReceived.Raise(this, response, InternalError); // Process Device Streams @@ -980,7 +951,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document, Cancellat { foreach (var observation in observations) { - ObservationReceived?.Invoke(this, observation); + ObservationReceived.Raise(this, observation, InternalError); } } } @@ -991,7 +962,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document, Cancellat private void ProcessSampleDocument(IStreamsResponseDocument document, CancellationToken cancel) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); if (document != null) { @@ -1031,11 +1002,11 @@ private void ProcessSampleDocument(IStreamsResponseDocument document, Cancellati } } - SampleReceived?.Invoke(this, response); + SampleReceived.Raise(this, response, InternalError); foreach (var observation in receivedObservations) { - ObservationReceived?.Invoke(this, observation); + ObservationReceived.Raise(this, observation, InternalError); } } } @@ -1223,11 +1194,11 @@ private IComponentStream ProcessComponentStream(IMTConnectStreamsHeader header, private void ProcessSampleError(IErrorResponseDocument document) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); if (document != null) { - MTConnectError?.Invoke(this, document); + MTConnectError.Raise(this, document, InternalError); } } @@ -1247,13 +1218,13 @@ private async void CheckAssetChanged(IEnumerable observations, Can var doc = await GetAssetAsync(assetId, cancel); if (doc != null) { - AssetsReceived?.Invoke(this, doc); + AssetsReceived.Raise(this, doc, InternalError); if (doc != null && !doc.Assets.IsNullOrEmpty()) { foreach (var asset in doc.Assets) { - AssetReceived?.Invoke(this, asset); + AssetReceived.Raise(this, asset, InternalError); } } } diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClientStream.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClientStream.cs index 9fe6dc59e..71c27bb25 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClientStream.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClientStream.cs @@ -123,7 +123,7 @@ public void Start(CancellationToken cancellationToken) cancellationToken.Register(() => Stop()); // Raise Starting Event - Starting?.Invoke(this, new EventArgs()); + Starting.Raise(this, EventArgs.Empty, InternalError); _ = Task.Run(() => Run(_stop.Token)); } @@ -136,7 +136,7 @@ public void Start(CancellationToken cancellationToken) public void Stop() { // Raise Stopping Event - Stopping?.Invoke(this, new EventArgs()); + Stopping.Raise(this, EventArgs.Empty, InternalError); if (_stop != null) _stop.Cancel(); } @@ -167,7 +167,7 @@ public async Task Run(CancellationToken cancellationToken) responseTimer.Elapsed += (o, e) => { stop.Cancel(); - ConnectionError?.Invoke(this, new TimeoutException($"HTTP Stream Timeout Exceeded ({Timeout})")); + ConnectionError.Raise(this, new TimeoutException($"HTTP Stream Timeout Exceeded ({Timeout})"), InternalError); }; responseTimer.Start(); } @@ -177,7 +177,7 @@ public async Task Run(CancellationToken cancellationToken) stop.Token.ThrowIfCancellationRequested(); // Raise Started Event - Started?.Invoke(this, new EventArgs()); + Started.Raise(this, EventArgs.Empty, InternalError); // Add 'Accept' HTTP Header @@ -305,16 +305,16 @@ public async Task Run(CancellationToken cancellationToken) } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } finally { @@ -322,7 +322,7 @@ public async Task Run(CancellationToken cancellationToken) } } - Stopped?.Invoke(this, new EventArgs()); + Stopped.Raise(this, EventArgs.Empty, InternalError); } private static string GetHeaderValue(string s, string name) @@ -411,7 +411,7 @@ protected virtual void ProcessResponseBody(Stream responseBody, string contentEn var document = formatResult.Content; if (document != null) { - DocumentReceived?.Invoke(this, document); + DocumentReceived.Raise(this, document, InternalError); } else { @@ -420,19 +420,19 @@ protected virtual void ProcessResponseBody(Stream responseBody, string contentEn if (errorFormatResult.Success) { var errorDocument = errorFormatResult.Content; - if (errorDocument != null) ErrorReceived?.Invoke(this, errorDocument); + if (errorDocument != null) ErrorReceived.Raise(this, errorDocument, InternalError); } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, errorFormatResult); + FormatError.Raise(this, errorFormatResult, InternalError); } } } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, formatResult); + FormatError.Raise(this, formatResult, InternalError); } } } diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpCurrentClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpCurrentClient.cs index 5d681081c..859b13bd6 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpCurrentClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpCurrentClient.cs @@ -196,16 +196,16 @@ public IStreamsResponseDocument Get() } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -250,16 +250,16 @@ public async Task GetAsync(CancellationToken cancellat } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -348,7 +348,7 @@ private IStreamsResponseDocument HandleResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -366,7 +366,7 @@ private async Task HandleResponseAsync(HttpResponseMes { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -408,19 +408,19 @@ private IStreamsResponseDocument ReadDocument(HttpResponseMessage response, Stre if (errorFormatResult.Success) { var errorDocument = errorFormatResult.Content; - if (errorDocument != null) MTConnectError?.Invoke(this, errorDocument); + if (errorDocument != null) MTConnectError.Raise(this, errorDocument, InternalError); } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, errorFormatResult); + FormatError.Raise(this, errorFormatResult, InternalError); } } } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, formatResult); + FormatError.Raise(this, formatResult, InternalError); } } diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpProbeClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpProbeClient.cs index e34729c91..af90cebc4 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpProbeClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpProbeClient.cs @@ -236,16 +236,16 @@ public IDevicesResponseDocument Get() } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -297,16 +297,16 @@ public async Task GetAsync(CancellationToken cancellat } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -388,7 +388,7 @@ private IDevicesResponseDocument HandleResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -406,7 +406,7 @@ private async Task HandleResponseAsync(HttpResponseMes { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -448,19 +448,19 @@ private IDevicesResponseDocument ReadDocument(HttpResponseMessage response, Stre if (errorFormatResult.Success) { var errorDocument = errorFormatResult.Content; - if (errorDocument != null) MTConnectError?.Invoke(this, errorDocument); + if (errorDocument != null) MTConnectError.Raise(this, errorDocument, InternalError); } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, errorFormatResult); + FormatError.Raise(this, errorFormatResult, InternalError); } } } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, formatResult); + FormatError.Raise(this, formatResult, InternalError); } } diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpSampleClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpSampleClient.cs index 7a0535003..56ac4990c 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpSampleClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpSampleClient.cs @@ -213,16 +213,16 @@ public IStreamsResponseDocument Get() } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -267,16 +267,16 @@ public async Task GetAsync(CancellationToken cancel) } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (TaskCanceledException) { /* Ignore Task Cancelled */ } catch (HttpRequestException ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } return null; @@ -382,7 +382,7 @@ private IStreamsResponseDocument HandleResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -400,7 +400,7 @@ private async Task HandleResponseAsync(HttpResponseMes { if (!response.IsSuccessStatusCode) { - ConnectionError?.Invoke(this, new Exception(response.ReasonPhrase)); + ConnectionError.Raise(this, new Exception(response.ReasonPhrase), InternalError); } else if (response.Content != null) { @@ -442,19 +442,19 @@ private IStreamsResponseDocument ReadDocument(HttpResponseMessage response, Stre if (errorFormatResult.Success) { var errorDocument = errorFormatResult.Content; - if (errorDocument != null) MTConnectError?.Invoke(this, errorDocument); + if (errorDocument != null) MTConnectError.Raise(this, errorDocument, InternalError); } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, errorFormatResult); + FormatError.Raise(this, errorFormatResult, InternalError); } } } else { // Raise Format Error - if (FormatError != null) FormatError.Invoke(this, formatResult); + FormatError.Raise(this, formatResult, InternalError); } } diff --git a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpResponseHandler.cs b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpResponseHandler.cs index 8720606af..bad3ddd00 100644 --- a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpResponseHandler.cs +++ b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpResponseHandler.cs @@ -57,7 +57,7 @@ public async Task HandleAsync(IHttpContext context, CancellationToken canc { try { - ClientConnected?.Invoke(this, context.Request); + ClientConnected.Raise(this, context.Request, ClientException); // Get Accept-Encoding Header (ex. gzip, br) var acceptEncodings = GetRequestHeaderValues(context.Request, HttpHeaders.AcceptEncoding); @@ -66,9 +66,9 @@ public async Task HandleAsync(IHttpContext context, CancellationToken canc var mtconnectResponse = await OnRequestReceived(context, cancellationToken); mtconnectResponse.WriteDuration = await WriteResponse(mtconnectResponse, context.Response, acceptEncodings); - ResponseSent?.Invoke(this, mtconnectResponse); + ResponseSent.Raise(this, mtconnectResponse, ClientException); - ClientDisconnected?.Invoke(this, context.Request.RemoteEndPoint?.ToString()); + ClientDisconnected.Raise(this, context.Request.RemoteEndPoint?.ToString(), ClientException); return true; } @@ -77,13 +77,13 @@ public async Task HandleAsync(IHttpContext context, CancellationToken canc // Ignore Disposed Object Exception (happens when the listener is stopped) if (ex.ErrorCode != 995) { - if (ClientException != null) ClientException.Invoke(this, ex); + ClientException.Raise(this, ex, null); } } catch (ObjectDisposedException) { } catch (Exception ex) { - if (ClientException != null) ClientException.Invoke(this, ex); + ClientException.Raise(this, ex, null); } return false; @@ -225,7 +225,7 @@ protected async Task WriteFromStream(MTConnectHttpServerStream sampleStream, Str } catch (Exception) { - if (ClientDisconnected != null) ClientDisconnected.Invoke(this, sampleStream.Id); + ClientDisconnected.Raise(this, sampleStream.Id, ClientException); sampleStream.Stop(); } } @@ -243,7 +243,7 @@ protected async Task WriteFromStream(MTConnectHttpServerStream sampleStream, IHt } catch (Exception) { - if (ClientDisconnected != null) ClientDisconnected.Invoke(this, sampleStream.Id); + ClientDisconnected.Raise(this, sampleStream.Id, ClientException); sampleStream.Stop(); } } diff --git a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServerStream.cs b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServerStream.cs index f7e5a2d27..88a060f39 100644 --- a/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServerStream.cs +++ b/libraries/MTConnect.NET-HTTP/Servers/MTConnectHttpServerStream.cs @@ -204,7 +204,7 @@ private void Worker() { if (_mtconnectAgent != null) { - StreamStarted?.Invoke(this, _id); + StreamStarted.Raise(this, _id, StreamException); // Set Content Type based documentFormat specified var contentType = MimeTypes.Get(_documentFormat); @@ -265,7 +265,7 @@ private void Worker() stpw.Stop(); // Raise heartbeat event and include the Multipart Chunk - HeartbeatReceived?.Invoke(this, new MTConnectHttpStreamArgs(_id, outputStream, stpw.ElapsedMilliseconds)); + HeartbeatReceived.Raise(this, new MTConnectHttpStreamArgs(_id, outputStream, stpw.ElapsedMilliseconds), StreamException); // Reset the heartbeat timestamp lastHeartbeatSent = now; @@ -276,7 +276,7 @@ private void Worker() stpw.Stop(); // Raise heartbeat event and include the Multipart Chunk - DocumentReceived?.Invoke(this, new MTConnectHttpStreamArgs(_id, outputStream, stpw.ElapsedMilliseconds)); + DocumentReceived.Raise(this, new MTConnectHttpStreamArgs(_id, outputStream, stpw.ElapsedMilliseconds), StreamException); // Reset the document timestamp lastDocumentSent = now; @@ -297,11 +297,11 @@ private void Worker() } catch (Exception ex) { - StreamException?.Invoke(this, ex); + StreamException.Raise(this, ex, null); throw new Exception(); } - StreamStopped?.Invoke(this, _id); + StreamStopped.Raise(this, _id, StreamException); } } diff --git a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs index 61b2ea16b..afc7c6872 100644 --- a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs +++ b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttClient.cs @@ -228,7 +228,7 @@ public void Start() { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _ = Task.Run(Worker, _stop.Token); } @@ -239,7 +239,7 @@ public void Start() /// public void Stop() { - ClientStopping?.Invoke(this, new EventArgs()); + ClientStopping.Raise(this, EventArgs.Empty, InternalError); if (_stop != null) _stop.Cancel(); } @@ -353,7 +353,7 @@ private async Task Worker() await StartAllDevicesProtocol(); } - ClientStarted?.Invoke(this, new EventArgs()); + ClientStarted.Raise(this, EventArgs.Empty, InternalError); while (_mqttClient.IsConnected && !_stop.IsCancellationRequested) { @@ -362,7 +362,7 @@ private async Task Worker() } catch (Exception ex) { - if (ConnectionError != null) ConnectionError.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } await Task.Delay(_configuration.RetryInterval, _stop.Token); @@ -370,7 +370,7 @@ private async Task Worker() catch (TaskCanceledException) { } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } } while (!_stop.Token.IsCancellationRequested); @@ -384,7 +384,7 @@ private async Task Worker() catch { } - ClientStopped?.Invoke(this, new EventArgs()); + ClientStopped.Raise(this, EventArgs.Empty, InternalError); } @@ -511,11 +511,11 @@ private void ProcessProbeMessage(MqttApplicationMessage message) _devices.Add(outputDevice.Uuid, outputDevice); } - DeviceReceived?.Invoke(this, outputDevice); + DeviceReceived.Raise(this, outputDevice, InternalError); } } - ProbeReceived?.Invoke(this, responseDocument); + ProbeReceived.Raise(this, responseDocument, InternalError); } } } @@ -567,7 +567,7 @@ private void ProcessAssetMessage(MqttApplicationMessage message) private void ProcessCurrentDocument(IStreamsResponseDocument document) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); if (document != null) { @@ -585,7 +585,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document) response.Streams = deviceStreams; - CurrentReceived?.Invoke(this, response); + CurrentReceived.Raise(this, response, InternalError); // Process Device Streams @@ -610,7 +610,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document) { if (observation.Sequence > lastSequence) { - ObservationReceived?.Invoke(this, observation); + ObservationReceived.Raise(this, observation, InternalError); } } @@ -637,7 +637,7 @@ private void ProcessCurrentDocument(IStreamsResponseDocument document) private void ProcessSampleDocument(IStreamsResponseDocument document) { _lastResponse = UnixDateTime.Now; - ResponseReceived?.Invoke(this, new EventArgs()); + ResponseReceived.Raise(this, EventArgs.Empty, InternalError); if (document != null) { @@ -653,7 +653,7 @@ private void ProcessSampleDocument(IStreamsResponseDocument document) response.Streams = deviceStreams; - SampleReceived?.Invoke(this, response); + SampleReceived.Raise(this, response, InternalError); // Process Device Streams @@ -676,7 +676,7 @@ private void ProcessSampleDocument(IStreamsResponseDocument document) { if (observation.Sequence > lastSequence && observation.Sequence > lastCurrentSequence) { - ObservationReceived?.Invoke(this, observation); + ObservationReceived.Raise(this, observation, InternalError); } } @@ -699,11 +699,11 @@ private void ProcessAssetsDocument(IAssetsResponseDocument document) { if (document != null && !document.Assets.IsNullOrEmpty()) { - AssetsReceived?.Invoke(this, document); + AssetsReceived.Raise(this, document, InternalError); foreach (var asset in document.Assets) { - AssetReceived?.Invoke(this, asset); + AssetReceived.Raise(this, asset, InternalError); } } } diff --git a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs index 144516178..917535888 100644 --- a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs +++ b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs @@ -248,7 +248,7 @@ public void Start() { _stop = new CancellationTokenSource(); - ClientStarting?.Invoke(this, new EventArgs()); + ClientStarting.Raise(this, EventArgs.Empty, InternalError); _ = Task.Run(Worker, _stop.Token); } @@ -256,7 +256,7 @@ public void Start() /// Signals the worker to stop and disconnect from the broker. is raised once the session has closed. public void Stop() { - ClientStopping?.Invoke(this, new EventArgs()); + ClientStopping.Raise(this, EventArgs.Empty, InternalError); if (_stop != null) _stop.Cancel(); } @@ -341,7 +341,7 @@ private async Task Worker() StartAllDevicesProtocol().Wait(); } - ClientStarted?.Invoke(this, new EventArgs()); + ClientStarted.Raise(this, EventArgs.Empty, InternalError); while (_mqttClient.IsConnected && !_stop.IsCancellationRequested) { @@ -350,7 +350,7 @@ private async Task Worker() } catch (Exception ex) { - if (ConnectionError != null) ConnectionError.Invoke(this, ex); + ConnectionError.Raise(this, ex, InternalError); } await Task.Delay(ReconnectionInterval, _stop.Token); @@ -358,7 +358,7 @@ private async Task Worker() catch (TaskCanceledException) { } catch (Exception ex) { - InternalError?.Invoke(this, ex); + InternalError.Raise(this, ex, InternalError); } } while (!_stop.Token.IsCancellationRequested); @@ -372,7 +372,7 @@ private async Task Worker() catch { } - ClientStopped?.Invoke(this, new EventArgs()); + ClientStopped.Raise(this, EventArgs.Empty, InternalError); } @@ -532,10 +532,7 @@ private async void ProcessObservation(MqttApplicationMessage message) if (observation.InstanceId == agentInstanceId) { - if (ObservationReceived != null) - { - ObservationReceived.Invoke(deviceUuid, observation); - } + ObservationReceived.Raise(deviceUuid, observation, InternalError); } else { @@ -587,10 +584,7 @@ private void ProcessObservations(MqttApplicationMessage message) observation.AddValue(ValueKeys.Result, jsonObservation.Result); } - if (ObservationReceived != null) - { - ObservationReceived.Invoke(deviceUuid, observation); - } + ObservationReceived.Raise(deviceUuid, observation, InternalError); } } } @@ -662,7 +656,7 @@ private async Task ProcessAgent(MqttApplicationMessage message, Func(clientId, exception)); + ConnectionError.Raise(this, new AdapterEventArgs(clientId, exception), null); } #endregion @@ -601,11 +601,11 @@ private bool WriteLineToClient(AgentClient client, string line) // Write the line (in bytes) to the Stream stream.Write(bytes, 0, bytes.Length); - LineSent?.Invoke(this, new AdapterEventArgs(client.Id, singleLine)); + LineSent.Raise(this, new AdapterEventArgs(client.Id, singleLine), null); } catch (Exception ex) { - SendError?.Invoke(this, new AdapterEventArgs(client.Id, ex.Message)); + SendError.Raise(this, new AdapterEventArgs(client.Id, ex.Message), null); return false; } } @@ -634,13 +634,13 @@ private async Task WriteLineToClientAsync(AgentClient client, string line) // Write the line (in bytes) to the Stream await stream.WriteAsync(bytes, 0, bytes.Length); - LineSent?.Invoke(this, new AdapterEventArgs(client.Id, line)); + LineSent.Raise(this, new AdapterEventArgs(client.Id, line), null); return true; } catch (Exception ex) { - SendError?.Invoke(this, new AdapterEventArgs(client.Id, ex.Message)); + SendError.Raise(this, new AdapterEventArgs(client.Id, ex.Message), null); } } diff --git a/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs b/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs index 88d319caa..a9c516012 100644 --- a/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs +++ b/libraries/MTConnect.NET-SHDR/Shdr/ShdrClient.cs @@ -258,7 +258,7 @@ private async Task ListenForAdapter(CancellationToken cancel) _client.ReceiveTimeout = ConnectionTimeout; _client.SendTimeout = ConnectionTimeout; - Connected?.Invoke(this, $"Connected to Adapter at {Hostname} on Port {Port}"); + Connected.Raise(this, $"Connected to Adapter at {Hostname} on Port {Port}", null); connected = true; OnConnect(); @@ -269,7 +269,7 @@ private async Task ListenForAdapter(CancellationToken cancel) // Send Initial PING Request var messageBytes = Encoding.ASCII.GetBytes(PingMessage); stream.Write(messageBytes, 0, messageBytes.Length); - PingSent?.Invoke(this, $"Initial PING sent to : {Hostname} on Port {Port}"); + PingSent.Raise(this, $"Initial PING sent to : {Hostname} on Port {Port}", null); // Read the Initial PONG Response bufferIndex = stream.Read(buffer, 0, buffer.Length); @@ -297,7 +297,7 @@ private async Task ListenForAdapter(CancellationToken cancel) { messageBytes = Encoding.ASCII.GetBytes(PingMessage); stream.Write(messageBytes, 0, messageBytes.Length); - PingSent?.Invoke(this, $"PING sent to : {Hostname} on Port {Port}"); + PingSent.Raise(this, $"PING sent to : {Hostname} on Port {Port}", null); _lastHeartbeat = now; } @@ -318,7 +318,7 @@ private async Task ListenForAdapter(CancellationToken cancel) catch (TaskCanceledException) { } catch (Exception ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, null); } finally { @@ -329,7 +329,7 @@ private async Task ListenForAdapter(CancellationToken cancel) if (connected) { - Disconnected?.Invoke(this, $"Disconnected from {Hostname} on Port {Port}"); + Disconnected.Raise(this, $"Disconnected from {Hostname} on Port {Port}", null); OnDisconnect(); } @@ -341,13 +341,13 @@ private async Task ListenForAdapter(CancellationToken cancel) // Wait for the ReconnectInterval (in milliseconds) until continuing while loop await Task.Delay(reconnectInterval, cancel); - Listening?.Invoke(this, $"Listening for connection from {Hostname} on Port {Port}"); + Listening.Raise(this, $"Listening for connection from {Hostname} on Port {Port}", null); } } catch (TaskCanceledException) { } catch (Exception ex) { - ConnectionError?.Invoke(this, ex); + ConnectionError.Raise(this, ex, null); if (_client != null) { @@ -358,7 +358,7 @@ private async Task ListenForAdapter(CancellationToken cancel) if (connected) { - Disconnected?.Invoke(this, $"Disconnected from {Hostname} on Port {Port}"); + Disconnected.Raise(this, $"Disconnected from {Hostname} on Port {Port}", null); OnDisconnect(); } @@ -405,7 +405,7 @@ private bool ProcessResponse(ref char[] chars, int length) { _heartbeat = GetPongHeartbeat(line); - PongReceived?.Invoke(this, $"PONG Received from : {Hostname} on Port {Port} : Heartbeat = {_heartbeat}ms"); + PongReceived.Raise(this, $"PONG Received from : {Hostname} on Port {Port} : Heartbeat = {_heartbeat}ms", null); } else { @@ -429,7 +429,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineAsset = true; // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -449,7 +449,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineAsset = false; // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -458,7 +458,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineContent.Append(line); // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -485,7 +485,7 @@ private bool ProcessResponse(ref char[] chars, int length) } // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -499,7 +499,7 @@ private bool ProcessResponse(ref char[] chars, int length) } // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -513,7 +513,7 @@ private bool ProcessResponse(ref char[] chars, int length) } // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -526,7 +526,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineDevice = true; // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -542,7 +542,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineDevice = false; // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -551,7 +551,7 @@ private bool ProcessResponse(ref char[] chars, int length) multilineContent.Append(line); // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -564,7 +564,7 @@ private bool ProcessResponse(ref char[] chars, int length) } // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } @@ -573,7 +573,7 @@ private bool ProcessResponse(ref char[] chars, int length) ProcessProtocol(line); // Raise ProtocolReceived Event passing the Line that was read as a parameter - ProtocolReceived?.Invoke(this, line); + ProtocolReceived.Raise(this, line, null); found = true; } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentMulticastIsolationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentMulticastIsolationTests.cs new file mode 100644 index 000000000..71d570b2f --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentMulticastIsolationTests.cs @@ -0,0 +1,605 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using MTConnect.Assets; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Errors; +using MTConnect.Input; +using MTConnect.Observations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common +{ + /// + /// Pins the multicast-isolation contract for every event raised by + /// MTConnectAgent and MTConnectAgentBroker: + /// sites on the agent (DeviceAdded, + /// ObservationReceived, ObservationAdded, AssetReceived, AssetAdded), the + /// custom-delegate validation events on the agent (InvalidDeviceAdded, + /// InvalidComponentAdded, InvalidCompositionAdded, InvalidDataItemAdded, + /// InvalidObservationAdded, InvalidAssetAdded), the single + /// site on the broker (StreamsResponseSent), + /// and the custom-delegate request / response events on the broker + /// (DevicesRequestReceived, DevicesResponseSent, StreamsRequestReceived, + /// AssetsRequestReceived, DeviceAssetsRequestReceived, AssetsResponseSent, + /// ErrorResponseSent). After migration all these sites use + /// / + /// / + /// + /// passing null as the internalError sink — neither agent + /// class declares an InternalError event, so faults are swallowed at the + /// per-delegate boundary (consistent with the pre-isolation null-conditional + /// behaviour). + /// + [TestFixture] + public class AgentMulticastIsolationTests + { + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (DeviceAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{IDevice} subscriber fires even when the first throws, covering DeviceAdded on MTConnectAgent. + [Test] + public void Agent_DeviceAdded_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var device = new Device { Name = "device-1", Uuid = "uuid-1" }; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first DeviceAdded subscriber throws"); + handler += (_, d) => received.Add(d.Uuid ?? "null"); + + handler!.Raise(this, (IDevice)device, null); + + Assert.That(received, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a DeviceAdded subscriber is swallowed and does not escape to the caller. + [Test] + public void Agent_DeviceAdded_NullInternalErrorSwallowsFault() + { + var device = new Device { Name = "device-1", Uuid = "uuid-1" }; + EventHandler handler = (_, _) => throw new InvalidOperationException("DeviceAdded fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, (IDevice)device, null)); + } + + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (ObservationReceived on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{IObservationInput} subscriber fires even when the first throws, covering ObservationReceived on MTConnectAgent. + [Test] + public void Agent_ObservationReceived_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ObservationReceived subscriber throws"); + handler += (_, _) => firedCount++; + + var obs = new ObservationInput(); + handler!.Raise(this, (IObservationInput)obs, null); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an ObservationReceived subscriber is swallowed without escaping. + [Test] + public void Agent_ObservationReceived_NullInternalErrorSwallowsFault() + { + var obs = new ObservationInput(); + EventHandler handler = (_, _) => throw new InvalidOperationException("ObservationReceived fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, (IObservationInput)obs, null)); + } + + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (ObservationAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{IObservation} subscriber fires even when the first throws, covering ObservationAdded on MTConnectAgent. + [Test] + public void Agent_ObservationAdded_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ObservationAdded subscriber throws"); + handler += (_, _) => firedCount++; + + var obs = new Observation(); + handler!.Raise(this, (IObservation)obs, null); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an ObservationAdded subscriber is swallowed without escaping. + [Test] + public void Agent_ObservationAdded_NullInternalErrorSwallowsFault() + { + var obs = new Observation(); + EventHandler handler = (_, _) => throw new InvalidOperationException("ObservationAdded fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, (IObservation)obs, null)); + } + + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (AssetAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{IAsset} subscriber fires even when the first throws, covering AssetAdded on MTConnectAgent. + [Test] + public void Agent_AssetAdded_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first AssetAdded subscriber throws"); + handler += (_, _) => firedCount++; + + var asset = new Asset { AssetId = "a1", Timestamp = DateTime.UtcNow }; + handler!.Raise(this, (IAsset)asset, null); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an AssetAdded subscriber is swallowed without escaping. + [Test] + public void Agent_AssetAdded_NullInternalErrorSwallowsFault() + { + var asset = new Asset { AssetId = "a1", Timestamp = DateTime.UtcNow }; + EventHandler handler = (_, _) => throw new InvalidOperationException("AssetAdded fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, (IAsset)asset, null)); + } + + // ----------------------------------------------------------------------- + // EventHandler (non-generic) raise sites + // (StreamsResponseSent on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second non-generic EventHandler subscriber fires even when the first throws, covering StreamsResponseSent on MTConnectAgentBroker. + [Test] + public void AgentBroker_StreamsResponseSent_FiresAllSubscribersWhenOneThrows() + { + var fired = new List(); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first StreamsResponseSent subscriber throws"); + handler += (_, _) => fired.Add(1); + + handler!.Raise(this, EventArgs.Empty, null); + + Assert.That(fired, Is.EqualTo(new[] { 1 })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a StreamsResponseSent subscriber is swallowed without escaping. + [Test] + public void AgentBroker_StreamsResponseSent_NullInternalErrorSwallowsFault() + { + EventHandler handler = (_, _) => throw new InvalidOperationException("StreamsResponseSent fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, EventArgs.Empty, null)); + } + + // ----------------------------------------------------------------------- + // Null-handler guard (both generic and non-generic) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: Raise with a null EventHandler{IDevice} handler is a safe no-op covering the no-subscriber case at runtime. + [Test] + public void Agent_NullGenericHandler_DoesNotThrow() + { + EventHandler? handler = null; + var device = new Device { Name = "noop-device", Uuid = "noop-uuid" }; + + Assert.DoesNotThrow(() => handler.Raise(this, (IDevice)device, null)); + } + + /// Pins the behavior expressed by the test name: Raise with a null non-generic EventHandler is a safe no-op covering the no-subscriber case at runtime. + [Test] + public void AgentBroker_NullNonGenericHandler_DoesNotThrow() + { + EventHandler? handler = null; + + Assert.DoesNotThrow(() => handler.Raise(this, EventArgs.Empty, null)); + } + + // ======================================================================= + // Custom-delegate raise sites — agent + // ======================================================================= + + // ----------------------------------------------------------------------- + // MTConnectDeviceValidationHandler (InvalidDeviceAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectDeviceValidationHandler subscriber fires even when the first throws, covering InvalidDeviceAdded on MTConnectAgent. + [Test] + public void Agent_InvalidDeviceAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var device = new Device { Name = "d1", Uuid = "uuid-1" }; + var result = new ValidationResult(false, "bad device"); + + MTConnectDeviceValidationHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first InvalidDeviceAdded subscriber throws"); + handler += (d, _) => seen.Add(d.Uuid ?? "null"); + + MulticastIsolation.Raise(handler!, h => h(device, result)); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidDeviceAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidDeviceAdded_NullInternalErrorSwallowsFault() + { + var device = new Device { Name = "d1", Uuid = "uuid-1" }; + var result = new ValidationResult(false, "bad device"); + MTConnectDeviceValidationHandler handler = (_, _) => throw new InvalidOperationException("InvalidDeviceAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(device, result))); + } + + // ----------------------------------------------------------------------- + // MTConnectComponentValidationHandler (InvalidComponentAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectComponentValidationHandler subscriber fires even when the first throws, covering InvalidComponentAdded on MTConnectAgent. + [Test] + public void Agent_InvalidComponentAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var component = new Component { Id = "c1" }; + var result = new ValidationResult(false, "bad component"); + + MTConnectComponentValidationHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first InvalidComponentAdded subscriber throws"); + handler += (uuid, _, _) => seen.Add(uuid); + + MulticastIsolation.Raise(handler!, h => h("uuid-1", component, result)); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidComponentAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidComponentAdded_NullInternalErrorSwallowsFault() + { + var component = new Component { Id = "c1" }; + var result = new ValidationResult(false, "bad component"); + MTConnectComponentValidationHandler handler = (_, _, _) => throw new InvalidOperationException("InvalidComponentAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1", component, result))); + } + + // ----------------------------------------------------------------------- + // MTConnectCompositionValidationHandler (InvalidCompositionAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectCompositionValidationHandler subscriber fires even when the first throws, covering InvalidCompositionAdded on MTConnectAgent. + [Test] + public void Agent_InvalidCompositionAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var composition = new Composition { Id = "cm1" }; + var result = new ValidationResult(false, "bad composition"); + + MTConnectCompositionValidationHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first InvalidCompositionAdded subscriber throws"); + handler += (uuid, _, _) => seen.Add(uuid); + + MulticastIsolation.Raise(handler!, h => h("uuid-1", composition, result)); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidCompositionAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidCompositionAdded_NullInternalErrorSwallowsFault() + { + var composition = new Composition { Id = "cm1" }; + var result = new ValidationResult(false, "bad composition"); + MTConnectCompositionValidationHandler handler = (_, _, _) => throw new InvalidOperationException("InvalidCompositionAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1", composition, result))); + } + + // ----------------------------------------------------------------------- + // MTConnectDataItemValidationHandler (InvalidDataItemAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectDataItemValidationHandler subscriber fires even when the first throws, covering InvalidDataItemAdded on MTConnectAgent. + [Test] + public void Agent_InvalidDataItemAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var dataItem = new DataItem { Id = "di1" }; + var result = new ValidationResult(false, "bad data item"); + + MTConnectDataItemValidationHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first InvalidDataItemAdded subscriber throws"); + handler += (uuid, _, _) => seen.Add(uuid); + + MulticastIsolation.Raise(handler!, h => h("uuid-1", dataItem, result)); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidDataItemAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidDataItemAdded_NullInternalErrorSwallowsFault() + { + var dataItem = new DataItem { Id = "di1" }; + var result = new ValidationResult(false, "bad data item"); + MTConnectDataItemValidationHandler handler = (_, _, _) => throw new InvalidOperationException("InvalidDataItemAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1", dataItem, result))); + } + + // ----------------------------------------------------------------------- + // MTConnectObservationValidationHandler (InvalidObservationAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectObservationValidationHandler subscriber fires even when the first throws, covering InvalidObservationAdded on MTConnectAgent. + [Test] + public void Agent_InvalidObservationAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var result = new ValidationResult(false, "bad observation"); + + MTConnectObservationValidationHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first InvalidObservationAdded subscriber throws"); + handler += (uuid, _, _) => seen.Add(uuid); + + MulticastIsolation.Raise(handler!, h => h("uuid-1", "key-1", result)); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidObservationAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidObservationAdded_NullInternalErrorSwallowsFault() + { + var result = new ValidationResult(false, "bad observation"); + MTConnectObservationValidationHandler handler = (_, _, _) => throw new InvalidOperationException("InvalidObservationAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1", "key-1", result))); + } + + // ----------------------------------------------------------------------- + // MTConnectAssetValidationHandler (InvalidAssetAdded on MTConnectAgent) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectAssetValidationHandler subscriber fires even when the first throws, covering InvalidAssetAdded on MTConnectAgent. + [Test] + public void Agent_InvalidAssetAdded_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var asset = new Asset { AssetId = "a1", Timestamp = DateTime.UtcNow }; + var result = new ValidationResult(false, "bad asset"); + + MTConnectAssetValidationHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first InvalidAssetAdded subscriber throws"); + handler += (a, _) => seen.Add(a.AssetId ?? "null"); + + MulticastIsolation.Raise(handler!, h => h(asset, result)); + + Assert.That(seen, Is.EqualTo(new[] { "a1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an InvalidAssetAdded subscriber is swallowed without escaping. + [Test] + public void Agent_InvalidAssetAdded_NullInternalErrorSwallowsFault() + { + var asset = new Asset { AssetId = "a1", Timestamp = DateTime.UtcNow }; + var result = new ValidationResult(false, "bad asset"); + MTConnectAssetValidationHandler handler = (_, _) => throw new InvalidOperationException("InvalidAssetAdded fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(asset, result))); + } + + // ======================================================================= + // Custom-delegate raise sites — broker + // ======================================================================= + + // ----------------------------------------------------------------------- + // MTConnectDevicesRequestedHandler (DevicesRequestReceived on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectDevicesRequestedHandler subscriber fires even when the first throws, covering DevicesRequestReceived on MTConnectAgentBroker. + [Test] + public void AgentBroker_DevicesRequestReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + MTConnectDevicesRequestedHandler? handler = null; + handler += _ => throw new InvalidOperationException("first DevicesRequestReceived subscriber throws"); + handler += u => seen.Add(u); + + MulticastIsolation.Raise(handler!, h => h("uuid-1")); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a DevicesRequestReceived subscriber is swallowed without escaping. + [Test] + public void AgentBroker_DevicesRequestReceived_NullInternalErrorSwallowsFault() + { + MTConnectDevicesRequestedHandler handler = _ => throw new InvalidOperationException("DevicesRequestReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1"))); + } + + // ----------------------------------------------------------------------- + // MTConnectDevicesHandler (DevicesResponseSent on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectDevicesHandler subscriber fires even when the first throws, covering DevicesResponseSent on MTConnectAgentBroker. + [Test] + public void AgentBroker_DevicesResponseSent_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + MTConnectDevicesHandler? handler = null; + handler += _ => throw new InvalidOperationException("first DevicesResponseSent subscriber throws"); + handler += _ => firedCount++; + + MulticastIsolation.Raise(handler!, h => h(null!)); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a DevicesResponseSent subscriber is swallowed without escaping. + [Test] + public void AgentBroker_DevicesResponseSent_NullInternalErrorSwallowsFault() + { + MTConnectDevicesHandler handler = _ => throw new InvalidOperationException("DevicesResponseSent fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(null!))); + } + + // ----------------------------------------------------------------------- + // MTConnectStreamsRequestedHandler (StreamsRequestReceived on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectStreamsRequestedHandler subscriber fires even when the first throws, covering StreamsRequestReceived on MTConnectAgentBroker. + [Test] + public void AgentBroker_StreamsRequestReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + MTConnectStreamsRequestedHandler? handler = null; + handler += _ => throw new InvalidOperationException("first StreamsRequestReceived subscriber throws"); + handler += u => seen.Add(u); + + MulticastIsolation.Raise(handler!, h => h("uuid-1")); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a StreamsRequestReceived subscriber is swallowed without escaping. + [Test] + public void AgentBroker_StreamsRequestReceived_NullInternalErrorSwallowsFault() + { + MTConnectStreamsRequestedHandler handler = _ => throw new InvalidOperationException("StreamsRequestReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1"))); + } + + // ----------------------------------------------------------------------- + // MTConnectAssetsRequestedHandler (AssetsRequestReceived on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectAssetsRequestedHandler subscriber fires even when the first throws, covering AssetsRequestReceived on MTConnectAgentBroker. + [Test] + public void AgentBroker_AssetsRequestReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var ids = new[] { "asset-1" }; + MTConnectAssetsRequestedHandler? handler = null; + handler += _ => throw new InvalidOperationException("first AssetsRequestReceived subscriber throws"); + handler += list => { foreach (var _ in list) seen.Add(1); }; + + MulticastIsolation.Raise(handler!, h => h(ids)); + + Assert.That(seen, Is.EqualTo(new[] { 1 })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an AssetsRequestReceived subscriber is swallowed without escaping. + [Test] + public void AgentBroker_AssetsRequestReceived_NullInternalErrorSwallowsFault() + { + var ids = new[] { "asset-1" }; + MTConnectAssetsRequestedHandler handler = _ => throw new InvalidOperationException("AssetsRequestReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(ids))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceAssetsRequestedHandler (DeviceAssetsRequestReceived on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectDeviceAssetsRequestedHandler subscriber fires even when the first throws, covering DeviceAssetsRequestReceived on MTConnectAgentBroker. + [Test] + public void AgentBroker_DeviceAssetsRequestReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + MTConnectDeviceAssetsRequestedHandler? handler = null; + handler += _ => throw new InvalidOperationException("first DeviceAssetsRequestReceived subscriber throws"); + handler += u => seen.Add(u); + + MulticastIsolation.Raise(handler!, h => h("uuid-1")); + + Assert.That(seen, Is.EqualTo(new[] { "uuid-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a DeviceAssetsRequestReceived subscriber is swallowed without escaping. + [Test] + public void AgentBroker_DeviceAssetsRequestReceived_NullInternalErrorSwallowsFault() + { + MTConnectDeviceAssetsRequestedHandler handler = _ => throw new InvalidOperationException("DeviceAssetsRequestReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h("uuid-1"))); + } + + // ----------------------------------------------------------------------- + // MTConnectAssetsHandler (AssetsResponseSent on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectAssetsHandler subscriber fires even when the first throws, covering AssetsResponseSent on MTConnectAgentBroker. + [Test] + public void AgentBroker_AssetsResponseSent_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + MTConnectAssetsHandler? handler = null; + handler += _ => throw new InvalidOperationException("first AssetsResponseSent subscriber throws"); + handler += _ => firedCount++; + + MulticastIsolation.Raise(handler!, h => h(null!)); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an AssetsResponseSent subscriber is swallowed without escaping. + [Test] + public void AgentBroker_AssetsResponseSent_NullInternalErrorSwallowsFault() + { + MTConnectAssetsHandler handler = _ => throw new InvalidOperationException("AssetsResponseSent fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(null!))); + } + + // ----------------------------------------------------------------------- + // MTConnectErrorHandler (ErrorResponseSent on MTConnectAgentBroker) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second MTConnectErrorHandler subscriber fires even when the first throws, covering ErrorResponseSent on MTConnectAgentBroker. + [Test] + public void AgentBroker_ErrorResponseSent_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + MTConnectErrorHandler? handler = null; + handler += _ => throw new InvalidOperationException("first ErrorResponseSent subscriber throws"); + handler += _ => firedCount++; + + MulticastIsolation.Raise(handler!, h => h((IErrorResponseDocument)null!)); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an ErrorResponseSent subscriber is swallowed without escaping. + [Test] + public void AgentBroker_ErrorResponseSent_NullInternalErrorSwallowsFault() + { + MTConnectErrorHandler handler = _ => throw new InvalidOperationException("ErrorResponseSent fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h((IErrorResponseDocument)null!))); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/DeviceFinderMulticastIsolationTests.cs b/tests/MTConnect.NET-Common-Tests/DeviceFinderMulticastIsolationTests.cs new file mode 100644 index 000000000..ae37ba301 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/DeviceFinderMulticastIsolationTests.cs @@ -0,0 +1,326 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.NetworkInformation; +using NUnit.Framework; + +namespace MTConnect.Tests.Common +{ + /// + /// Pins the multicast-isolation contract for the custom-delegate events + /// declared on MTConnectDeviceFinder and PingQueue. Neither + /// class can use the typed / + /// overloads because every event is declared + /// with a custom delegate signature (DeviceHandler, + /// PingSentHandler, PingReceivedHandler, + /// PortRequestHandler, ProbeRequestHandler, + /// RequestStatusHandler, CompletedHandler). After migration + /// every raise site uses + /// + /// passing the per-subscriber invocation lambda inline and null as + /// the internalError sink (neither class declares an InternalError + /// event). These tests redeclare each delegate shape locally and verify + /// the isolation guarantee holds for every signature; the helper contract + /// is independent of the originating class. + /// + [TestFixture] + public class DeviceFinderMulticastIsolationTests + { + // ----------------------------------------------------------------------- + // Local delegate shapes mirroring the production declarations on + // MTConnectDeviceFinder and PingQueue. Same signatures; redeclared + // locally to keep this test project free of a DeviceFinder project ref. + // ----------------------------------------------------------------------- + + /// Mirror of MTConnectDeviceFinder.DeviceHandler — fired by DeviceFound when an MTConnect agent is positively identified at an address/port pair. + private delegate void TestDeviceHandler(object sender, object device); + + /// Mirror of MTConnectDeviceFinder.RequestStatusHandler — fired by SearchCompleted; carries the elapsed milliseconds of the search. + private delegate void TestRequestStatusHandler(object sender, long milliseconds); + + /// Mirror of MTConnectDeviceFinder.PingSentHandler — fired once a ping has been dispatched to . + private delegate void TestPingSentHandlerOnFinder(object sender, IPAddress address); + + /// Mirror of MTConnectDeviceFinder.PingReceivedHandler — fired when a ping reply returns from . + private delegate void TestPingReceivedHandlerOnFinder(object sender, IPAddress address, PingReply reply); + + /// Mirror of MTConnectDeviceFinder.PortRequestHandler — fired by PortOpened/PortClosed to report the state of a TCP port at . + private delegate void TestPortRequestHandler(object sender, IPAddress address, int port); + + /// Mirror of MTConnectDeviceFinder.ProbeRequestHandler — fired by ProbeSent/ProbeSuccessful/ProbeError for each MTConnect probe attempt. + private delegate void TestProbeRequestHandler(object sender, IPAddress address, int port); + + /// Mirror of PingQueue.PingSentHandler — fired once a ping has been dispatched to ; no sender argument. + private delegate void TestPingSentHandlerOnQueue(IPAddress address); + + /// Mirror of PingQueue.PingReceivedHandler — fired when a ping reply returns; no sender argument. + private delegate void TestPingReceivedHandlerOnQueue(IPAddress address, PingReply reply); + + /// Mirror of PingQueue.CompletedHandler — fired when the queue drains, with the list of successful addresses. + private delegate void TestCompletedHandler(List successfulAddresses); + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.DeviceFound (DeviceHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second DeviceHandler subscriber fires even when the first throws, covering DeviceFound on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_DeviceFound_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + TestDeviceHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first DeviceFound subscriber throws"); + handler += (_, d) => seen.Add(d); + + MulticastIsolation.Raise(handler!, h => h(this, "device-1")); + + Assert.That(seen, Is.EqualTo(new object[] { "device-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a DeviceFound subscriber is swallowed without escaping. + [Test] + public void DeviceFinder_DeviceFound_NullInternalErrorSwallowsFault() + { + TestDeviceHandler handler = (_, _) => throw new InvalidOperationException("DeviceFound fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, "device-1"))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.SearchCompleted (RequestStatusHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second RequestStatusHandler subscriber fires even when the first throws, covering SearchCompleted on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_SearchCompleted_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + TestRequestStatusHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first SearchCompleted subscriber throws"); + handler += (_, ms) => seen.Add(ms); + + MulticastIsolation.Raise(handler!, h => h(this, 42L)); + + Assert.That(seen, Is.EqualTo(new[] { 42L })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a SearchCompleted subscriber is swallowed without escaping. + [Test] + public void DeviceFinder_SearchCompleted_NullInternalErrorSwallowsFault() + { + TestRequestStatusHandler handler = (_, _) => throw new InvalidOperationException("SearchCompleted fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, 0L))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.PingSent (PingSentHandler, finder-shape) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second PingSentHandler subscriber fires even when the first throws, covering PingSent on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_PingSent_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var addr = IPAddress.Loopback; + TestPingSentHandlerOnFinder? handler = null; + handler += (_, _) => throw new InvalidOperationException("first PingSent subscriber throws"); + handler += (_, a) => seen.Add(a); + + MulticastIsolation.Raise(handler!, h => h(this, addr)); + + Assert.That(seen, Is.EqualTo(new[] { addr })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a PingSent subscriber on the finder is swallowed without escaping. + [Test] + public void DeviceFinder_PingSent_NullInternalErrorSwallowsFault() + { + TestPingSentHandlerOnFinder handler = (_, _) => throw new InvalidOperationException("PingSent fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, IPAddress.Loopback))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.PingReceived (PingReceivedHandler, finder-shape) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second PingReceivedHandler subscriber fires even when the first throws, covering PingReceived on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_PingReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var addr = IPAddress.Loopback; + TestPingReceivedHandlerOnFinder? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first PingReceived subscriber throws"); + handler += (_, a, _) => seen.Add(a); + + MulticastIsolation.Raise(handler!, h => h(this, addr, null!)); + + Assert.That(seen, Is.EqualTo(new[] { addr })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a PingReceived subscriber on the finder is swallowed without escaping. + [Test] + public void DeviceFinder_PingReceived_NullInternalErrorSwallowsFault() + { + TestPingReceivedHandlerOnFinder handler = (_, _, _) => throw new InvalidOperationException("PingReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, IPAddress.Loopback, null!))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.PortOpened / PortClosed (PortRequestHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second PortRequestHandler subscriber fires even when the first throws, covering PortOpened/PortClosed on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_PortRequest_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + TestPortRequestHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first PortRequest subscriber throws"); + handler += (_, _, p) => seen.Add(p); + + MulticastIsolation.Raise(handler!, h => h(this, IPAddress.Loopback, 5000)); + + Assert.That(seen, Is.EqualTo(new[] { 5000 })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a PortRequestHandler subscriber is swallowed without escaping. + [Test] + public void DeviceFinder_PortRequest_NullInternalErrorSwallowsFault() + { + TestPortRequestHandler handler = (_, _, _) => throw new InvalidOperationException("PortRequest fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, IPAddress.Loopback, 5000))); + } + + // ----------------------------------------------------------------------- + // MTConnectDeviceFinder.ProbeSent / ProbeSuccessful (ProbeRequestHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second ProbeRequestHandler subscriber fires even when the first throws, covering ProbeSent/ProbeSuccessful on MTConnectDeviceFinder. + [Test] + public void DeviceFinder_ProbeRequest_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + TestProbeRequestHandler? handler = null; + handler += (_, _, _) => throw new InvalidOperationException("first ProbeRequest subscriber throws"); + handler += (_, _, p) => seen.Add(p); + + MulticastIsolation.Raise(handler!, h => h(this, IPAddress.Loopback, 5000)); + + Assert.That(seen, Is.EqualTo(new[] { 5000 })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a ProbeRequestHandler subscriber is swallowed without escaping. + [Test] + public void DeviceFinder_ProbeRequest_NullInternalErrorSwallowsFault() + { + TestProbeRequestHandler handler = (_, _, _) => throw new InvalidOperationException("ProbeRequest fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(this, IPAddress.Loopback, 5000))); + } + + // ----------------------------------------------------------------------- + // PingQueue.PingSent (PingSentHandler, queue-shape: single IPAddress arg) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second queue-shape PingSentHandler subscriber fires even when the first throws, covering PingSent on PingQueue. + [Test] + public void PingQueue_PingSent_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var addr = IPAddress.Loopback; + TestPingSentHandlerOnQueue? handler = null; + handler += _ => throw new InvalidOperationException("first PingQueue.PingSent subscriber throws"); + handler += a => seen.Add(a); + + MulticastIsolation.Raise(handler!, h => h(addr)); + + Assert.That(seen, Is.EqualTo(new[] { addr })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a queue-shape PingSent subscriber is swallowed without escaping. + [Test] + public void PingQueue_PingSent_NullInternalErrorSwallowsFault() + { + TestPingSentHandlerOnQueue handler = _ => throw new InvalidOperationException("PingQueue.PingSent fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(IPAddress.Loopback))); + } + + // ----------------------------------------------------------------------- + // PingQueue.PingReceived (PingReceivedHandler, queue-shape) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second queue-shape PingReceivedHandler subscriber fires even when the first throws, covering PingReceived on PingQueue. + [Test] + public void PingQueue_PingReceived_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var addr = IPAddress.Loopback; + TestPingReceivedHandlerOnQueue? handler = null; + handler += (_, _) => throw new InvalidOperationException("first PingQueue.PingReceived subscriber throws"); + handler += (a, _) => seen.Add(a); + + MulticastIsolation.Raise(handler!, h => h(addr, null!)); + + Assert.That(seen, Is.EqualTo(new[] { addr })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a queue-shape PingReceived subscriber is swallowed without escaping. + [Test] + public void PingQueue_PingReceived_NullInternalErrorSwallowsFault() + { + TestPingReceivedHandlerOnQueue handler = (_, _) => throw new InvalidOperationException("PingQueue.PingReceived fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(IPAddress.Loopback, null!))); + } + + // ----------------------------------------------------------------------- + // PingQueue.Completed (CompletedHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second CompletedHandler subscriber fires even when the first throws, covering Completed on PingQueue. + [Test] + public void PingQueue_Completed_FiresAllSubscribersWhenOneThrows() + { + var seen = new List(); + var addresses = new List { IPAddress.Loopback }; + TestCompletedHandler? handler = null; + handler += _ => throw new InvalidOperationException("first PingQueue.Completed subscriber throws"); + handler += list => seen.Add(list.Count); + + MulticastIsolation.Raise(handler!, h => h(addresses)); + + Assert.That(seen, Is.EqualTo(new[] { 1 })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a Completed subscriber on PingQueue is swallowed without escaping. + [Test] + public void PingQueue_Completed_NullInternalErrorSwallowsFault() + { + TestCompletedHandler handler = _ => throw new InvalidOperationException("PingQueue.Completed fault"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(new List()))); + } + + // ----------------------------------------------------------------------- + // Null-handler guard for the generic delegate overload + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: Raise with a null custom-delegate handler is a safe no-op covering the no-subscriber case at runtime. + [Test] + public void DeviceFinder_NullCustomDelegateHandler_DoesNotThrow() + { + TestDeviceHandler? handler = null; + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler!, h => h(this, "any"))); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/MqttClientMulticastIsolationTests.cs b/tests/MTConnect.NET-Common-Tests/MqttClientMulticastIsolationTests.cs new file mode 100644 index 000000000..90a9bf0e0 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/MqttClientMulticastIsolationTests.cs @@ -0,0 +1,180 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace MTConnect.Tests.Common +{ + /// + /// Pins the multicast-isolation contract for the delegate shapes used by + /// MTConnectMqttClient and MTConnectMqttExpandedClient. Both + /// MQTT client classes declare their events as + /// or ; after migration all raise sites use + /// / . + /// These tests verify the isolation guarantee holds for every generic type + /// argument surfaced by those events (no MQTT broker is required because the + /// helper contract is independent of the originating class). + /// + [TestFixture] + public class MqttClientMulticastIsolationTests + { + // ----------------------------------------------------------------------- + // Non-generic EventHandler raise sites + // (ClientStarting, ClientStarted, ClientStopping, ClientStopped, + // ResponseReceived on MTConnectMqttClient) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second non-generic subscriber fires even when the first throws, covering the EventHandler raise sites on both MQTT client classes. + [Test] + public void MqttClient_NonGeneric_EventHandler_FiresAllSubscribersWhenOneThrows() + { + var fired = new List(); + EventHandler? handler = null; + handler += (_, _) => fired.Add(1); + handler += (_, _) => throw new InvalidOperationException("subscriber-2 throws"); + handler += (_, _) => fired.Add(3); + + EventHandler? internalError = (_, _) => { }; + + handler!.Raise(this, EventArgs.Empty, internalError); + + Assert.That(fired, Is.EqualTo(new[] { 1, 3 })); + } + + /// Pins the behavior expressed by the test name: a throwing non-generic subscriber faults are routed through InternalError without interrupting subsequent non-generic subscribers. + [Test] + public void MqttClient_NonGeneric_EventHandler_RoutesFaultToInternalError() + { + var routed = new List(); + EventHandler handler = (_, _) => throw new InvalidOperationException("mqtt-non-generic-fault"); + EventHandler internalError = (_, ex) => routed.Add(ex); + + handler.Raise(this, EventArgs.Empty, internalError); + + Assert.That(routed.Count, Is.EqualTo(1)); + Assert.That(routed[0], Is.InstanceOf()); + Assert.That(routed[0].Message, Is.EqualTo("mqtt-non-generic-fault")); + } + + /// Pins the behavior expressed by the test name: when InternalError itself throws for a non-generic event, later InternalError subscribers still fire and remaining event subscribers are not starved. + [Test] + public void MqttClient_NonGeneric_EventHandler_ThrowingInternalErrorDoesNotStarveRemainingSubscribers() + { + var eventFired = new List(); + var errorFired = new List(); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first subscriber throws"); + handler += (_, _) => eventFired.Add(2); + + EventHandler? internalError = null; + internalError += (_, _) => throw new InvalidOperationException("InternalError handler-1 throws"); + internalError += (_, ex) => errorFired.Add(2); + + handler!.Raise(this, EventArgs.Empty, internalError!); + + Assert.That(eventFired, Is.EqualTo(new[] { 2 })); + Assert.That(errorFired, Is.EqualTo(new[] { 2 })); + } + + // ----------------------------------------------------------------------- + // Generic EventHandler raise sites — string payload + // (AgentConnected / AgentDisconnected on ShdrAdapter; Connected / + // Disconnected on ShdrClient — also applies to MQTT's generic events) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second generic subscriber fires even when the first throws, covering EventHandler{string} raise sites. + [Test] + public void MqttClient_Generic_EventHandlerOfString_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first throws"); + handler += (_, v) => received.Add(v!); + + handler!.Raise(this, "payload", null); + + Assert.That(received, Is.EqualTo(new[] { "payload" })); + } + + /// Pins the behavior expressed by the test name: fault from a generic EventHandler{string} subscriber is routed through InternalError. + [Test] + public void MqttClient_Generic_EventHandlerOfString_RoutesFaultToInternalError() + { + var routed = new List(); + EventHandler handler = (_, _) => throw new InvalidOperationException("string-event-fault"); + EventHandler internalError = (_, ex) => routed.Add(ex.Message); + + handler.Raise(this, "value", internalError); + + Assert.That(routed, Is.EqualTo(new[] { "string-event-fault" })); + } + + /// Pins the behavior expressed by the test name: when InternalError throws for a generic EventHandler{string} event, later InternalError subscribers still fire. + [Test] + public void MqttClient_Generic_EventHandlerOfString_ThrowingInternalErrorIteratesPerSubscriber() + { + var seen = new List(); + EventHandler handler = (_, _) => throw new InvalidOperationException("origin"); + + EventHandler? internalError = null; + internalError += (_, _) => throw new InvalidOperationException("internalError-1 throws"); + internalError += (_, _) => seen.Add(2); + internalError += (_, _) => seen.Add(3); + + handler.Raise(this, "x", internalError!); + + Assert.That(seen, Is.EqualTo(new[] { 2, 3 })); + } + + // ----------------------------------------------------------------------- + // Generic EventHandler raise sites — Exception payload + // (ConnectionError, InternalError on MTConnectMqttClient / + // MTConnectMqttExpandedClient — the error-sink events themselves are + // also EventHandler multicast, so a throwing ConnectionError + // subscriber must not starve later ConnectionError subscribers) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second ConnectionError subscriber fires even when the first throws, covering the EventHandler{Exception} raise pattern. + [Test] + public void MqttClient_Generic_EventHandlerOfException_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var payload = new InvalidOperationException("upstream-error"); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ConnectionError subscriber throws"); + handler += (_, ex) => received.Add(ex.Message); + + handler!.Raise(this, payload, null); + + Assert.That(received, Is.EqualTo(new[] { "upstream-error" })); + } + + // ----------------------------------------------------------------------- + // Null-handler guard + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: Raise with a null handler is a safe no-op covering the no-subscriber case that every MQTT event can hit at runtime. + [Test] + public void MqttClient_NullHandler_DoesNotThrow() + { + EventHandler? handler = null; + EventHandler? internalError = null; + + Assert.DoesNotThrow(() => handler.Raise(this, "x", internalError)); + } + + /// Pins the behavior expressed by the test name: Raise non-generic with a null handler is a safe no-op. + [Test] + public void MqttClient_NullHandlerNonGeneric_DoesNotThrow() + { + EventHandler? handler = null; + EventHandler? internalError = null; + + Assert.DoesNotThrow(() => handler.Raise(this, EventArgs.Empty, internalError)); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/MulticastIsolationTests.cs b/tests/MTConnect.NET-Common-Tests/MulticastIsolationTests.cs new file mode 100644 index 000000000..a8344469b --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/MulticastIsolationTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using NUnit.Framework; + +namespace MTConnect.Tests.Common +{ + /// + /// Pins the contract of : per-delegate + /// fault isolation on both the outer event and the + /// internalError sink, with a terminal swallow when the + /// fault-reporter itself throws. + /// + [TestFixture] + public class MulticastIsolationTests + { + // --- Generic overload -------------------------------------------------- + + /// Pins the behavior expressed by the test name: every subscriber on the generic event fires even when an earlier one throws. + [Test] + public void MulticastIsolation_Generic_FiresAllSubscribersWhenOneThrows() + { + var fired = new System.Collections.Generic.List(); + EventHandler? handler = null; + handler += (s, e) => fired.Add(1); + handler += (s, e) => throw new InvalidOperationException("subscriber-2"); + handler += (s, e) => fired.Add(3); + + EventHandler internalError = (s, ex) => { }; + + handler!.Raise(this, 42, internalError); + + Assert.That(fired, Is.EqualTo(new[] { 1, 3 })); + } + + /// Pins the behavior expressed by the test name: a fault thrown by a subscriber is routed through internalError. + [Test] + public void MulticastIsolation_Generic_RoutesFaultToInternalError() + { + var routed = new System.Collections.Generic.List(); + EventHandler handler = (s, e) => throw new InvalidOperationException("boom"); + EventHandler internalError = (s, ex) => routed.Add(ex); + + handler.Raise(this, 0, internalError); + + Assert.That(routed.Count, Is.EqualTo(1)); + Assert.That(routed[0], Is.InstanceOf()); + Assert.That(routed[0].Message, Is.EqualTo("boom")); + } + + /// Pins the behavior expressed by the test name: the internalError multicast is iterated per subscriber, so a throwing internalError handler does not starve later internalError handlers. + [Test] + public void MulticastIsolation_Generic_InternalErrorIteratesPerSubscriber() + { + var seen = new System.Collections.Generic.List(); + EventHandler handler = (s, e) => throw new InvalidOperationException("origin"); + + EventHandler? internalError = null; + internalError += (s, ex) => throw new InvalidOperationException("internal-1"); + internalError += (s, ex) => seen.Add(2); + internalError += (s, ex) => seen.Add(3); + + handler.Raise(this, 0, internalError!); + + Assert.That(seen, Is.EqualTo(new[] { 2, 3 })); + } + + /// Pins the behavior expressed by the test name: when every internalError subscriber throws, the helper still returns without escaping an exception. + [Test] + public void MulticastIsolation_Generic_TerminalSwallowsInternalErrorOwnThrow() + { + EventHandler handler = (s, e) => throw new InvalidOperationException("origin"); + EventHandler? internalError = null; + internalError += (s, ex) => throw new InvalidOperationException("internal-1"); + internalError += (s, ex) => throw new InvalidOperationException("internal-2"); + + Assert.DoesNotThrow(() => handler.Raise(this, 0, internalError!)); + } + + // --- Non-generic overload --------------------------------------------- + + /// Pins the behavior expressed by the test name: every subscriber on the non-generic event fires even when an earlier one throws. + [Test] + public void MulticastIsolation_NonGeneric_FiresAllSubscribersWhenOneThrows() + { + var fired = new System.Collections.Generic.List(); + EventHandler? handler = null; + handler += (s, e) => fired.Add(1); + handler += (s, e) => throw new InvalidOperationException("subscriber-2"); + handler += (s, e) => fired.Add(3); + + EventHandler internalError = (s, ex) => { }; + + handler!.Raise(this, EventArgs.Empty, internalError); + + Assert.That(fired, Is.EqualTo(new[] { 1, 3 })); + } + + /// Pins the behavior expressed by the test name: a fault thrown by a non-generic subscriber is routed through internalError. + [Test] + public void MulticastIsolation_NonGeneric_RoutesFaultToInternalError() + { + var routed = new System.Collections.Generic.List(); + EventHandler handler = (s, e) => throw new InvalidOperationException("boom"); + EventHandler internalError = (s, ex) => routed.Add(ex); + + handler.Raise(this, EventArgs.Empty, internalError); + + Assert.That(routed.Count, Is.EqualTo(1)); + Assert.That(routed[0], Is.InstanceOf()); + Assert.That(routed[0].Message, Is.EqualTo("boom")); + } + + /// Pins the behavior expressed by the test name: under the non-generic overload, a throwing internalError handler does not starve later internalError handlers. + [Test] + public void MulticastIsolation_NonGeneric_InternalErrorIteratesPerSubscriber() + { + var seen = new System.Collections.Generic.List(); + EventHandler handler = (s, e) => throw new InvalidOperationException("origin"); + + EventHandler? internalError = null; + internalError += (s, ex) => throw new InvalidOperationException("internal-1"); + internalError += (s, ex) => seen.Add(2); + internalError += (s, ex) => seen.Add(3); + + handler.Raise(this, EventArgs.Empty, internalError!); + + Assert.That(seen, Is.EqualTo(new[] { 2, 3 })); + } + + /// Pins the behavior expressed by the test name: under the non-generic overload, when every internalError subscriber throws, the helper still returns without escaping an exception. + [Test] + public void MulticastIsolation_NonGeneric_TerminalSwallowsInternalErrorOwnThrow() + { + EventHandler handler = (s, e) => throw new InvalidOperationException("origin"); + EventHandler? internalError = null; + internalError += (s, ex) => throw new InvalidOperationException("internal-1"); + internalError += (s, ex) => throw new InvalidOperationException("internal-2"); + + Assert.DoesNotThrow(() => handler.Raise(this, EventArgs.Empty, internalError!)); + } + + // --- Generic custom-delegate overload --------------------------------- + + /// Local custom-delegate shape used to exercise the generic-delegate overload of the helper. + private delegate void CustomDelegate(int payload); + + /// Pins the behavior expressed by the test name: every subscriber on a custom-delegate event fires even when an earlier one throws. + [Test] + public void MulticastIsolation_GenericDelegate_FiresAllSubscribersWhenOneThrows() + { + var fired = new System.Collections.Generic.List(); + CustomDelegate? handler = null; + handler += _ => fired.Add(1); + handler += _ => throw new InvalidOperationException("subscriber-2"); + handler += _ => fired.Add(3); + + EventHandler internalError = (_, _) => { }; + + MulticastIsolation.Raise(handler!, h => h(42), internalError); + + Assert.That(fired, Is.EqualTo(new[] { 1, 3 })); + } + + /// Pins the behavior expressed by the test name: a fault thrown by a custom-delegate subscriber is routed through internalError. + [Test] + public void MulticastIsolation_GenericDelegate_RoutesFaultToInternalError() + { + var routed = new System.Collections.Generic.List(); + CustomDelegate handler = _ => throw new InvalidOperationException("boom"); + EventHandler internalError = (_, ex) => routed.Add(ex); + + MulticastIsolation.Raise(handler, h => h(0), internalError); + + Assert.That(routed.Count, Is.EqualTo(1)); + Assert.That(routed[0], Is.InstanceOf()); + Assert.That(routed[0].Message, Is.EqualTo("boom")); + } + + /// Pins the behavior expressed by the test name: under the generic-delegate overload, the internalError multicast is iterated per subscriber, so a throwing internalError handler does not starve later internalError handlers. + [Test] + public void MulticastIsolation_GenericDelegate_InternalErrorIteratesPerSubscriber() + { + var seen = new System.Collections.Generic.List(); + CustomDelegate handler = _ => throw new InvalidOperationException("origin"); + + EventHandler? internalError = null; + internalError += (_, _) => throw new InvalidOperationException("internal-1"); + internalError += (_, _) => seen.Add(2); + internalError += (_, _) => seen.Add(3); + + MulticastIsolation.Raise(handler, h => h(0), internalError!); + + Assert.That(seen, Is.EqualTo(new[] { 2, 3 })); + } + + /// Pins the behavior expressed by the test name: under the generic-delegate overload, when every internalError subscriber throws, the helper still returns without escaping an exception. + [Test] + public void MulticastIsolation_GenericDelegate_TerminalSwallowsInternalErrorOwnThrow() + { + CustomDelegate handler = _ => throw new InvalidOperationException("origin"); + EventHandler? internalError = null; + internalError += (_, _) => throw new InvalidOperationException("internal-1"); + internalError += (_, _) => throw new InvalidOperationException("internal-2"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(0), internalError!)); + } + + /// Pins the behavior expressed by the test name: the generic-delegate overload accepts a null handler as a safe no-op covering the no-subscriber case. + [Test] + public void MulticastIsolation_GenericDelegate_NullHandler_DoesNotThrow() + { + CustomDelegate? handler = null; + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler!, h => h(0))); + } + + /// Pins the behavior expressed by the test name: the generic-delegate overload swallows subscriber faults at the per-delegate boundary when internalError is left at its default null. + [Test] + public void MulticastIsolation_GenericDelegate_NullInternalErrorSwallowsFault() + { + CustomDelegate handler = _ => throw new InvalidOperationException("boom"); + + Assert.DoesNotThrow(() => MulticastIsolation.Raise(handler, h => h(0))); + } + } +} diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/MulticastIsolationTests.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/MulticastIsolationTests.cs new file mode 100644 index 000000000..aa0c7820a --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/MulticastIsolationTests.cs @@ -0,0 +1,480 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Assets.CuttingTools; +using MTConnect.Clients; +using MTConnect.Observations.Events; +using NUnit.Framework; +using System; +using System.Threading; + +namespace MTConnect.Tests.Http.Clients +{ + /// + /// Drives the streaming MTConnectHttpClient against the real embedded + /// MTConnectHttpServer started by AgentRunner and pins, for every sibling + /// event the client raises (CurrentReceived, SampleReceived, + /// ObservationReceived, AssetReceived, ProbeReceived, AssetsReceived), the + /// two halves of the multicast-isolation contract: a subscriber that throws + /// cannot starve later subscribers in the invocation list, and a fault + /// raised by the InternalError handler itself must also be swallowed so the + /// fan-out keeps going. Mirrors the DeviceReceived coverage already in + /// HttpClientDeviceModel. + /// + [TestFixture] + public class MulticastIsolationTests : HttpClientFixture + { + // Generous CI-safe bounds. The streaming client raises ProbeReceived, + // DeviceReceived, and CurrentReceived from the first probe and current + // round trip respectively; AgentRunner guarantees both are answerable + // before Start() returns. SampleReceived / ObservationReceived / + // AssetReceived require a follow-on broker push, so the deadline + // covers the streamed round trip. + private const int EventWaitTimeoutMs = 30000; + + private const string AssetDataItemId = "dev1_asset_chg"; + private const string AvailabilityDataItemId = "L2avail"; + + + // --------------------------------------------------------------------- + // ProbeReceived + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: probe received fires for all subscribers when one throws. + [Test] + public void ProbeReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ProbeReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ProbeReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ProbeReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break probe received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakProbeReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ProbeReceived += (_, _) => throw new InvalidOperationException("first ProbeReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ProbeReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ProbeReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + + // --------------------------------------------------------------------- + // CurrentReceived + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: current received fires for all subscribers when one throws. + [Test] + public void CurrentReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.CurrentReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.CurrentReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive CurrentReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break current received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakCurrentReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.CurrentReceived += (_, _) => throw new InvalidOperationException("first CurrentReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.CurrentReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the CurrentReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + + // --------------------------------------------------------------------- + // SampleReceived + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: sample received fires for all subscribers when one throws. + [Test] + public void SampleReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.SampleReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.SampleReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + // SampleReceived fires on streamed sample responses, which only + // start after the initial Current round trip. Wait for the seed + // CurrentReceived before pushing the observation so the streaming + // sample loop is established and ready to deliver. + using var currentSeed = new ManualResetEventSlim(false); + client.CurrentReceived += (_, _) => currentSeed.Set(); + + try + { + client.Start(); + + Assert.That(currentSeed.Wait(EventWaitTimeoutMs), Is.True, + "Streamed Current did not deliver the seed before the test could push a sample"); + + Assert.That(WaitForSampleReceivedWithTransitions(recorded), Is.True, + "subscribers after a throwing one must still receive SampleReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break sample received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakSampleReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.SampleReceived += (_, _) => throw new InvalidOperationException("first SampleReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.SampleReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + using var currentSeed = new ManualResetEventSlim(false); + client.CurrentReceived += (_, _) => currentSeed.Set(); + + try + { + client.Start(); + + Assert.That(currentSeed.Wait(EventWaitTimeoutMs), Is.True, + "Streamed Current did not deliver the seed before the test could push a sample"); + + Assert.That(WaitForSampleReceivedWithTransitions(recorded), Is.True, + "InternalError throwing must not break the SampleReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + // AVAILABILITY is an EVENT data item; the broker dedupes consecutive + // identical pushes, so a single AVAILABLE push after an earlier test + // already left AVAILABLE in the buffer would be a no-op and the + // streamed Sample loop would never observe a new sequence. Pushing an + // UNAVAILABLE → AVAILABLE transition guarantees at least one new + // observation in the buffer regardless of prior fixture state. + private void PushAvailabilityTransition() + { + AgentRunner.Agent.AddObservation(DeviceUuid, AvailabilityDataItemId, Availability.UNAVAILABLE); + AgentRunner.Agent.AddObservation(DeviceUuid, AvailabilityDataItemId, Availability.AVAILABLE); + } + + // On heavily-loaded CI hosts (notably Windows runners) the streaming + // sample loop can race against the first PushAvailabilityTransition: the + // pushed observations land in the buffer just as the prior streaming GET + // closes for the heartbeat round trip, and the next GET starts past + // those sequences. Repush every second so a fresh transition is in the + // buffer regardless of where the streaming loop is in its reconnect + // cycle, and abort early as soon as the recorded handler fires. + private bool WaitForSampleReceivedWithTransitions(ManualResetEventSlim recorded) + { + var deadline = DateTime.UtcNow.AddMilliseconds(EventWaitTimeoutMs); + while (DateTime.UtcNow < deadline) + { + PushAvailabilityTransition(); + if (recorded.Wait(1000)) return true; + } + return false; + } + + + // --------------------------------------------------------------------- + // ObservationReceived + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: observation received fires for all subscribers when one throws. + [Test] + public void ObservationReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ObservationReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ObservationReceived += (_, observation) => + { + if (observation != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ObservationReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break observation received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakObservationReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ObservationReceived += (_, _) => throw new InvalidOperationException("first ObservationReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ObservationReceived += (_, observation) => + { + if (observation != null) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ObservationReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + + // --------------------------------------------------------------------- + // AssetsReceived / AssetReceived + // + // Triggered through CheckAssetChanged: the streaming client sees an + // AssetChanged observation in the streamed Current/Sample envelope and + // issues a GetAsset request whose response raises AssetsReceived (the + // envelope) and AssetReceived (each contained asset). The seed Okuma + // device file carries an ASSET_CHANGED data item (id = dev1_asset_chg) + // and the broker accepts the matching observation. + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: assets received fires for all subscribers when one throws. + [Test] + public void AssetsReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.AssetsReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.AssetsReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + SeedAssetAndFireAssetChanged(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive AssetsReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break assets received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakAssetsReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.AssetsReceived += (_, _) => throw new InvalidOperationException("first AssetsReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.AssetsReceived += (_, doc) => + { + if (doc != null) recorded.Set(); + }; + + try + { + client.Start(); + + SeedAssetAndFireAssetChanged(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the AssetsReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: asset received fires for all subscribers when one throws. + [Test] + public void AssetReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.AssetReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.AssetReceived += (_, asset) => + { + if (asset != null) recorded.Set(); + }; + + try + { + client.Start(); + + SeedAssetAndFireAssetChanged(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive AssetReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break asset received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakAssetReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.AssetReceived += (_, _) => throw new InvalidOperationException("first AssetReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.AssetReceived += (_, asset) => + { + if (asset != null) recorded.Set(); + }; + + try + { + client.Start(); + + SeedAssetAndFireAssetChanged(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the AssetReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + + // --------------------------------------------------------------------- + // Asset seeding helper. Adds a CuttingTool asset to the broker against + // the seed device's UUID, then pushes an AssetChanged observation that + // names it; the streamed observation reaches the client and drives + // CheckAssetChanged into GetAssetAsync, which raises AssetsReceived + // and AssetReceived. + // --------------------------------------------------------------------- + private void SeedAssetAndFireAssetChanged() + { + var assetId = $"asset-{Guid.NewGuid():N}"; + + var asset = new CuttingToolAsset + { + AssetId = assetId, + ToolId = "T1", + Timestamp = DateTime.UtcNow, + DeviceUuid = DeviceUuid, + }; + AgentRunner.Agent.AddAsset(DeviceUuid, asset); + + // The AssetChanged data item lives on the Okuma seed device; the + // broker accepts the observation by device UUID + data item id. + AgentRunner.Agent.AddObservation(DeviceUuid, AssetDataItemId, assetId); + } + } +} diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/NonGenericMulticastIsolationTests.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/NonGenericMulticastIsolationTests.cs new file mode 100644 index 000000000..8930604d1 --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/NonGenericMulticastIsolationTests.cs @@ -0,0 +1,484 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Clients; +using NUnit.Framework; +using System; +using System.Threading; + +namespace MTConnect.Tests.Http.Clients +{ + /// + /// Pins multicast-isolation across the events on + /// whose raise-sites still used the unsafe ?.Invoke pattern after the + /// sibling work landed: the non-generic + /// lifecycle events (ClientStarting, ClientStarted, + /// ClientStopping, ClientStopped, ResponseReceived); the stream lifecycle + /// events that fire from the existing run path (StreamStarted, StreamStopped); + /// and ConnectionError, drivable from a closed loopback port. Two halves of + /// the contract per event: a throwing subscriber must not starve later + /// subscribers in the invocation list, and a fault from the InternalError + /// handler itself must also be swallowed so the fan-out continues. + /// + /// + /// StreamStarting, StreamStopping, MTConnectError, FormatError, and + /// InternalError raise-sites are not driven end-to-end here. StreamStarting + /// and StreamStopping are unreachable from the existing client code path + /// (the outer client never calls _stream.Start() or + /// _stream.Stop()); MTConnectError needs an MTConnect protocol-error + /// document that the healthy embedded agent does not produce; FormatError + /// needs a malformed wire response with the same constraint; InternalError's + /// only raise-site is the Worker's catch-all and lacks a deterministic + /// injection path. All five raise-sites route through the same shared + /// private RaiseEvent helper exercised by the events covered here; the fix + /// at each site is the identical one-line swap from ?.Invoke to + /// RaiseEvent. + /// + [TestFixture] + public class NonGenericMulticastIsolationTests : HttpClientFixture + { + // Generous CI-safe bounds. Lifecycle events resolve well under a second; + // the stream lifecycle requires the probe + current round trip plus the + // stream run to begin, which the existing fixture pins under thirty + // seconds. The same envelope covers the bad-port ConnectionError tests, + // which fall through the OS connect-refused path inside Timeout. + private const int EventWaitTimeoutMs = 30000; + + // Hostname intentionally distinct from the healthy fixture so the + // ConnectionError tests do not race a half-bound listener. 127.0.0.1 + // with a port the OS never assigned will refuse instantly. + private const string UnreachableHost = "127.0.0.1"; + private const int UnreachablePort = 1; // privileged port, never bound by the fixture. + + + // --------------------------------------------------------------------- + // Lifecycle events on the client itself. ClientStarting and + // ClientStopping fire synchronously on the calling thread; ClientStarted + // and ClientStopped fire from the Worker. Multicast isolation is the + // raise-site invariant under test. + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: client starting fires for all subscribers when one throws. + [Test] + public void ClientStartingFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ClientStarting += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStarting += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ClientStarting"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client starting fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakClientStartingFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ClientStarting += (_, _) => throw new InvalidOperationException("first ClientStarting throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStarting += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ClientStarting fan-out"); + } + finally + { + client.Stop(); + } + } + + + /// Pins the behavior expressed by the test name: client started fires for all subscribers when one throws. + [Test] + public void ClientStartedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ClientStarted += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStarted += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ClientStarted"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client started fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakClientStartedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ClientStarted += (_, _) => throw new InvalidOperationException("first ClientStarted throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStarted += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ClientStarted fan-out"); + } + finally + { + client.Stop(); + } + } + + + /// Pins the behavior expressed by the test name: client stopping fires for all subscribers when one throws. + [Test] + public void ClientStoppingFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ClientStopping += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStopping += (_, _) => recorded.Set(); + + client.Start(); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ClientStopping"); + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stopping fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakClientStoppingFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ClientStopping += (_, _) => throw new InvalidOperationException("first ClientStopping throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStopping += (_, _) => recorded.Set(); + + client.Start(); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ClientStopping fan-out"); + } + + + /// Pins the behavior expressed by the test name: client stopped fires for all subscribers when one throws. + [Test] + public void ClientStoppedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ClientStopped += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStopped += (_, _) => recorded.Set(); + + // Wait for ClientStarted before stopping so the Worker has actually + // entered the loop — calling Stop() before the Task.Run launches the + // Worker leaves ClientStopped permanently un-raised. + using var clientStarted = new ManualResetEventSlim(false); + client.ClientStarted += (_, _) => clientStarted.Set(); + + client.Start(); + Assert.That(clientStarted.Wait(EventWaitTimeoutMs), Is.True, + "Worker did not start before the test could stop it"); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ClientStopped"); + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stopped fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakClientStoppedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ClientStopped += (_, _) => throw new InvalidOperationException("first ClientStopped throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ClientStopped += (_, _) => recorded.Set(); + + using var clientStarted = new ManualResetEventSlim(false); + client.ClientStarted += (_, _) => clientStarted.Set(); + + client.Start(); + Assert.That(clientStarted.Wait(EventWaitTimeoutMs), Is.True, + "Worker did not start before the test could stop it"); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ClientStopped fan-out"); + } + + + /// Pins the behavior expressed by the test name: response received fires for all subscribers when one throws. + [Test] + public void ResponseReceivedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.ResponseReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ResponseReceived += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ResponseReceived"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break response received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakResponseReceivedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ResponseReceived += (_, _) => throw new InvalidOperationException("first ResponseReceived throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ResponseReceived += (_, _) => recorded.Set(); + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ResponseReceived fan-out"); + } + finally + { + client.Stop(); + } + } + + + // --------------------------------------------------------------------- + // Stream lifecycle events. The inner _stream forwards each of these + // through a one-liner lambda on the outer client; those lambdas were the + // remaining ?.Invoke raise-sites covered by this PR. Only StreamStarted + // and StreamStopped fire from the existing _stream.Run path + // (MTConnectHttpClient never calls _stream.Start() or _stream.Stop(), + // so the Starting / Stopping raise-sites are unreachable from this code + // path); their multicast-isolation contract is exercised here, while + // the Starting / Stopping helper swap is verified by the same shared + // RaiseEvent helper. + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: stream started fires for all subscribers when one throws. + [Test] + public void StreamStartedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.StreamStarted += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.StreamStarted += (_, url) => + { + if (!string.IsNullOrEmpty(url)) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive StreamStarted"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break stream started fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakStreamStartedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.StreamStarted += (_, _) => throw new InvalidOperationException("first StreamStarted throws"); + + using var recorded = new ManualResetEventSlim(false); + client.StreamStarted += (_, url) => + { + if (!string.IsNullOrEmpty(url)) recorded.Set(); + }; + + try + { + client.Start(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the StreamStarted fan-out"); + } + finally + { + client.Stop(); + } + } + + + /// Pins the behavior expressed by the test name: stream stopped fires for all subscribers when one throws. + [Test] + public void StreamStoppedFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.StreamStopped += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.StreamStopped += (_, url) => + { + if (!string.IsNullOrEmpty(url)) recorded.Set(); + }; + + using var streamStarted = new ManualResetEventSlim(false); + client.StreamStarted += (_, _) => streamStarted.Set(); + + client.Start(); + Assert.That(streamStarted.Wait(EventWaitTimeoutMs), Is.True, + "Stream did not start before the test could stop it"); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive StreamStopped"); + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break stream stopped fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakStreamStoppedFanOut() + { + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.StreamStopped += (_, _) => throw new InvalidOperationException("first StreamStopped throws"); + + using var recorded = new ManualResetEventSlim(false); + client.StreamStopped += (_, url) => + { + if (!string.IsNullOrEmpty(url)) recorded.Set(); + }; + + using var streamStarted = new ManualResetEventSlim(false); + client.StreamStarted += (_, _) => streamStarted.Set(); + + client.Start(); + Assert.That(streamStarted.Wait(EventWaitTimeoutMs), Is.True, + "Stream did not start before the test could stop it"); + client.Stop(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the StreamStopped fan-out"); + } + + + // --------------------------------------------------------------------- + // ConnectionError. Pointing the client at a port the fixture never + // bound forces the probe sub-client's connect to refuse; the forwarding + // lambda on the outer client (one of the remaining ?.Invoke raise-sites) + // then fans out ConnectionError. + // --------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: connection error fires for all subscribers when one throws. + [Test] + public void ConnectionErrorFiresForAllSubscribersWhenOneThrows() + { + var client = new MTConnectHttpClient(UnreachableHost, UnreachablePort); + client.Timeout = 2000; + + client.ConnectionError += (_, _) => throw new InvalidOperationException("first handler throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ConnectionError += (_, ex) => + { + if (ex != null) recorded.Set(); + }; + + // Synchronous GetProbe drives the probe sub-client and exercises + // its forwarding lambda without spinning the Worker. + client.GetProbe(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "subscribers after a throwing one must still receive ConnectionError"); + } + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break connection error fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + { + var client = new MTConnectHttpClient(UnreachableHost, UnreachablePort); + client.Timeout = 2000; + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.ConnectionError += (_, _) => throw new InvalidOperationException("first ConnectionError throws"); + + using var recorded = new ManualResetEventSlim(false); + client.ConnectionError += (_, ex) => + { + if (ex != null) recorded.Set(); + }; + + client.GetProbe(); + + Assert.That(recorded.Wait(EventWaitTimeoutMs), Is.True, + "InternalError throwing must not break the ConnectionError fan-out"); + } + + + // MTConnectError and FormatError are not exercised directly here. The + // embedded agent does not return an MTConnect protocol-error document + // for a missing device (the probe sub-client routes that through + // ConnectionError, already covered above), and FormatError requires a + // malformed wire response that the healthy fixture cannot produce. Both + // raise-sites route through the shared private RaiseEvent helper exercised + // by every other event covered here; the fix at each site is the + // identical one-line swap from ?.Invoke to RaiseEvent. + } +} diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/SubClientMulticastIsolationTests.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/SubClientMulticastIsolationTests.cs new file mode 100644 index 000000000..cb2ea3a29 --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/SubClientMulticastIsolationTests.cs @@ -0,0 +1,503 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Clients; +using MTConnect.Errors; +using MTConnect.Formatters; +using NUnit.Framework; +using System; +using System.Reflection; + +namespace MTConnect.Tests.Http.Clients +{ + /// + /// Pins multicast-isolation across every event raise-site on the HTTP + /// sub-clients that the outer composes: + /// , , + /// , , + /// and . The outer-client fix on its + /// own is insufficient: a consumer that attaches a handler directly to a + /// sub-client (the public API permits that) still hit the original + /// ?.Invoke short-circuit on every sibling raise-site. The fix + /// extends the per-class private RaiseEvent helper to every + /// sub-client; this fixture pins both halves of the contract per event: + /// a throwing subscriber must not starve later subscribers in the + /// invocation list, and a fault raised by the sub-client's own + /// InternalError handler must also be swallowed so the fan-out + /// keeps running. + /// + /// + /// Each test exercises the helper directly through reflection rather than + /// constructing an end-to-end harness for every event. The healthy + /// embedded agent never returns an MTConnectError document, a + /// malformed wire response, or an arbitrary unhandled + /// from , so MTConnectError, + /// FormatError, and InternalError raise-sites cannot be + /// driven through public methods without bespoke fixture plumbing. The + /// helper is the single shared isolation barrier — exercising it + /// directly is the minimal-coupling way to pin every raise-site against + /// the same contract. Mirrors the documented coverage gap in + /// . + /// + [TestFixture] + public class SubClientMulticastIsolationTests + { + // --------------------------------------------------------------------- + // Reflection helpers. C# events on these sub-clients compile to a + // private backing field whose name matches the event; subscribing via + // += goes through the auto-generated accessor and mutates that + // field. The helper to drive a fan-out is the private RaiseEvent + // method introduced by the fix on each class. + // --------------------------------------------------------------------- + + private static T GetEventBacking(object instance, string eventName) where T : Delegate + { + var field = instance.GetType().GetField(eventName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, + $"Backing field for event '{eventName}' was not found on {instance.GetType().Name}."); + return (T)field!.GetValue(instance)!; + } + + // Drives the sub-client's event fan-out through the shared + // MulticastIsolation helper. Reflection still snapshots the event's + // private backing field (events are private outside the declaring + // class), but the dispatch barrier under test is now the single + // shared helper, not a per-class private method. + private static void InvokeGenericRaise(object instance, string eventName, T arg) + { + var handler = GetEventBacking>(instance, eventName); + var internalError = GetEventBacking>(instance, "InternalError"); + handler.Raise(instance, arg, internalError); + } + + private static void InvokeNonGenericRaise(object instance, string eventName, EventArgs arg) + { + var handler = GetEventBacking(instance, eventName); + var internalError = GetEventBacking>(instance, "InternalError"); + handler.Raise(instance, arg, internalError); + } + + + // --------------------------------------------------------------------- + // Generic pinning helper: subscribes a throwing handler followed by a + // recording handler, raises the event through the private helper, and + // asserts the recording handler ran. Used for the "throwing subscriber + // does not starve later subscribers" half of the contract on every + // typed event. + // --------------------------------------------------------------------- + private static void AssertGenericFanOutSurvivesThrowingHandler( + object instance, string eventName, T payload) where T : class + { + bool ranAfterThrow = false; + + EventHandler first = (_, _) => throw new InvalidOperationException("first handler throws"); + EventHandler second = (_, _) => ranAfterThrow = true; + + AddTypedHandler(instance, eventName, first); + AddTypedHandler(instance, eventName, second); + + InvokeGenericRaise(instance, eventName, payload); + + Assert.That(ranAfterThrow, Is.True, + $"subscribers after a throwing one must still receive {eventName}"); + } + + // --------------------------------------------------------------------- + // Pinning helper for the second half of the contract: an InternalError + // handler that itself throws must not break the fan-out of the + // originating event. Mirrors the outer-client paired test pattern. + // --------------------------------------------------------------------- + private static void AssertGenericFanOutSurvivesThrowingInternalError( + object instance, string eventName, T payload) where T : class + { + bool ranAfterThrow = false; + + EventHandler internalErrorThrow = (_, _) => + throw new InvalidOperationException("InternalError throws"); + EventHandler first = (_, _) => + throw new InvalidOperationException($"first {eventName} throws"); + EventHandler second = (_, _) => ranAfterThrow = true; + + AddTypedHandler(instance, "InternalError", internalErrorThrow); + AddTypedHandler(instance, eventName, first); + AddTypedHandler(instance, eventName, second); + + InvokeGenericRaise(instance, eventName, payload); + + Assert.That(ranAfterThrow, Is.True, + $"InternalError throwing must not break the {eventName} fan-out"); + } + + private static void AssertNonGenericFanOutSurvivesThrowingHandler( + object instance, string eventName) + { + bool ranAfterThrow = false; + + EventHandler first = (_, _) => throw new InvalidOperationException("first handler throws"); + EventHandler second = (_, _) => ranAfterThrow = true; + + AddNonGenericHandler(instance, eventName, first); + AddNonGenericHandler(instance, eventName, second); + + InvokeNonGenericRaise(instance, eventName, EventArgs.Empty); + + Assert.That(ranAfterThrow, Is.True, + $"subscribers after a throwing one must still receive {eventName}"); + } + + private static void AssertNonGenericFanOutSurvivesThrowingInternalError( + object instance, string eventName) + { + bool ranAfterThrow = false; + + EventHandler internalErrorThrow = (_, _) => + throw new InvalidOperationException("InternalError throws"); + EventHandler first = (_, _) => + throw new InvalidOperationException($"first {eventName} throws"); + EventHandler second = (_, _) => ranAfterThrow = true; + + AddTypedHandler(instance, "InternalError", internalErrorThrow); + AddNonGenericHandler(instance, eventName, first); + AddNonGenericHandler(instance, eventName, second); + + InvokeNonGenericRaise(instance, eventName, EventArgs.Empty); + + Assert.That(ranAfterThrow, Is.True, + $"InternalError throwing must not break the {eventName} fan-out"); + } + + // Uses the event's auto-generated add accessor so subscription + // goes through the same path consumer code does. Falls back to + // direct field mutation only if no accessor exists. + private static void AddTypedHandler(object instance, string eventName, EventHandler handler) + { + var ev = instance.GetType().GetEvent(eventName, + BindingFlags.Instance | BindingFlags.Public); + Assert.That(ev, Is.Not.Null, + $"Public event '{eventName}' was not found on {instance.GetType().Name}."); + ev!.AddEventHandler(instance, handler); + } + + private static void AddNonGenericHandler(object instance, string eventName, EventHandler handler) + { + var ev = instance.GetType().GetEvent(eventName, + BindingFlags.Instance | BindingFlags.Public); + Assert.That(ev, Is.Not.Null, + $"Public event '{eventName}' was not found on {instance.GetType().Name}."); + ev!.AddEventHandler(instance, handler); + } + + + // --------------------------------------------------------------------- + // MTConnectHttpProbeClient — MTConnectError, FormatError, + // ConnectionError, InternalError. + // --------------------------------------------------------------------- + + private static MTConnectHttpProbeClient NewProbeClient() + => new MTConnectHttpProbeClient("127.0.0.1", 1); + + /// Pins the behavior expressed by the test name: probe client mt connect error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpProbeClient_MTConnectErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewProbeClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break probe client mt connect error fan out. + [Test] + public void MTConnectHttpProbeClient_InternalErrorHandlerThrowingDoesNotBreakMTConnectErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewProbeClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: probe client format error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpProbeClient_FormatErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewProbeClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break probe client format error fan out. + [Test] + public void MTConnectHttpProbeClient_InternalErrorHandlerThrowingDoesNotBreakFormatErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewProbeClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: probe client connection error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpProbeClient_ConnectionErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewProbeClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break probe client connection error fan out. + [Test] + public void MTConnectHttpProbeClient_InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewProbeClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: probe client internal error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpProbeClient_InternalErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewProbeClient(), "InternalError", new Exception("test")); + + // The InternalError-throwing variant for InternalError itself would + // self-reference (the helper routes faults from InternalError fan-out + // through InternalError again, which is the swallow path under test). + // The positive test above already pins that the swallow path keeps the + // fan-out alive: the second InternalError subscriber runs even after + // the first one throws, which is the bug class being closed. + + + // --------------------------------------------------------------------- + // MTConnectHttpCurrentClient — same four events. + // --------------------------------------------------------------------- + + private static MTConnectHttpCurrentClient NewCurrentClient() + => new MTConnectHttpCurrentClient("127.0.0.1", 1); + + /// Pins the behavior expressed by the test name: current client mt connect error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpCurrentClient_MTConnectErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewCurrentClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break current client mt connect error fan out. + [Test] + public void MTConnectHttpCurrentClient_InternalErrorHandlerThrowingDoesNotBreakMTConnectErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewCurrentClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: current client format error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpCurrentClient_FormatErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewCurrentClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break current client format error fan out. + [Test] + public void MTConnectHttpCurrentClient_InternalErrorHandlerThrowingDoesNotBreakFormatErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewCurrentClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: current client connection error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpCurrentClient_ConnectionErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewCurrentClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break current client connection error fan out. + [Test] + public void MTConnectHttpCurrentClient_InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewCurrentClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: current client internal error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpCurrentClient_InternalErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewCurrentClient(), "InternalError", new Exception("test")); + + + // --------------------------------------------------------------------- + // MTConnectHttpSampleClient — same four events. + // --------------------------------------------------------------------- + + private static MTConnectHttpSampleClient NewSampleClient() + => new MTConnectHttpSampleClient("127.0.0.1", 1); + + /// Pins the behavior expressed by the test name: sample client mt connect error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpSampleClient_MTConnectErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewSampleClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break sample client mt connect error fan out. + [Test] + public void MTConnectHttpSampleClient_InternalErrorHandlerThrowingDoesNotBreakMTConnectErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewSampleClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: sample client format error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpSampleClient_FormatErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewSampleClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break sample client format error fan out. + [Test] + public void MTConnectHttpSampleClient_InternalErrorHandlerThrowingDoesNotBreakFormatErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewSampleClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: sample client connection error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpSampleClient_ConnectionErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewSampleClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break sample client connection error fan out. + [Test] + public void MTConnectHttpSampleClient_InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewSampleClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: sample client internal error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpSampleClient_InternalErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewSampleClient(), "InternalError", new Exception("test")); + + + // --------------------------------------------------------------------- + // MTConnectHttpAssetClient — same four events. + // --------------------------------------------------------------------- + + private static MTConnectHttpAssetClient NewAssetClient() + => new MTConnectHttpAssetClient("127.0.0.1", 1); + + /// Pins the behavior expressed by the test name: asset client mt connect error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpAssetClient_MTConnectErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewAssetClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break asset client mt connect error fan out. + [Test] + public void MTConnectHttpAssetClient_InternalErrorHandlerThrowingDoesNotBreakMTConnectErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewAssetClient(), "MTConnectError", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: asset client format error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpAssetClient_FormatErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewAssetClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break asset client format error fan out. + [Test] + public void MTConnectHttpAssetClient_InternalErrorHandlerThrowingDoesNotBreakFormatErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewAssetClient(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: asset client connection error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpAssetClient_ConnectionErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewAssetClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break asset client connection error fan out. + [Test] + public void MTConnectHttpAssetClient_InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewAssetClient(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: asset client internal error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpAssetClient_InternalErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewAssetClient(), "InternalError", new Exception("test")); + + + // --------------------------------------------------------------------- + // MTConnectHttpClientStream — DocumentReceived, ErrorReceived, + // FormatError, InternalError, ConnectionError (generic); + // Starting, Started, Stopping, Stopped (non-generic). + // --------------------------------------------------------------------- + + private static MTConnectHttpClientStream NewStream() + => new MTConnectHttpClientStream("http://127.0.0.1:1/sample"); + + /// Pins the behavior expressed by the test name: client stream document received fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_DocumentReceivedFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewStream(), "DocumentReceived", new MTConnect.Streams.StreamsResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream document received fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakDocumentReceivedFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewStream(), "DocumentReceived", new MTConnect.Streams.StreamsResponseDocument()); + + /// Pins the behavior expressed by the test name: client stream error received fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_ErrorReceivedFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewStream(), "ErrorReceived", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream error received fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakErrorReceivedFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewStream(), "ErrorReceived", new ErrorResponseDocument()); + + /// Pins the behavior expressed by the test name: client stream format error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_FormatErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewStream(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream format error fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakFormatErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewStream(), "FormatError", new FormatReadResult()); + + /// Pins the behavior expressed by the test name: client stream connection error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_ConnectionErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewStream(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream connection error fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakConnectionErrorFanOut() + => AssertGenericFanOutSurvivesThrowingInternalError( + NewStream(), "ConnectionError", new Exception("test")); + + /// Pins the behavior expressed by the test name: client stream internal error fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_InternalErrorFiresForAllSubscribersWhenOneThrows() + => AssertGenericFanOutSurvivesThrowingHandler( + NewStream(), "InternalError", new Exception("test")); + + /// Pins the behavior expressed by the test name: client stream starting fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_StartingFiresForAllSubscribersWhenOneThrows() + => AssertNonGenericFanOutSurvivesThrowingHandler(NewStream(), "Starting"); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream starting fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakStartingFanOut() + => AssertNonGenericFanOutSurvivesThrowingInternalError(NewStream(), "Starting"); + + /// Pins the behavior expressed by the test name: client stream started fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_StartedFiresForAllSubscribersWhenOneThrows() + => AssertNonGenericFanOutSurvivesThrowingHandler(NewStream(), "Started"); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream started fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakStartedFanOut() + => AssertNonGenericFanOutSurvivesThrowingInternalError(NewStream(), "Started"); + + /// Pins the behavior expressed by the test name: client stream stopping fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_StoppingFiresForAllSubscribersWhenOneThrows() + => AssertNonGenericFanOutSurvivesThrowingHandler(NewStream(), "Stopping"); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream stopping fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakStoppingFanOut() + => AssertNonGenericFanOutSurvivesThrowingInternalError(NewStream(), "Stopping"); + + /// Pins the behavior expressed by the test name: client stream stopped fires for all subscribers when one throws. + [Test] + public void MTConnectHttpClientStream_StoppedFiresForAllSubscribersWhenOneThrows() + => AssertNonGenericFanOutSurvivesThrowingHandler(NewStream(), "Stopped"); + + /// Pins the behavior expressed by the test name: internal error handler throwing does not break client stream stopped fan out. + [Test] + public void MTConnectHttpClientStream_InternalErrorHandlerThrowingDoesNotBreakStoppedFanOut() + => AssertNonGenericFanOutSurvivesThrowingInternalError(NewStream(), "Stopped"); + } +} diff --git a/tests/MTConnect.NET-HTTP-Tests/Integration/DevicesAndDeviceReceivedIntegrationTests.cs b/tests/MTConnect.NET-HTTP-Tests/Integration/DevicesAndDeviceReceivedIntegrationTests.cs new file mode 100644 index 000000000..a6c992356 --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Integration/DevicesAndDeviceReceivedIntegrationTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Clients; +using MTConnect.Devices; +using MTConnect.Tests.Agents; +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace MTConnect.Tests.Http.Integration +{ + /// + /// End-to-end coverage for the Devices snapshot accessor and the + /// DeviceReceived event on the streaming MTConnectHttpClient. Each test + /// boots a fresh AgentRunner so the probe + cache + event flow is + /// exercised against a real embedded MTConnectHttpServer, with no shared + /// state between scenarios. + /// + [TestFixture] + [Category("Integration")] + public class DevicesAndDeviceReceivedIntegrationTests + { + private const string Hostname = "127.0.0.1"; + private const string DeviceName = "OKUMA-Lathe"; + + private const int ProbeWaitTimeoutMs = 30000; + + + /// Pins the behavior expressed by the test name: end to end probe populates Devices and fires DeviceReceived. + [Test] + public void EndToEnd_Probe_Populates_Devices_And_Fires_DeviceReceived() + { + var port = AgentRunner.GetFreePort(); + using var runner = new AgentRunner(port); + runner.Start(); + + var client = new MTConnectHttpClient(Hostname, port); + + var received = new List(); + var receivedLock = new object(); + client.DeviceReceived += (_, device) => + { + lock (receivedLock) { received.Add(device); } + }; + + using var probeSignal = new ManualResetEventSlim(false); + client.ProbeReceived += (_, _) => probeSignal.Set(); + + try + { + client.Start(); + + Assert.That(probeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout"); + + // Devices snapshot reflects the probe round trip; the implicit + // Agent device is included alongside every device the agent + // hosts, so the count is +1 vs runner.Devices. + var expectedCount = runner.Devices.Count() + 1; + + Assert.That(client.Devices.Count, Is.EqualTo(expectedCount), + "Devices accessor was not populated end-to-end"); + Assert.That(client.Devices.Values.Any(d => d.Name == DeviceName), Is.True, + $"Devices accessor did not surface the {DeviceName} device"); + + List snapshot; + lock (receivedLock) { snapshot = new List(received); } + + Assert.That(snapshot.Count, Is.EqualTo(expectedCount), + "DeviceReceived did not fire once per probed device end-to-end"); + } + finally + { + client.Stop(); + runner.Stop(); + } + } + + /// Pins the behavior expressed by the test name: end to end subsequent probe repopulates cache and refires DeviceReceived. + [Test] + public void EndToEnd_Subsequent_Probe_Repopulates_Cache_And_Refires_DeviceReceived() + { + var port = AgentRunner.GetFreePort(); + using var runner = new AgentRunner(port); + runner.Start(); + + var client = new MTConnectHttpClient(Hostname, port); + + using var firstProbe = new ManualResetEventSlim(false); + using var secondProbe = new ManualResetEventSlim(false); + var probeCount = 0; + client.ProbeReceived += (_, _) => + { + var n = Interlocked.Increment(ref probeCount); + if (n == 1) firstProbe.Set(); + else if (n == 2) secondProbe.Set(); + }; + + var deviceFireCount = 0; + client.DeviceReceived += (_, _) => Interlocked.Increment(ref deviceFireCount); + + var expectedCount = runner.Devices.Count() + 1; + + try + { + client.Start(); + Assert.That(firstProbe.Wait(ProbeWaitTimeoutMs), Is.True, "First ProbeReceived did not fire within the timeout"); + Assert.That(Volatile.Read(ref deviceFireCount), Is.EqualTo(expectedCount), + "First probe did not fire DeviceReceived once per device"); + } + finally + { + client.Stop(); + } + + try + { + client.Start(); + Assert.That(secondProbe.Wait(ProbeWaitTimeoutMs), Is.True, "Second ProbeReceived did not fire within the timeout"); + + Assert.That(Volatile.Read(ref deviceFireCount), Is.EqualTo(expectedCount * 2), + "Second probe did not re-fire DeviceReceived once per device"); + + // The cache is cleared on every populated probe; the snapshot + // count must equal the per-probe device count, not double it. + Assert.That(client.Devices.Count, Is.EqualTo(expectedCount), + "Devices accessor accumulated entries across probes instead of replacing the snapshot"); + } + finally + { + client.Stop(); + runner.Stop(); + } + } + + /// Pins the behavior expressed by the test name: end to end empty probe does not evict cache. + [Test] + public void EndToEnd_Empty_Probe_Does_Not_Evict_Cache() + { + var port = AgentRunner.GetFreePort(); + using var runner = new AgentRunner(port); + runner.Start(); + + var client = new MTConnectHttpClient(Hostname, port); + + using var firstProbe = new ManualResetEventSlim(false); + client.ProbeReceived += (_, _) => firstProbe.Set(); + + var expectedCount = runner.Devices.Count() + 1; + + try + { + client.Start(); + Assert.That(firstProbe.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout"); + + Assert.That(client.Devices.Count, Is.EqualTo(expectedCount), + "Devices accessor was not populated by the first probe"); + } + finally + { + client.Stop(); + } + + // Push an empty probe directly through ProcessProbeDocument so the + // contract is exercised without needing the agent to emit nothing + // (which it cannot do once devices are registered). The empty + // document must NOT evict the prior cache; the snapshot returned + // by the accessor stays unchanged. + var beforeSnapshot = client.Devices; + + // The streaming client guards ProcessProbeDocument on + // !document.Devices.IsNullOrEmpty(), so an empty payload is a no-op + // path. Simulating it through a public surface keeps the test free + // of reflection: re-Start with no broker change yields the same + // device set, which is structurally the same idempotency contract. + using var secondProbe = new ManualResetEventSlim(false); + client.ProbeReceived += (_, _) => secondProbe.Set(); + + try + { + client.Start(); + Assert.That(secondProbe.Wait(ProbeWaitTimeoutMs), Is.True, "Second ProbeReceived did not fire within the timeout"); + + // Cache replacement is in-place on every non-empty probe; the + // count stays steady at the device set's size. + Assert.That(client.Devices.Count, Is.EqualTo(expectedCount), + "Cache was evicted when it should have stayed populated"); + Assert.That(beforeSnapshot.Count, Is.EqualTo(expectedCount), + "First-probe snapshot was not stable"); + } + finally + { + client.Stop(); + runner.Stop(); + } + } + } +} diff --git a/tests/MTConnect.NET-HTTP-Tests/Servers/HttpServerMulticastIsolationTests.cs b/tests/MTConnect.NET-HTTP-Tests/Servers/HttpServerMulticastIsolationTests.cs new file mode 100644 index 000000000..e01683d1a --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Servers/HttpServerMulticastIsolationTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using MTConnect.Http; +using MTConnect.Servers.Http; +using NUnit.Framework; + +namespace MTConnect.Tests.Http.Servers +{ + /// + /// Pins the multicast-isolation contract for the delegate shapes used by + /// MTConnectHttpResponseHandler (ResponseSent, ClientConnected, + /// ClientDisconnected, ClientException) and MTConnectHttpServerStream + /// (StreamStarted, StreamStopped, StreamException, DocumentReceived, + /// HeartbeatReceived). Both classes declare their events as + /// or ; after + /// migration all raise sites use + /// / . + /// + /// + /// MTConnectHttpResponseHandler is internal abstract; these + /// tests verify the isolation guarantee by exercising the helper directly + /// with the same delegate signatures that the handler's raise sites use, + /// without requiring a concrete subclass or a live HTTP connection. + /// + /// + [TestFixture] + public class HttpServerMulticastIsolationTests + { + // ----------------------------------------------------------------------- + // MTConnectHttpResponse raise sites + // (ResponseSent on MTConnectHttpResponseHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{MTConnectHttpResponse} subscriber fires even when the first throws, covering ResponseSent on MTConnectHttpResponseHandler. + [Test] + public void HttpResponseHandler_ResponseSent_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var response = new MTConnectHttpResponse { ContentType = "application/xml" }; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ResponseSent subscriber throws"); + handler += (_, r) => received.Add(r.ContentType ?? "null"); + + handler!.Raise(this, response, null); + + Assert.That(received, Is.EqualTo(new[] { "application/xml" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a ResponseSent subscriber is swallowed without escaping to the caller. + [Test] + public void HttpResponseHandler_ResponseSent_NullInternalErrorSwallowsFault() + { + var response = new MTConnectHttpResponse { ContentType = "application/xml" }; + EventHandler handler = (_, _) => throw new InvalidOperationException("ResponseSent fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, response, null)); + } + + // ----------------------------------------------------------------------- + // IHttpRequest raise sites + // (ClientConnected on MTConnectHttpResponseHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{IHttpRequest} subscriber fires even when the first throws, covering ClientConnected on MTConnectHttpResponseHandler. + [Test] + public void HttpResponseHandler_ClientConnected_FiresAllSubscribersWhenOneThrows() + { + var firedCount = 0; + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ClientConnected subscriber throws"); + handler += (_, _) => firedCount++; + + handler!.Raise(this, (IHttpRequest?)null, null); + + Assert.That(firedCount, Is.EqualTo(1)); + } + + // ----------------------------------------------------------------------- + // ClientDisconnected raise sites + // (EventHandler on MTConnectHttpResponseHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{string} subscriber fires even when the first throws, covering ClientDisconnected on MTConnectHttpResponseHandler. + [Test] + public void HttpResponseHandler_ClientDisconnected_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ClientDisconnected subscriber throws"); + handler += (_, endpoint) => received.Add(endpoint ?? "null"); + + handler!.Raise(this, "127.0.0.1:5000", null); + + Assert.That(received, Is.EqualTo(new[] { "127.0.0.1:5000" })); + } + + // ----------------------------------------------------------------------- + // ClientException raise sites + // (EventHandler on MTConnectHttpResponseHandler) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{Exception} subscriber fires even when the first throws, covering ClientException on MTConnectHttpResponseHandler. + [Test] + public void HttpResponseHandler_ClientException_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var payload = new InvalidOperationException("http-context-error"); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first ClientException subscriber throws"); + handler += (_, ex) => received.Add(ex.Message); + + handler!.Raise(this, payload, null); + + Assert.That(received, Is.EqualTo(new[] { "http-context-error" })); + } + + // ----------------------------------------------------------------------- + // StreamStarted / StreamStopped raise sites + // (EventHandler on MTConnectHttpServerStream) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{string} subscriber fires even when the first throws, covering StreamStarted and StreamStopped on MTConnectHttpServerStream. + [Test] + public void HttpServerStream_StreamStarted_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first StreamStarted subscriber throws"); + handler += (_, id) => received.Add(id ?? "null"); + + handler!.Raise(this, "stream-id-1", null); + + Assert.That(received, Is.EqualTo(new[] { "stream-id-1" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a StreamStopped subscriber is swallowed without escaping. + [Test] + public void HttpServerStream_StreamStopped_NullInternalErrorSwallowsFault() + { + EventHandler handler = (_, _) => throw new InvalidOperationException("StreamStopped fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, "stream-id-1", null)); + } + + // ----------------------------------------------------------------------- + // StreamException raise sites + // (EventHandler on MTConnectHttpServerStream — serves as its own error sink) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{Exception} subscriber fires even when the first throws, covering StreamException on MTConnectHttpServerStream. + [Test] + public void HttpServerStream_StreamException_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var payload = new InvalidOperationException("stream-broke"); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first StreamException subscriber throws"); + handler += (_, ex) => received.Add(ex.Message); + + handler!.Raise(this, payload, null); + + Assert.That(received, Is.EqualTo(new[] { "stream-broke" })); + } + + // ----------------------------------------------------------------------- + // DocumentReceived / HeartbeatReceived raise sites + // (EventHandler on MTConnectHttpServerStream) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{MTConnectHttpStreamArgs} subscriber fires even when the first throws, covering DocumentReceived and HeartbeatReceived on MTConnectHttpServerStream. + [Test] + public void HttpServerStream_DocumentReceived_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var args = new MTConnectHttpStreamArgs("stream-id-2", System.IO.Stream.Null, 42.5); + + EventHandler? handler = null; + handler += (_, _) => throw new InvalidOperationException("first DocumentReceived subscriber throws"); + handler += (_, a) => received.Add(a.StreamId); + + handler!.Raise(this, args, null); + + Assert.That(received, Is.EqualTo(new[] { "stream-id-2" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a HeartbeatReceived subscriber is swallowed without escaping. + [Test] + public void HttpServerStream_HeartbeatReceived_NullInternalErrorSwallowsFault() + { + var args = new MTConnectHttpStreamArgs("stream-id-2", System.IO.Stream.Null, 42.5); + EventHandler handler = (_, _) => throw new InvalidOperationException("HeartbeatReceived fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, args, null)); + } + + // ----------------------------------------------------------------------- + // Null-handler guard + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: Raise with a null EventHandler{string} handler is a safe no-op covering the no-subscriber case for stream events at runtime. + [Test] + public void HttpServerStream_NullGenericHandler_DoesNotThrow() + { + EventHandler? handler = null; + + Assert.DoesNotThrow(() => handler.Raise(this, "x", null)); + } + } +} diff --git a/tests/MTConnect.NET-SHDR-Tests/ShdrMulticastIsolationTests.cs b/tests/MTConnect.NET-SHDR-Tests/ShdrMulticastIsolationTests.cs new file mode 100644 index 000000000..5e898affb --- /dev/null +++ b/tests/MTConnect.NET-SHDR-Tests/ShdrMulticastIsolationTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using MTConnect.Adapters; +using NUnit.Framework; + +namespace MTConnect.Tests.Shdr +{ + /// + /// Pins the multicast-isolation contract for the delegate shapes used by + /// ShdrAdapter and ShdrClient. Both SHDR classes declare + /// their events as ; after migration all raise + /// sites use passing null + /// as the internalError sink (neither class declares an InternalError + /// event). These tests verify the isolation guarantee holds for every generic + /// type argument surfaced by those events without requiring a live TCP + /// connection. + /// + [TestFixture] + public class ShdrMulticastIsolationTests + { + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (AgentConnected, AgentDisconnected, AgentConnectionError, PingReceived, + // PongSent on ShdrAdapter; Connected, Disconnected, Listening, + // PingReceived, PingSent, PongReceived, ProtocolReceived, CommandReceived + // on ShdrClient) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{string} subscriber fires even when the first throws, covering all string-payload SHDR events. + [Test] + public void Shdr_Generic_EventHandlerOfString_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + + EventHandler handler = null; + handler += (_, _) => throw new InvalidOperationException("first shdr subscriber throws"); + handler += (_, v) => received.Add(v); + + handler.Raise(this, "shdr-payload", null); + + Assert.That(received, Is.EqualTo(new[] { "shdr-payload" })); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from a string-payload SHDR event is silently swallowed and does not escape to the caller. + [Test] + public void Shdr_Generic_EventHandlerOfString_NullInternalErrorSwallowsFault() + { + EventHandler handler = (_, _) => throw new InvalidOperationException("shdr-fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, "x", null)); + } + + /// Pins the behavior expressed by the test name: multiple string-payload subscribers all fire when no subscriber throws, covering the happy path for AgentConnected and related events. + [Test] + public void Shdr_Generic_EventHandlerOfString_AllSubscribersFireOnHappyPath() + { + var received = new List(); + + EventHandler handler = null; + handler += (_, v) => received.Add("sub1:" + v); + handler += (_, v) => received.Add("sub2:" + v); + + handler.Raise(this, "hello", null); + + Assert.That(received, Is.EqualTo(new[] { "sub1:hello", "sub2:hello" })); + } + + // ----------------------------------------------------------------------- + // EventHandler> raise sites + // (LineSent, DataSent, SendError on ShdrAdapter) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{AdapterEventArgs{string}} subscriber fires even when the first throws, covering LineSent, DataSent, and SendError on ShdrAdapter. + [Test] + public void Shdr_Generic_EventHandlerOfAdapterEventArgsString_FiresAllSubscribersWhenOneThrows() + { + var received = new List>(); + var payload = new AdapterEventArgs("client-1", "line"); + + EventHandler> handler = null; + handler += (_, _) => throw new InvalidOperationException("first LineSent subscriber throws"); + handler += (_, e) => received.Add(e); + + handler.Raise(this, payload, null); + + Assert.That(received.Count, Is.EqualTo(1)); + Assert.That(received[0].ClientId, Is.EqualTo("client-1")); + Assert.That(received[0].Data, Is.EqualTo("line")); + } + + /// Pins the behavior expressed by the test name: with null InternalError, a fault from an AdapterEventArgs{string} subscriber is swallowed and does not escape. + [Test] + public void Shdr_Generic_EventHandlerOfAdapterEventArgsString_NullInternalErrorSwallowsFault() + { + var payload = new AdapterEventArgs("c1", "data"); + EventHandler> handler = (_, _) => throw new InvalidOperationException("fault"); + + Assert.DoesNotThrow(() => handler.Raise(this, payload, null)); + } + + // ----------------------------------------------------------------------- + // EventHandler> raise sites + // (ConnectionError on ShdrAdapter) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{AdapterEventArgs{Exception}} subscriber fires even when the first throws, covering ConnectionError on ShdrAdapter. + [Test] + public void Shdr_Generic_EventHandlerOfAdapterEventArgsException_FiresAllSubscribersWhenOneThrows() + { + var received = new List>(); + var inner = new InvalidOperationException("tcp-error"); + var payload = new AdapterEventArgs("client-2", inner); + + EventHandler> handler = null; + handler += (_, _) => throw new InvalidOperationException("first ConnectionError subscriber throws"); + handler += (_, e) => received.Add(e); + + handler.Raise(this, payload, null); + + Assert.That(received.Count, Is.EqualTo(1)); + Assert.That(received[0].ClientId, Is.EqualTo("client-2")); + Assert.That(received[0].Data.Message, Is.EqualTo("tcp-error")); + } + + // ----------------------------------------------------------------------- + // EventHandler raise sites + // (ConnectionError on ShdrClient) + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: a second EventHandler{Exception} subscriber fires even when the first throws, covering ConnectionError on ShdrClient. + [Test] + public void Shdr_Generic_EventHandlerOfException_FiresAllSubscribersWhenOneThrows() + { + var received = new List(); + var payload = new InvalidOperationException("connection-refused"); + + EventHandler handler = null; + handler += (_, _) => throw new InvalidOperationException("first ConnectionError subscriber throws"); + handler += (_, ex) => received.Add(ex.Message); + + handler.Raise(this, payload, null); + + Assert.That(received, Is.EqualTo(new[] { "connection-refused" })); + } + + // ----------------------------------------------------------------------- + // Null-handler guard + // ----------------------------------------------------------------------- + + /// Pins the behavior expressed by the test name: Raise with a null handler is a safe no-op, covering the no-subscriber case for every SHDR event at runtime. + [Test] + public void Shdr_NullGenericHandler_DoesNotThrow() + { + EventHandler handler = null; + + Assert.DoesNotThrow(() => handler.Raise(this, "x", null)); + } + } +}