Resolve 6 of 7 stability review findings and close test coverage gaps

Fixes P1 StaComThread hang (crash-path faulting via WorkItem queue), P1 subscription
fire-and-forget (block+log or ContinueWith on 5 call sites), P2 continuation point
leak (PurgeExpired on Retrieve/Release), P2 dashboard bind failure (localhost prefix,
bool Start), P3 background loop double-start (task handles + join on stop in 3 files),
and P3 config logging exposure (SqlConnectionStringBuilder password masking). Adds
FakeMxAccessClient fault injection and 12 new tests. Documents required runtime
assemblies in ServiceHosting.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-07 15:37:27 -04:00
parent a28600ab1b
commit 95ad9c6866
16 changed files with 692 additions and 52 deletions

View File

@@ -46,6 +46,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// </summary>
public int ReconnectCount { get; set; }
/// <summary>
/// When set, <see cref="SubscribeAsync"/> returns a faulted task with this exception.
/// </summary>
public Exception? SubscribeException { get; set; }
/// <summary>
/// When set, <see cref="UnsubscribeAsync"/> returns a faulted task with this exception.
/// </summary>
public Exception? UnsubscribeException { get; set; }
/// <summary>
/// When set, <see cref="ReadAsync"/> returns a faulted task with this exception.
/// </summary>
public Exception? ReadException { get; set; }
/// <summary>
/// When set, <see cref="WriteAsync"/> returns a faulted task with this exception.
/// </summary>
public Exception? WriteException { get; set; }
/// <summary>
/// Occurs when tests explicitly simulate a connection-state transition.
/// </summary>
@@ -82,6 +102,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <param name="callback">The callback that should receive simulated value changes.</param>
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
if (SubscribeException != null)
return Task.FromException(SubscribeException);
_subscriptions[fullTagReference] = callback;
return Task.CompletedTask;
}
@@ -92,6 +114,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <param name="fullTagReference">The Galaxy attribute reference to stop monitoring.</param>
public Task UnsubscribeAsync(string fullTagReference)
{
if (UnsubscribeException != null)
return Task.FromException(UnsubscribeException);
_subscriptions.TryRemove(fullTagReference, out _);
return Task.CompletedTask;
}
@@ -104,6 +128,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <returns>The seeded VTQ value or a bad not-connected VTQ when the tag was not populated.</returns>
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (ReadException != null)
return Task.FromException<Vtq>(ReadException);
if (TagValues.TryGetValue(fullTagReference, out var vtq))
return Task.FromResult(vtq);
return Task.FromResult(Vtq.Bad(Quality.BadNotConnected));
@@ -118,6 +144,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <returns>A completed task returning the configured write outcome.</returns>
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
if (WriteException != null)
return Task.FromException<bool>(WriteException);
WrittenValues.Add((fullTagReference, value));
if (WriteResult)
TagValues[fullTagReference] = Vtq.Good(value);

View File

@@ -0,0 +1,83 @@
using System;
using System.Threading;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{
/// <summary>
/// Verifies Historian data source lifecycle behavior: dispose safety,
/// post-dispose rejection, and double-dispose idempotency.
/// </summary>
public class HistorianDataSourceLifecycleTests
{
private static HistorianConfiguration DefaultConfig => new()
{
Enabled = true,
ServerName = "test-historian",
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 5
};
[Fact]
public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
.GetAwaiter().GetResult());
}
[Fact]
public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.NotThrow(() => ds.Dispose());
}
[Fact]
public void ExtractAggregateValue_UnknownColumn_ReturnsNull()
{
HistorianDataSource.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull();
}
}
}

View File

