dfbcb0e7f0
C1 (critical): a latched "this endpoint cannot stream" verdict could end with a GREEN driver holding a permanently silent subscription. DriverInstanceActor handles a tag-set change as Unsubscribe-then-Subscribe with no re-initialize; the unsubscribe cleared the stream-degraded flag, the resubscribe was refused by the latch without a word, and one successful read then reported Healthy. StartSampleStreamCore now re-asserts the degradation and logs a Warning naming the remedy, and teardown no longer clears the flag while the latch is set. NOT promoted to Faulted: DriverHealthReport maps any Faulted driver to a /readyz 503, so that would de-ready the whole node over one misconfigured Agent whose reads are perfectly healthy. Faulted stays for the case that earns it. I1: teardown waits are bounded (5 s) and honour the caller's token. They were unbounded and ignored it, so DriverInstanceActor's 5 s budget was inert and PostStop — which blocks a dispatcher thread on ShutdownAsync while holding the lifecycle semaphore — could be wedged forever by one blocking subscriber. The cancellation source is disposed only when the loop is provably gone. Applied to StopProbeLoopAsync too: Task 13 reproduced the identical shape, and closing the class in one method while leaving the other as the pattern to copy is worse than not closing it. I2: the reconnect ladder was monotonic for the process lifetime, so ~9 unrelated drops pinned the driver at MaxBackoffMs forever. A stream that delivered before dropping now starts a fresh ladder (at attempt 1, not 0, so MinBackoffMs is still honoured). I3: MaxBackoffMs <= 0 clamped every delay to zero — an operator-authorable reconnect spin. Treated as unset, like a multiplier that cannot grow. I4: the session published instanceId without NextSequence, so after a restart it held the new agent's id beside the dead one's cursor, and a gap/OUT_OF_RANGE re-baseline (id unchanged) never published the cursor at all. Both move in one CAS, and the pump publishes its cursor as it exits. I5: OnDataChange was a multicast Invoke — the first throwing handler aborted the rest of the list, starving every later subscriber. Now walked by hand with the catch inside the loop. The test that claimed to cover this registered the recorder BEFORE the thrower, so it passed regardless; swapped, it was red. Faults are latched to one Warning per stream generation (Debug thereafter) so a consistently-throwing consumer cannot flood the log. Also: the pump restarts after an unexpected fault (IsCompleted, not null); a device-scope that matches nothing is a Warning, not a Debug tally; a device or component with neither name nor id is skipped rather than emitting a blank path segment; DiscoverAsync's remark no longer claims DriverInstanceActor retries (it does not for a Once driver, and injection is dormant in v3); and two flake seeds in the fake are gone (Dispose re-read its own counter; _disposeCts was disposed under a racing SampleAsync). 439/439. Every changed behaviour falsified by mutation.
66 lines
2.0 KiB
C#
66 lines
2.0 KiB
C#
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|
|
|
/// <summary>
|
|
/// Captures what the driver logs, so that "and it says so" can be asserted rather than assumed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Used where the log line <b>is</b> the behaviour: a browse scope that matches nothing, and a
|
|
/// subscription the driver silently refuses to serve. Both are cases whose only other symptom is
|
|
/// an operator staring at an empty panel, so a fix that emits nothing is not a fix.
|
|
/// </remarks>
|
|
internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
|
{
|
|
/// <summary>Formatted <see cref="LogLevel.Warning"/> lines, in order.</summary>
|
|
public List<string> Warnings { get; } = [];
|
|
|
|
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
|
|
public List<string> Errors { get; } = [];
|
|
|
|
/// <inheritdoc/>
|
|
public IDisposable? BeginScope<TState>(TState state)
|
|
where TState : notnull => null;
|
|
|
|
/// <inheritdoc/>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <inheritdoc/>
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(formatter);
|
|
|
|
var sink = logLevel switch
|
|
{
|
|
LogLevel.Warning => Warnings,
|
|
LogLevel.Error => Errors,
|
|
_ => null,
|
|
};
|
|
|
|
// Locked: the /sample pump logs from its own task while the test thread reads.
|
|
if (sink is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (sink)
|
|
{
|
|
sink.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
|
|
/// <summary>A snapshot copy of <see cref="Warnings"/>, safe to enumerate while the pump runs.</summary>
|
|
public IReadOnlyList<string> WarningsSnapshot()
|
|
{
|
|
lock (Warnings)
|
|
{
|
|
return [.. Warnings];
|
|
}
|
|
}
|
|
}
|