b0c031f09f
The driver-side half of the same mistake the address-space fix corrected. ReconcileDrivers and PushDesiredSubscriptionsFromArtifact both read the artifact as `...FirstOrDefault() ?? Array.Empty<byte>()`. Their THROW paths already returned early, but empty bytes flowed onwards as a real answer: zero driver specs, so DriverSpawnPlanner planned every running child for StopChild, and then an empty desired set dropped each surviving driver's live subscription handle. A node lost its entire field I/O and still ACKed Applied. Same rule as the address space: no bytes is no answer, not "a configuration with no drivers". Nothing legitimate produces a zero-length blob — deleting the last driver still deploys a JSON document with empty arrays. Both sites now skip and keep what is running. The two guards are independent because PushDesiredSubscriptions does its OWN ConfigDb read, so the row can go missing between them. Also corrects the ReconcileDrivers doc comment, which claimed an empty blob made it "effectively a no-op" — with children running it was the opposite. Tests: DriverHostActorUnreadableArtifactTests, RED-first (verified failing — after the empty dispatch the driver list was empty). A zero-length ArtifactBlob reproduces byte-for-byte what the missing-row case delivers, so the race does not have to be constructed. Two controls, both load-bearing: - a READABLE driverless artifact still stops the driver, so the guard keys on "the read gave us nothing", not on "fewer drivers than before"; - dropping a driver's LAST TAG still clears its subscription. This one was added after the absence assertion was caught passing on a race: the unsubscribe is an async self-tell, so with the second guard deleted the test still went green. The control observes that same unsubscribe arriving well inside the settle window, which is what makes the absence meaningful — with the guard deleted the suite now correctly goes RED. SubscribableStubDriver gains an UnsubscribeCount for that observation. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
164 lines
8.4 KiB
C#
164 lines
8.4 KiB
C#
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Shared <see cref="IDriver"/> stub harness used by <c>DriverInstanceActorTests</c> and
|
|
/// <c>DriverInstanceActorWriteAndSubscribeTests</c>. Promoted from the superset copy in
|
|
/// <c>DriverInstanceActorTests</c> so both suites compile against a single definition.
|
|
/// </summary>
|
|
internal class StubDriver : IDriver
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether initialization should throw.</summary>
|
|
public bool InitializeShouldThrow { get; set; }
|
|
/// <summary>Gets the number of times initialization was called.</summary>
|
|
public int InitializeCount;
|
|
/// <summary>Gets the number of times reinitialization was called.</summary>
|
|
public int ReinitializeCount;
|
|
|
|
private readonly object _initConfigsLock = new();
|
|
/// <summary>Every config string passed to <see cref="InitializeAsync"/>, in call order.</summary>
|
|
public List<string> InitConfigs { get; } = new();
|
|
/// <summary>Optional per-config init behaviour. When set, it fully owns the init outcome for that
|
|
/// config (await/throw); <see cref="InitializeShouldThrow"/> is ignored. Null ⇒ legacy behaviour.</summary>
|
|
public Func<string, Task>? InitBehavior { get; set; }
|
|
|
|
/// <summary>Gets the driver instance ID.</summary>
|
|
public string DriverInstanceId => "stub-driver-1";
|
|
/// <summary>Gets the driver type.</summary>
|
|
public string DriverType => "Stub";
|
|
|
|
/// <summary>Initializes the driver with the specified configuration JSON.</summary>
|
|
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref InitializeCount);
|
|
lock (_initConfigsLock) InitConfigs.Add(driverConfigJson);
|
|
if (InitBehavior is not null) { await InitBehavior(driverConfigJson); return; }
|
|
if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail");
|
|
}
|
|
|
|
/// <summary>Reinitializes the driver with the specified configuration JSON.</summary>
|
|
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref ReinitializeCount);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Shuts down the driver.</summary>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <summary>Gets the health status of the driver.</summary>
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
/// <summary>Gets the memory footprint of the driver.</summary>
|
|
public long GetMemoryFootprint() => 0;
|
|
/// <summary>Flushes optional caches in the driver.</summary>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <see cref="StubDriver"/> that also implements <see cref="IWritable"/>, recording every
|
|
/// write call and returning a configurable status code.
|
|
/// </summary>
|
|
internal sealed class WritableStubDriver : StubDriver, IWritable
|
|
{
|
|
/// <summary>Gets or sets the next status code to return from write operations.</summary>
|
|
public uint NextStatusCode { get; set; } = 0u;
|
|
/// <summary>Gets the list of write requests received.</summary>
|
|
public List<WriteRequest> Writes { get; } = new();
|
|
|
|
/// <summary>Writes the specified requests.</summary>
|
|
/// <param name="writes">The write requests.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
Writes.AddRange(writes);
|
|
IReadOnlyList<WriteResult> results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
|
|
return Task.FromResult(results);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <see cref="StubDriver"/> that also implements <see cref="ISubscribable"/>, firing
|
|
/// <see cref="OnDataChange"/> on demand and counting subscribe calls.
|
|
/// </summary>
|
|
internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
|
{
|
|
/// <summary>Occurs when data changes.</summary>
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
|
|
private readonly StubHandle _handle = new();
|
|
|
|
/// <summary>Gets the number of subscribers to OnDataChange.</summary>
|
|
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
|
|
|
|
/// <summary>Number of times <see cref="SubscribeAsync"/> was called (re-subscribe asserts).</summary>
|
|
public int SubscribeCount;
|
|
/// <summary>The reference set passed to the most recent <see cref="SubscribeAsync"/> call.</summary>
|
|
public IReadOnlyList<string>? LastSubscribedRefs;
|
|
|
|
/// <summary>Number of times <see cref="UnsubscribeAsync"/> was called — the observable for a live
|
|
/// subscription being torn down (an EMPTY desired set drops the handle rather than re-subscribing).</summary>
|
|
public int UnsubscribeCount;
|
|
|
|
/// <summary>When true, <see cref="UnsubscribeAsync"/> genuinely yields (<c>await Task.Yield()</c>)
|
|
/// before completing, so a <c>ConfigureAwait(false)</c> continuation in the actor resumes off the
|
|
/// Akka ActorContext on a thread-pool thread — reproducing the no-ActorContext race that a
|
|
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
|
|
public bool UnsubscribeYields { get; set; }
|
|
|
|
/// <summary>Subscribes to the specified full references.</summary>
|
|
/// <param name="fullReferences">The full references to subscribe to.</param>
|
|
/// <param name="publishingInterval">The publishing interval.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref SubscribeCount);
|
|
LastSubscribedRefs = fullReferences;
|
|
return Task.FromResult<ISubscriptionHandle>(_handle);
|
|
}
|
|
|
|
/// <summary>Unsubscribes from the specified subscription handle.</summary>
|
|
/// <param name="handle">The subscription handle.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref UnsubscribeCount);
|
|
if (UnsubscribeYields)
|
|
{
|
|
// Complete the awaited task from a fresh background thread that has NO Akka actor
|
|
// cell on it, so the caller's `ConfigureAwait(false)` continuation resumes on a
|
|
// clean thread-pool thread where InternalCurrentActorCellKeeper.Current is null —
|
|
// a deterministic repro of the real async-backend no-ActorContext race.
|
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_ = Task.Run(() => tcs.SetResult());
|
|
await tcs.Task.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>Fires a data change event with the specified parameters.</summary>
|
|
/// <param name="fullRef">The full reference of the data that changed.</param>
|
|
/// <param name="value">The new value.</param>
|
|
/// <param name="statusCode">The OPC UA status code.</param>
|
|
public void FireDataChange(string fullRef, object? value, uint statusCode)
|
|
{
|
|
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
|
|
}
|
|
}
|
|
|
|
/// <summary>Minimal <see cref="ISubscriptionHandle"/> for use by <see cref="SubscribableStubDriver"/>.</summary>
|
|
internal sealed class StubHandle : ISubscriptionHandle
|
|
{
|
|
/// <summary>Gets the diagnostic ID of the subscription.</summary>
|
|
public string DiagnosticId => "stub-sub";
|
|
}
|