@@ -94,6 +94,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
mgr.Retrieve(cp).ShouldBeNull();
}
[Fact]
public void Retrieve_ExpiredContinuationPoint_ReturnsNull()
{
var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1));
var values = CreateTestValues(5);
var cp = mgr.Store(values);
System.Threading.Thread.Sleep(50);
mgr.Retrieve(cp).ShouldBeNull();
}
[Fact]
public void Release_PurgesExpiredEntries()
{
var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1));
var cp1 = mgr.Store(CreateTestValues(3));
var cp2 = mgr.Store(CreateTestValues(5));
System.Threading.Thread.Sleep(50);
// Release one — purge should clean both expired entries
mgr.Release(cp1);
mgr.Retrieve(cp2).ShouldBeNull();
}
[Fact]
public void MultipleContinuationPoints_IndependentRetrieval()
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
@@ -13,6 +14,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
/// </summary>
public class StaComThreadTests : IDisposable
{
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int nExitCode);
private readonly StaComThread _thread;
/// <summary>
@@ -101,5 +105,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
results.Count.ShouldBe(3);
}
/// <summary>
/// Confirms that after the message pump exits, subsequent RunAsync calls throw instead of hanging.
/// </summary>
[Fact]
public async Task RunAsync_AfterPumpExit_ThrowsInsteadOfHanging()
{
// Kill the pump from inside by posting WM_QUIT
await _thread.RunAsync(() => PostQuitMessage(0));
await Task.Delay(100); // let pump exit
_thread.IsRunning.ShouldBe(false);
Should.Throw<InvalidOperationException>(() =>
_thread.RunAsync(() => { }).GetAwaiter().GetResult());
}
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
{
/// <summary>
/// Verifies that subscription and unsubscription failures in the MXAccess client
/// are handled gracefully by the node manager instead of silently lost.
/// </summary>
public class LmxNodeManagerSubscriptionFaultTests
{
/// <summary>
/// Confirms that a faulted SubscribeAsync is caught and logged rather than silently discarded.
/// </summary>
[Fact]
public async Task SubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
{
var mxClient = new FakeMxAccessClient
{
SubscribeException = new InvalidOperationException("COM connection lost")
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
await fixture.InitializeAsync();
try
{
var nodeManager = fixture.Service.NodeManagerInstance!;
// SubscribeTag should catch the fault — not throw and not hang
Should.NotThrow(() => nodeManager.SubscribeTag("TestMachine_001.MachineID"));
}
finally
{
await fixture.DisposeAsync();
}
}
/// <summary>
/// Confirms that a faulted UnsubscribeAsync is caught and logged rather than silently discarded.
/// </summary>
[Fact]
public async Task UnsubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
{
var mxClient = new FakeMxAccessClient();
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
await fixture.InitializeAsync();
try
{
var nodeManager = fixture.Service.NodeManagerInstance!;
// Subscribe first (succeeds)
nodeManager.SubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(1);
// Now inject fault for unsubscribe
mxClient.UnsubscribeException = new InvalidOperationException("COM connection lost");
// UnsubscribeTag should catch the fault — not throw and not hang
Should.NotThrow(() => nodeManager.UnsubscribeTag("TestMachine_001.MachineID"));
}
finally
{
await fixture.DisposeAsync();
}
}
/// <summary>
/// Confirms that subscription failure does not corrupt the ref-count bookkeeping,
/// allowing a retry to succeed after the fault clears.
/// </summary>
[Fact]
public async Task SubscribeTag_AfterFaultClears_CanSubscribeAgain()
{
var mxClient = new FakeMxAccessClient
{
SubscribeException = new InvalidOperationException("transient fault")
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
await fixture.InitializeAsync();
try
{
var nodeManager = fixture.Service.NodeManagerInstance!;
// First subscribe faults (caught)
nodeManager.SubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(0); // subscribe failed
// Clear the fault
mxClient.SubscribeException = null;
// Unsubscribe to reset ref count, then subscribe again
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
nodeManager.SubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(1);
}
finally
{
await fixture.DisposeAsync();
}
}
}
}

View File

@@ -145,7 +145,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
var server2 = new StatusWebServer(
new StatusReportService(new HealthCheckService(), 10),
new Random().Next(19000, 20000));
server2.Start();
server2.Start().ShouldBe(true);
server2.IsRunning.ShouldBe(true);
server2.Stop();
}

View File

@@ -34,6 +34,10 @@
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>