From 016d2bd3cafe85bc3af1b3f8ac8c43300da373c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 20:42:57 +0200 Subject: [PATCH 1/8] test(http-tests): pin Devices accessor and DeviceReceived invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new HttpClientDeviceModel fixture that drives the full MTConnectHttpClient against AgentRunner's embedded server, exercising both surfaces issue #176 calls out before they are fixed: - the public Devices snapshot accessor (currently missing — compile- error RED), with separate tests for the pre-probe empty state, the post-probe populated state and snapshot-not-live-view semantics; - the DeviceReceived event (currently raised zero times because ProcessProbeDocument iterates an empty list — behavioural RED), with one test pinning the per-device fire count on the first probe and another pinning the IDataItem Container / Device back-pointers the issue calls out as the wired model's selling point. Both invariants will go green when the matching MTConnectHttpClient changes land. --- .../Clients/HttpClientDeviceModel.cs | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs new file mode 100644 index 000000000..4cedd9c3c --- /dev/null +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs @@ -0,0 +1,189 @@ +// 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 NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace MTConnect.Tests.Http.Clients +{ + // Drives the full MTConnectHttpClient (the streaming client, not the + // single-shot probe client) against the real embedded MTConnectHttpServer + // started by AgentRunner, pinning the two surfaces issue #176 added: + // + // - the public Devices snapshot accessor, which exposes the post-probe + // device cache that the client already maintains internally; + // - the DeviceReceived event, which historically built an empty list + // and iterated it, so it never fired in the field. + // + // Both invariants are exercised end to end through Start()/Stop(), so the + // probe round trip and the ProcessProbeDocument hand-off both run. + [TestFixture] + public class HttpClientDeviceModel : HttpClientFixture + { + // Generous CI-safe bounds. The streaming client raises DeviceReceived + // and populates the snapshot from the first probe, which AgentRunner + // guarantees is answerable before the fixture returns from Start(). + private const int ProbeWaitTimeoutMs = 15000; + private const int ProbeWaitPollMs = 50; + + [Test] + public void DevicesAccessorIsEmptyBeforeProbe() + { + var client = new MTConnectHttpClient(Hostname, Port); + + Assert.That(client.Devices, Is.Not.Null, "Devices accessor returned null"); + Assert.That(client.Devices.Count, Is.EqualTo(0), "Devices accessor was non-empty before any probe ran"); + } + + [Test] + public void DevicesAccessorIsPopulatedAfterProbe() + { + var client = new MTConnectHttpClient(Hostname, Port); + + 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"); + + // The accessor returns a snapshot, so capture once and assert + // against the captured dictionary. + var snapshot = client.Devices; + + Assert.That(snapshot, Is.Not.Null, "Devices accessor returned null after probe"); + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "Devices accessor did not surface every probed device"); + Assert.That(snapshot.Values.Any(d => d.Name == DeviceName), Is.True, $"Devices accessor did not surface the {DeviceName} device"); + } + finally + { + client.Stop(); + } + } + + [Test] + public void DevicesAccessorReturnsSnapshotNotLiveView() + { + var client = new MTConnectHttpClient(Hostname, Port); + + 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"); + + var snapshot = client.Devices; + + // The accessor must hand back an independent snapshot, not a + // reference to the live cache — mutating the returned + // dictionary must not be possible, and successive reads must + // not alias one another. + Assert.That(snapshot, Is.InstanceOf>(), "Devices accessor did not return an IReadOnlyDictionary"); + Assert.That(ReferenceEquals(snapshot, client.Devices), Is.False, "Devices accessor handed back an aliased instance instead of a snapshot"); + } + finally + { + client.Stop(); + } + } + + [Test] + public void DeviceReceivedFiresOncePerDeviceOnFirstProbe() + { + var client = new MTConnectHttpClient(Hostname, Port); + + // Capture both the count and the carried IDevice instances so we + // can assert the wired model is delivered, not a placeholder. + var receivedDevices = new List(); + var receivedLock = new object(); + client.DeviceReceived += (_, device) => + { + lock (receivedLock) { receivedDevices.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"); + + // The event is raised from inside ProcessProbeDocument, which + // runs on the worker thread; ProbeReceived fires at the end of + // that same method, so once it has been observed, every + // DeviceReceived invocation for this probe has already + // happened. + List snapshot; + lock (receivedLock) { snapshot = new List(receivedDevices); } + + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "DeviceReceived did not fire once per probed device"); + Assert.That(snapshot.All(d => d != null), Is.True, "DeviceReceived carried a null device"); + Assert.That(snapshot.Any(d => d.Name == DeviceName), Is.True, $"DeviceReceived did not deliver the {DeviceName} device"); + } + finally + { + client.Stop(); + } + } + + [Test] + public void DeviceReceivedCarriesWiredDataItemBackPointers() + { + var client = new MTConnectHttpClient(Hostname, Port); + + IDevice? targetDevice = null; + var targetLock = new object(); + client.DeviceReceived += (_, device) => + { + if (device?.Name == DeviceName) + { + lock (targetLock) { targetDevice = 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"); + + IDevice? captured; + lock (targetLock) { captured = targetDevice; } + + Assert.That(captured, Is.Not.Null, $"DeviceReceived did not deliver the {DeviceName} device"); + + // Walk the DataItem tree and assert the back-pointers the + // issue calls out are wired through to the caller — the whole + // point of surfacing the cached IDevice is access to this + // ancestry without re-parsing. + var dataItems = captured!.GetDataItems().ToList(); + Assert.That(dataItems, Is.Not.Empty, $"Device {DeviceName} carried no DataItems"); + + foreach (var dataItem in dataItems) + { + Assert.That(dataItem.Device, Is.Not.Null, $"DataItem {dataItem.Id} lost its Device back-pointer"); + Assert.That(dataItem.Container, Is.Not.Null, $"DataItem {dataItem.Id} lost its Container back-pointer"); + } + } + finally + { + client.Stop(); + } + } + } +} From 075929dcbb550fe5bd9aaaaf9329384f4e36415c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 20:52:04 +0200 Subject: [PATCH 2/8] feat(http): expose Devices accessor and fix DeviceReceived to fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public read-only Devices snapshot accessor on MTConnectHttpClient and fixes DeviceReceived to actually fire for every parsed device. The client already cached the wired device model in a private _devices dictionary populated by ProcessProbeDocument, but never exposed it; the only public path to the post-probe model was ProbeReceived, which carries the raw IDevicesResponseDocument before InstanceId stamping. The new accessor returns an independent Dictionary copy under the same internal lock the populate path uses, so callers can enumerate without synchronising against the worker thread. DeviceReceived previously built an empty outputDevices list, populated only _devices in the first loop, then iterated the empty list — so the event was raised zero times across the lifetime of every client. The fix folds the invocation into the populate loop, keeping the cache and the event in lockstep and dropping the dead second loop. The new public Devices member shadows the MTConnect.Devices namespace inside the class scope, so the two pre-existing in-class references to that namespace are now fully qualified as MTConnect.Devices.* to keep their resolution unambiguous. The matching HttpClientDeviceModel test fixture, added in the previous commit, transitions from RED to GREEN: the missing accessor was a compile-error RED, the never-fired event was a behavioural RED, and both invariants now hold end to end against the AgentRunner-backed embedded HTTP server. Closes #176. --- .../Clients/MTConnectHttpClient.cs | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs index f3ec94e84..2d860f7a2 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs @@ -179,11 +179,40 @@ private void Init() /// public bool UseStreaming { get; set; } + /// + /// A snapshot of the device model received from the most recent Probe response, + /// keyed by device UUID. The dictionary is empty until the first probe succeeds, + /// and is replaced on every subsequent probe. Each 's + /// DataItems carry the fully wired and + /// back-pointers set during parsing, so consumers + /// can walk the component ancestry of any DataItem without re-parsing the + /// document. + /// + /// + /// The accessor returns an independent copy under the client's internal lock, + /// so callers may enumerate the snapshot without synchronising against the + /// worker thread that processes subsequent probes. + /// + public IReadOnlyDictionary Devices + { + get + { + lock (_lock) + { + return new Dictionary(_devices); + } + } + } + #region "Events" /// - /// Raised when a Device is received + /// Raised once per parsed device for every Probe response the client receives, + /// carrying the fully wired instance — the same instance + /// the snapshot accessor would return for that UUID — with + /// its DataItems' and + /// back-pointers set, and the agent's InstanceId stamped on each DataItem. /// public event EventHandler DeviceReceived; @@ -835,7 +864,6 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) _cachedDataItems.Clear(); } - var outputDevices = new List(); foreach (var device in document.Devices) { var outputDevice = ProcessDevice(document.Header, device); @@ -846,10 +874,12 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) _devices.Remove(outputDevice.Uuid); _devices.Add(outputDevice.Uuid, outputDevice); } - } - foreach (var outputDevice in outputDevices) - { + // Raise DeviceReceived for each parsed device. The event was + // previously raised against a separate list that was never + // populated, so it did not fire in the field; folding the + // invocation into the populate loop keeps the cache and the + // event in lockstep. DeviceReceived?.Invoke(this, outputDevice); } @@ -1092,7 +1122,7 @@ private IComponentStream ProcessComponentStream(IMTConnectStreamsHeader header, outputComponentStream.NativeName = inputComponentStream.NativeName; outputComponentStream.Uuid = inputComponentStream.Uuid; - if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == Devices.Device.TypeId) + if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == MTConnect.Devices.Device.TypeId) { outputComponentStream.Component = GetCachedDevice(deviceUuid); } @@ -1148,7 +1178,7 @@ private async void CheckAssetChanged(IEnumerable observations, Can { if (observations != null && observations.Count() > 0) { - var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == Devices.DataItems.AssetChangedDataItem.TypeId); + var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == MTConnect.Devices.DataItems.AssetChangedDataItem.TypeId); if (assetsChanged != null) { foreach (var assetChanged in assetsChanged) From c31b2866eaddace8a4260f3522f070ff006ddd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 21:54:57 +0200 Subject: [PATCH 3/8] docs(http): clarify Devices accessor and DeviceReceived semantics Tighten the Devices property XML doc to state that the returned dictionary is a fresh allocation independent of the cache while the IDevice values are shared references replaced wholesale on each probe. Fix the British "synchronising" spelling to "synchronizing" to match the surrounding American English register of the public XML docs. Add remarks to DeviceReceived noting that handlers fire in document order while the cache is still being populated. Add disambiguation comments at the two MTConnect.Devices fully-qualified sites where the Devices property shadows the namespace. Trim the five-line historical comment in ProcessProbeDocument to a single forward-looking line. --- .../Clients/MTConnectHttpClient.cs | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs index 2d860f7a2..cfd0f5e83 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs @@ -181,17 +181,26 @@ private void Init() /// /// A snapshot of the device model received from the most recent Probe response, - /// keyed by device UUID. The dictionary is empty until the first probe succeeds, - /// and is replaced on every subsequent probe. Each 's - /// DataItems carry the fully wired and - /// back-pointers set during parsing, so consumers - /// can walk the component ancestry of any DataItem without re-parsing the - /// document. + /// keyed by device UUID. The dictionary is empty until the first probe succeeds. + /// Each call returns a fresh snapshot reflecting the most recently completed probe; + /// previously returned snapshots are unaffected by subsequent probes. Each + /// 's DataItems carry the fully wired + /// and + /// back-pointers set during parsing, so consumers can walk the component ancestry + /// of any DataItem without re-parsing the document. /// /// - /// The accessor returns an independent copy under the client's internal lock, - /// so callers may enumerate the snapshot without synchronising against the - /// worker thread that processes subsequent probes. + /// + /// The dictionary returned by each call is a fresh allocation independent of the + /// cache; the values within are shared references to cached + /// instances that the client replaces wholesale on each probe. The accessor + /// acquires the client's internal lock, so callers may enumerate the snapshot + /// without synchronizing against the worker thread that processes subsequent probes. + /// + /// + /// Each access allocates a fresh dictionary. Cache the returned reference if you + /// read repeatedly between probes. + /// /// public IReadOnlyDictionary Devices { @@ -214,6 +223,11 @@ public IReadOnlyDictionary Devices /// its DataItems' and /// back-pointers set, and the agent's InstanceId stamped on each DataItem. /// + /// + /// Handlers fire in document order while the cache is still being populated. + /// Subscribe to to receive notification after the full + /// probe response has been processed. + /// public event EventHandler DeviceReceived; /// @@ -875,11 +889,7 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) _devices.Add(outputDevice.Uuid, outputDevice); } - // Raise DeviceReceived for each parsed device. The event was - // previously raised against a separate list that was never - // populated, so it did not fire in the field; folding the - // invocation into the populate loop keeps the cache and the - // event in lockstep. + // Fire per-device inside the populate loop so the cache and event stay in lockstep. DeviceReceived?.Invoke(this, outputDevice); } @@ -1122,6 +1132,7 @@ private IComponentStream ProcessComponentStream(IMTConnectStreamsHeader header, outputComponentStream.NativeName = inputComponentStream.NativeName; outputComponentStream.Uuid = inputComponentStream.Uuid; + // Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class. if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == MTConnect.Devices.Device.TypeId) { outputComponentStream.Component = GetCachedDevice(deviceUuid); @@ -1178,6 +1189,7 @@ private async void CheckAssetChanged(IEnumerable observations, Can { if (observations != null && observations.Count() > 0) { + // Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class. var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == MTConnect.Devices.DataItems.AssetChangedDataItem.TypeId); if (assetsChanged != null) { From fd03998912cd5e1b2a21eec045e759e0c7856a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 17:18:11 +0200 Subject: [PATCH 4/8] docs(http-tests): add XML doc comments on HttpClientDeviceModel surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /// doc comments to the HttpClientDeviceModel test fixture and each of its five [Test] methods so the project survives the campaign's 100% XML-doc coverage gate (CS1591 → error). The original block comment on the class is promoted to a /// summary; each test gains a one-line summary that pins the behaviour the test name expresses. The gap surfaced when this PR's branch was union-merged into the integration branch on top of feat/docs-full-coverage (#182), which turns CS1591 into a build error for every test project. --- .../Clients/HttpClientDeviceModel.cs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs index 4cedd9c3c..9ba0bfd2d 100644 --- a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs @@ -11,17 +11,17 @@ namespace MTConnect.Tests.Http.Clients { - // Drives the full MTConnectHttpClient (the streaming client, not the - // single-shot probe client) against the real embedded MTConnectHttpServer - // started by AgentRunner, pinning the two surfaces issue #176 added: - // - // - the public Devices snapshot accessor, which exposes the post-probe - // device cache that the client already maintains internally; - // - the DeviceReceived event, which historically built an empty list - // and iterated it, so it never fired in the field. - // - // Both invariants are exercised end to end through Start()/Stop(), so the - // probe round trip and the ProcessProbeDocument hand-off both run. + /// + /// Drives the full MTConnectHttpClient (the streaming client, not the + /// single-shot probe client) against the real embedded MTConnectHttpServer + /// started by AgentRunner, pinning the two surfaces issue #176 added: + /// the public Devices snapshot accessor (which exposes the post-probe + /// device cache the client already maintains internally) and the + /// DeviceReceived event (which historically built an empty list and + /// iterated it, so it never fired in the field). Both invariants are + /// exercised end to end through Start()/Stop(), so the probe round trip + /// and the ProcessProbeDocument hand-off both run. + /// [TestFixture] public class HttpClientDeviceModel : HttpClientFixture { @@ -31,6 +31,7 @@ public class HttpClientDeviceModel : HttpClientFixture private const int ProbeWaitTimeoutMs = 15000; private const int ProbeWaitPollMs = 50; + /// Pins the behaviour expressed by the test name: devices accessor is empty before probe. [Test] public void DevicesAccessorIsEmptyBeforeProbe() { @@ -40,6 +41,7 @@ public void DevicesAccessorIsEmptyBeforeProbe() Assert.That(client.Devices.Count, Is.EqualTo(0), "Devices accessor was non-empty before any probe ran"); } + /// Pins the behaviour expressed by the test name: devices accessor is populated after probe. [Test] public void DevicesAccessorIsPopulatedAfterProbe() { @@ -68,6 +70,7 @@ public void DevicesAccessorIsPopulatedAfterProbe() } } + /// Pins the behaviour expressed by the test name: devices accessor returns snapshot not live view. [Test] public void DevicesAccessorReturnsSnapshotNotLiveView() { @@ -97,6 +100,7 @@ public void DevicesAccessorReturnsSnapshotNotLiveView() } } + /// Pins the behaviour expressed by the test name: device received fires once per device on first probe. [Test] public void DeviceReceivedFiresOncePerDeviceOnFirstProbe() { @@ -138,6 +142,7 @@ public void DeviceReceivedFiresOncePerDeviceOnFirstProbe() } } + /// Pins the behaviour expressed by the test name: device received carries wired data item back pointers. [Test] public void DeviceReceivedCarriesWiredDataItemBackPointers() { From 77967c002cd1728a70626c5f5af759c12224c58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 21:59:43 +0200 Subject: [PATCH 5/8] fix(http): evict stale devices and isolate DeviceReceived handler faults Lands four review findings on the issue-176 surface: - Clear the _devices cache at the start of every probe inside the same lock block that clears _cachedComponents and _cachedDataItems so devices the agent no longer advertises are evicted before the new probe is loaded. The Devices accessor's XML doc claimed the snapshot reflected the most recent probe; without the clear, stale UUIDs persisted indefinitely. - Wrap DeviceReceived?.Invoke in try/catch and route subscriber exceptions through InternalError. A throwing handler previously aborted the populate loop mid-stream, leaving the cache half-filled and suppressing ProbeReceived for that probe. The Worker loop's outer catch around the same fan-out already used this pattern; this change extends it to the per-device event. - Wrap the snapshot in ReadOnlyDictionary so a consumer cannot mutate the cache through a downcast. The Devices XML doc promised a read-only contract; without the wrapper the runtime type stayed a plain Dictionary that downcasts could mutate. - Tighten the spaced em-dashes in the DeviceReceived summary to the unspaced typesetter form so the rendered docfx page matches the surrounding prose convention. Adds three NUnit tests pinning the new invariants and closing the negative-coverage gaps the review surfaced: - DeviceReceivedFiresOnEverySubsequentProbe restarts the client so a second probe round-trip runs through the same agent and asserts the event re-fires for every device on the second probe. - DevicesAccessorReflectsLatestProbeWithStaleEntriesEvicted pins the eviction contract on reprobe. - DeviceReceivedHandlerThrowingDoesNotBreakCachePopulation pins the per-handler isolation contract. --- .../Clients/MTConnectHttpClient.cs | 47 ++++-- .../Clients/HttpClientDeviceModel.cs | 136 ++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs index cfd0f5e83..7c33544b7 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs @@ -11,6 +11,7 @@ using MTConnect.Streams; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -183,7 +184,9 @@ private void Init() /// A snapshot of the device model received from the most recent Probe response, /// keyed by device UUID. The dictionary is empty until the first probe succeeds. /// Each call returns a fresh snapshot reflecting the most recently completed probe; - /// previously returned snapshots are unaffected by subsequent probes. Each + /// previously returned snapshots are unaffected by subsequent probes, and devices + /// that disappear between probes are evicted at the start of the next probe so the + /// snapshot never carries entries the agent no longer advertises. Each /// 's DataItems carry the fully wired /// and /// back-pointers set during parsing, so consumers can walk the component ancestry @@ -192,15 +195,21 @@ private void Init() /// /// /// The dictionary returned by each call is a fresh allocation independent of the - /// cache; the values within are shared references to cached - /// instances that the client replaces wholesale on each probe. The accessor - /// acquires the client's internal lock, so callers may enumerate the snapshot - /// without synchronizing against the worker thread that processes subsequent probes. + /// cache, wrapped in a + /// so consumers cannot mutate the snapshot through a downcast. The + /// values within are shared references to cached instances that the client replaces + /// wholesale on each probe. The accessor acquires the client's internal lock, so + /// callers may enumerate the snapshot without synchronizing against the worker thread + /// that processes subsequent probes. /// /// /// Each access allocates a fresh dictionary. Cache the returned reference if you /// read repeatedly between probes. /// + /// + /// Empty probe responses (where the agent answers with no devices) do not clear the + /// cache; the previous snapshot stays presented until the next non-empty probe. + /// /// public IReadOnlyDictionary Devices { @@ -208,7 +217,8 @@ public IReadOnlyDictionary Devices { lock (_lock) { - return new Dictionary(_devices); + return new ReadOnlyDictionary( + new Dictionary(_devices)); } } } @@ -218,15 +228,17 @@ public IReadOnlyDictionary Devices /// /// Raised once per parsed device for every Probe response the client receives, - /// carrying the fully wired instance — the same instance - /// the snapshot accessor would return for that UUID — with - /// its DataItems' and + /// carrying the fully wired instance—the same instance the + /// snapshot accessor would return for that UUID—with its + /// DataItems' and /// back-pointers set, and the agent's InstanceId stamped on each DataItem. /// /// /// Handlers fire in document order while the cache is still being populated. /// Subscribe to to receive notification after the full - /// probe response has been processed. + /// probe response has been processed. A handler that throws is isolated by the + /// client: the exception is forwarded through and the + /// cache fill plus subsequent fan-out continue normally. /// public event EventHandler DeviceReceived; @@ -871,11 +883,13 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) { if (document != null && !document.Devices.IsNullOrEmpty()) { - // Clear Cached DataItems and Components + // Clear cached DataItems, Components, and the device snapshot so devices + // the agent no longer advertises are evicted before the new probe is loaded. lock (_lock) { _cachedComponents.Clear(); _cachedDataItems.Clear(); + _devices.Clear(); } foreach (var device in document.Devices) @@ -890,7 +904,16 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) } // Fire per-device inside the populate loop so the cache and event stay in lockstep. - DeviceReceived?.Invoke(this, outputDevice); + // Isolate subscriber exceptions so one bad handler cannot abort the populate loop + // or suppress ProbeReceived; route the fault through InternalError instead. + try + { + DeviceReceived?.Invoke(this, outputDevice); + } + catch (Exception ex) + { + InternalError?.Invoke(this, ex); + } } // Raise ProbeReceived Event diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs index 9ba0bfd2d..27faaae7a 100644 --- a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs @@ -190,5 +190,141 @@ public void DeviceReceivedCarriesWiredDataItemBackPointers() client.Stop(); } } + + /// Pins the behaviour expressed by the test name: device received fires on every subsequent probe, not just the first. + [Test] + public void DeviceReceivedFiresOnEverySubsequentProbe() + { + var client = new MTConnectHttpClient(Hostname, Port); + + using var firstProbeSignal = new ManualResetEventSlim(false); + using var secondProbeSignal = new ManualResetEventSlim(false); + var probeCount = 0; + client.ProbeReceived += (_, _) => + { + var n = Interlocked.Increment(ref probeCount); + if (n == 1) firstProbeSignal.Set(); + else if (n == 2) secondProbeSignal.Set(); + }; + + var deviceFireCount = 0; + client.DeviceReceived += (_, _) => Interlocked.Increment(ref deviceFireCount); + + try + { + client.Start(); + Assert.That(firstProbeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "First ProbeReceived did not fire within the timeout"); + + var firstFireCount = Volatile.Read(ref deviceFireCount); + Assert.That(firstFireCount, Is.EqualTo(ExpectedDocumentEntryCount), "DeviceReceived did not fire once per device on the first probe"); + } + finally + { + client.Stop(); + } + + // Restart the client to force a second probe round-trip through the same + // agent; the event must fire again for every device in the second probe, + // not stay quiet. + try + { + client.Start(); + Assert.That(secondProbeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "Second ProbeReceived did not fire within the timeout"); + + var totalFireCount = Volatile.Read(ref deviceFireCount); + Assert.That(totalFireCount, Is.EqualTo(ExpectedDocumentEntryCount * 2), "DeviceReceived did not re-fire on the second probe"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behaviour expressed by the test name: devices accessor reflects the latest probe with stale entries evicted. + [Test] + public void DevicesAccessorReflectsLatestProbeWithStaleEntriesEvicted() + { + var client = new MTConnectHttpClient(Hostname, Port); + + using var firstProbeSignal = new ManualResetEventSlim(false); + using var secondProbeSignal = new ManualResetEventSlim(false); + var probeCount = 0; + client.ProbeReceived += (_, _) => + { + var n = Interlocked.Increment(ref probeCount); + if (n == 1) firstProbeSignal.Set(); + else if (n == 2) secondProbeSignal.Set(); + }; + + try + { + client.Start(); + Assert.That(firstProbeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "First ProbeReceived did not fire within the timeout"); + } + finally + { + client.Stop(); + } + + try + { + client.Start(); + Assert.That(secondProbeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "Second ProbeReceived did not fire within the timeout"); + + // The cache is cleared at the start of every probe, so the post-second-probe + // snapshot count must equal the agent's device count, not double it. This + // pins the eviction-on-reprobe contract surfaced by the §19 review. + var snapshot = client.Devices; + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "Devices accessor accumulated devices across probes instead of evicting stale entries"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behaviour expressed by the test name: device received handler throwing does not break cache population. + [Test] + public void DeviceReceivedHandlerThrowingDoesNotBreakCachePopulation() + { + var client = new MTConnectHttpClient(Hostname, Port); + + var internalErrorCount = 0; + client.InternalError += (_, _) => Interlocked.Increment(ref internalErrorCount); + + // First subscriber throws on every fan-out; second subscriber records every + // device it sees. With exception-isolation in place, the cache must still + // fully populate, the second subscriber must still see every device, and the + // throw must surface through InternalError. + client.DeviceReceived += (_, _) => throw new InvalidOperationException("intentional test fault"); + + var receivedDevices = new List(); + var receivedLock = new object(); + client.DeviceReceived += (_, device) => + { + lock (receivedLock) { receivedDevices.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"); + + List snapshot; + lock (receivedLock) { snapshot = new List(receivedDevices); } + + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "Throwing handler suppressed downstream subscribers"); + Assert.That(client.Devices.Count, Is.EqualTo(ExpectedDocumentEntryCount), "Throwing handler corrupted the cache fill"); + Assert.That(Volatile.Read(ref internalErrorCount), Is.GreaterThanOrEqualTo(ExpectedDocumentEntryCount), "Throwing handler did not surface through InternalError"); + } + finally + { + client.Stop(); + } + } } } From a592bd55a41a6734686c0e7e0b308e91e9b87e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 21:59:58 +0200 Subject: [PATCH 6/8] docs(http): document Devices accessor and DeviceReceived in narrative pages The Devices accessor and the now-firing DeviceReceived event landed on this branch with XML doc comments only; the auto-generated docfx API page picks those up, but the consumer-facing example and configure pages did not mention either surface. This commit closes that gap so every new public surface ships with matching narrative docs in the same change set. Adds: - docs/examples/client-http.md gains a "Subscribing to the wired device model" subsection under the document-client example, showing a DeviceReceived handler walking the wired DataItems and a Devices snapshot read after the first probe completes. References issue #176 for context. - docs/configure/consumer.md gains a paragraph in the .NET-HTTP-client subsection naming Devices and DeviceReceived alongside the existing CurrentReceived guidance, plus a note that throwing handlers route through InternalError without breaking the cache fill. --- docs/configure/consumer.md | 4 +++- docs/examples/client-http.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/configure/consumer.md b/docs/configure/consumer.md index 8e6f9ce91..a07b8827f 100644 --- a/docs/configure/consumer.md +++ b/docs/configure/consumer.md @@ -82,7 +82,9 @@ client.Start(); `CurrentReceived` fires once on stream initialization—`Start()` drives the `/sample` long-poll, not periodic `/current` polling. For periodic snapshots, call `GetCurrent()` directly on a timer; for the streaming case, subscribe to `SampleReceived`. -The client handles reconnects on the consumer's behalf; the `ConnectionError` event fires on transport failures and the client backs off and retries. Parsing or dispatch failures surface through `InternalError`. +The client also exposes the post-probe device model. `DeviceReceived` fires once per parsed device for every `/probe` response, carrying the fully wired [`IDevice`](/api/MTConnect.Devices.IDevice) instance with its DataItems' `Container` and `Device` back-pointers set. The `Devices` property returns a read-only snapshot of every device the most recent probe yielded, keyed by UUID; entries are replaced wholesale on each probe and devices the agent no longer advertises are evicted at the start of the next probe. Both surfaces were added per [issue #176](https://github.com/TrakHound/MTConnect.NET/issues/176). + +The client handles reconnects on the consumer's behalf; the `ConnectionError` event fires on transport failures and the client backs off and retries. Parsing or dispatch failures surface through `InternalError`. A `DeviceReceived` handler that throws is isolated: the exception is routed through `InternalError` and the cache fill plus the rest of the per-device fan-out continue normally. ## MQTT—the cppagent-parity broker / relay tree diff --git a/docs/examples/client-http.md b/docs/examples/client-http.md index bfd1ebb21..9f1ddc152 100644 --- a/docs/examples/client-http.md +++ b/docs/examples/client-http.md @@ -50,7 +50,35 @@ client.CurrentReceived += (s, response) => { /* iterate response.Streams */ }; client.Start(); ``` -`MTConnectHttpClient` wraps the agent's HTTP endpoints (`/probe`, `/current`, `/sample`, `/asset`) and exposes them as a long-polling subscription with event hooks. The `Interval` property is the consumer-side sample interval — the client requests `/sample?interval=100` and receives each sequence batch through `CurrentReceived` (or `SampleReceived` if you wire that hook up instead). +`MTConnectHttpClient` wraps the agent's HTTP endpoints (`/probe`, `/current`, `/sample`, `/asset`) and exposes them as a long-polling subscription with event hooks. The `Interval` property is the consumer-side sample interval—the client requests `/sample?interval=100` and receives each sequence batch through `CurrentReceived` (or `SampleReceived` if you wire that hook up instead). + +### Subscribing to the wired device model + +The client maintains a wired snapshot of every device it sees from `/probe` responses, keyed by UUID. Subscribe to `DeviceReceived` to react per-device while the snapshot is filling, and read the `Devices` accessor at any point to consult the post-probe model: + +```csharp +client.DeviceReceived += (s, device) => +{ + Console.WriteLine($"Device Received : {device.Uuid} : {device.Name}"); + foreach (var dataItem in device.GetDataItems()) + { + // Container + Device back-pointers are wired in by the parser. + Console.WriteLine($" {dataItem.Id} : {dataItem.Type} : container={dataItem.Container?.Id}"); + } +}; + +client.Start(); + +// At any point after the first probe completes: +foreach (var (uuid, device) in client.Devices) +{ + Console.WriteLine($"Cached device : {uuid} : {device.Name}"); +} +``` + +`DeviceReceived` fires once per parsed device for every Probe response, carrying the fully wired `IDevice` instance—the same one the `Devices` snapshot accessor returns for that UUID—with the agent's `InstanceId` stamped on each DataItem. A handler that throws is isolated by the client: the exception is forwarded through `InternalError` and the cache fill continues. `Devices` returns a fresh read-only snapshot on every read; cache the reference if you read repeatedly between probes. Devices that disappear between probes are evicted at the start of the next probe, so the snapshot never carries entries the agent no longer advertises. + +The `Devices` accessor and the `DeviceReceived` fan-out were added per [issue #176](https://github.com/TrakHound/MTConnect.NET/issues/176) (approved 2026-06-01 by @PatrickRitchie). ### Per-observation validation From deceaef5c99e48e4988bbe143b14a05fd85ec462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 3 Jun 2026 01:22:33 +0200 Subject: [PATCH 7/8] fix(http): isolate DeviceReceived subscribers across the invocation list --- .../Clients/MTConnectHttpClient.cs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs index 7c33544b7..13a029cc9 100644 --- a/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs +++ b/libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs @@ -904,20 +904,43 @@ private void ProcessProbeDocument(IDevicesResponseDocument document) } // Fire per-device inside the populate loop so the cache and event stay in lockstep. - // Isolate subscriber exceptions so one bad handler cannot abort the populate loop - // or suppress ProbeReceived; route the fault through InternalError instead. + // 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); + } + + // 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 { - DeviceReceived?.Invoke(this, outputDevice); + InternalError?.Invoke(this, ex); } - catch (Exception ex) + catch { - InternalError?.Invoke(this, ex); + // A faulting InternalError handler must not break DeviceReceived fan-out. } } - - // Raise ProbeReceived Event - ProbeReceived?.Invoke(this, document); } } From 2148107fd7081227a0d655cc51ee69b4b1b6267b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 3 Jun 2026 02:02:26 +0200 Subject: [PATCH 8/8] test(http-tests): pin multicast isolation and InternalError fault tolerance --- .../Clients/HttpClientDeviceModel.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs index 27faaae7a..1024c084c 100644 --- a/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs +++ b/tests/MTConnect.NET-HTTP-Tests/Clients/HttpClientDeviceModel.cs @@ -326,5 +326,85 @@ public void DeviceReceivedHandlerThrowingDoesNotBreakCachePopulation() client.Stop(); } } + + /// Pins the behaviour expressed by the test name: device received fires for all subscribers when one throws. + [Test] + public void DeviceReceivedFiresForAllSubscribersWhenOneThrows() + { + // Wire two subscribers: a throwing one FIRST, a recording one SECOND. + // The recording subscriber must still receive every device because + // RaiseDeviceReceived iterates the invocation list with per-delegate + // try/catch, not a single try/catch over the multicast. + var client = new MTConnectHttpClient(Hostname, Port); + + client.DeviceReceived += (_, _) => throw new InvalidOperationException("first handler throws"); + + var recorded = new List(); + var recordedLock = new object(); + client.DeviceReceived += (_, device) => + { + lock (recordedLock) { recorded.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"); + + List snapshot; + lock (recordedLock) { snapshot = new List(recorded); } + + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), + "subscribers after a throwing one must still be invoked"); + } + finally + { + client.Stop(); + } + } + + /// Pins the behaviour expressed by the test name: internal error handler throwing does not break device received fan out. + [Test] + public void InternalErrorHandlerThrowingDoesNotBreakDeviceReceivedFanOut() + { + // The fix routes swallowed DeviceReceived faults through InternalError. + // If InternalError ITSELF throws, that secondary fault must also be + // swallowed so the rest of the DeviceReceived fan-out continues. + var client = new MTConnectHttpClient(Hostname, Port); + + client.InternalError += (_, _) => throw new InvalidOperationException("InternalError throws"); + client.DeviceReceived += (_, _) => throw new InvalidOperationException("first DeviceReceived throws"); + + var recorded = new List(); + var recordedLock = new object(); + client.DeviceReceived += (_, device) => + { + lock (recordedLock) { recorded.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"); + + List snapshot; + lock (recordedLock) { snapshot = new List(recorded); } + + Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), + "InternalError throwing must not break the DeviceReceived fan-out"); + } + finally + { + client.Stop(); + } + } } }