fix(s7): a CPU refusal is per-tag Bad and counts as answered (R07:H4)
A deterministic PLC refusal (object does not exist, address out of range,
type inconsistent...) degraded driver health, and an all-refused driver (a
wrong DB number) sat Degraded with LastSuccessfulRead frozen, so the 90 s
comms-loss watchdog reconnected it forever against a reachable PLC.
- IsRefusal / IsAccessDenied classify over the whole exception chain. Live
probe on the snap7 fixture: S7.Net's READ paths raise the item error as a
BARE Exception ("Received error from PLC: ..."), its write paths as
PlcException(WriteData). The old PlcException-only match missed the read
shape entirely, so a real read access-denied was BadCommunicationError +
Degraded instead of the Faulted PUT/GET alert; the Test Connect probe had
the same blind spot.
- A refusal (read or write) is BadDeviceFailure with a once-per-tag Warning,
no health write and no reopen; it counts as answered, so it advances
LastSuccessfulRead and restores Healthy after a comms fault. An undecodable
buffer (S7ValueRangeException) also counts as answered.
- A write batch the CPU answered without a failure advances
LastSuccessfulRead and retires a Degraded the write side itself published.
- Tests that used ReadData as their generic "device fault" now use SendData
so their Degraded leg is still exercised.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+13
-2
@@ -312,7 +312,8 @@ Portal under *Protection & Security* for the CPU.
|
||||
| Read-only tag written | `BadNotWritable` | unchanged |
|
||||
| Unimplemented data type | `BadNotSupported` | unchanged |
|
||||
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
|
||||
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
|
||||
| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
|
||||
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
|
||||
| Socket / timeout | `BadCommunicationError` | `Degraded` |
|
||||
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
|
||||
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
|
||||
@@ -323,7 +324,17 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
|
||||
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
|
||||
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
|
||||
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
|
||||
transient `Degraded` write failure is still cleared by a clean read, as before.
|
||||
transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
|
||||
answers without a failure.
|
||||
|
||||
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
|
||||
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
|
||||
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
|
||||
access-denied text inside either is the `Faulted` alert. A refusal is deterministic, so it neither
|
||||
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
|
||||
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
|
||||
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
|
||||
watchdog reconnected it every 90 s against a perfectly reachable PLC.
|
||||
|
||||
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
|
||||
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
|
||||
|
||||
@@ -208,6 +208,19 @@ public sealed class S7Driver
|
||||
/// Guarded by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private (DriverState State, string Message)? _writeFault;
|
||||
|
||||
/// <summary>
|
||||
/// The message of the last Degraded the WRITE side published, or null. A clean write batch
|
||||
/// retires a Degraded carrying exactly this message (a good write proves the comms fault it
|
||||
/// reported is gone) and leaves any other Degraded to the read pass. Guarded by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private string? _writeOwnedError;
|
||||
|
||||
/// <summary>
|
||||
/// Tags whose refusal has already been logged this generation — the once-per-tag Warning. Cleared
|
||||
/// by every initialize publish.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _refusalWarned = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -797,7 +810,7 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -903,6 +916,13 @@ public sealed class S7Driver
|
||||
/// <summary>Records that one tag read successfully.</summary>
|
||||
public void RecordSuccess() => AnySuccess = true;
|
||||
|
||||
/// <summary>
|
||||
/// Records that the CPU answered for one tag without serving it (a refusal, an undecodable
|
||||
/// buffer). Not a failure — it ranks nothing — but it IS proof of a live device, so it
|
||||
/// advances <c>LastSuccessfulRead</c> exactly like a success.
|
||||
/// </summary>
|
||||
public void RecordAnswered() => AnySuccess = true;
|
||||
|
||||
/// <summary>
|
||||
/// Records one failed tag, keeping the worst state and the message that explains it.
|
||||
/// </summary>
|
||||
@@ -1098,7 +1118,7 @@ public sealed class S7Driver
|
||||
{
|
||||
// Decoding is pure and synchronous — no cancellation can surface here, so there
|
||||
// is deliberately no OperationCanceledException arm to shadow the request one.
|
||||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [tag.Name]), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1195,7 +1215,7 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var status = RecordReadFailure(ex, outcome);
|
||||
var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name));
|
||||
foreach (var member in span.Members)
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
|
||||
return;
|
||||
@@ -1214,7 +1234,7 @@ public sealed class S7Driver
|
||||
// Decoding is pure and synchronous — no cancellation can surface here, so there is
|
||||
// deliberately no OperationCanceledException arm to shadow the block-read one above.
|
||||
results[member.OriginalIndex] =
|
||||
new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1289,8 +1309,10 @@ public sealed class S7Driver
|
||||
/// </remarks>
|
||||
/// <param name="ex">The exception observed on a read.</param>
|
||||
/// <param name="outcome">The pass's outcome accumulator this failure is recorded into.</param>
|
||||
/// <param name="tagNames">The tags the failure belongs to (every member of a failed span), for the
|
||||
/// once-per-tag refusal Warning.</param>
|
||||
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
|
||||
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
|
||||
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome, IEnumerable<string> tagNames)
|
||||
{
|
||||
switch (ex)
|
||||
{
|
||||
@@ -1299,19 +1321,33 @@ public sealed class S7Driver
|
||||
|
||||
case S7ValueRangeException:
|
||||
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
|
||||
// problem, not a transport fault — no health change, no connection teardown.
|
||||
// problem, not a transport fault — no health change, no connection teardown. The
|
||||
// bytes came off the wire, so the CPU answered.
|
||||
outcome.RecordAnswered();
|
||||
return StatusBadOutOfRange;
|
||||
|
||||
case PlcException pex when IsAccessDenied(pex):
|
||||
case var _ when IsAccessDenied(ex):
|
||||
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
|
||||
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
|
||||
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
|
||||
// Degraded — per driver-specs.md §5.
|
||||
// Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ
|
||||
// paths raise the CPU's item error as a bare Exception, not a PlcException.
|
||||
outcome.RecordFailure(DriverState.Faulted,
|
||||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
|
||||
return StatusBadNotSupported;
|
||||
|
||||
case var _ when IsRefusal(ex):
|
||||
// The CPU answered and refused the item (object does not exist, address out of
|
||||
// range, type inconsistent...) — deterministic, so a retry, a reopen or a Degraded
|
||||
// would all be wrong: per-tag Bad, a once-per-tag Warning, no health write, and it
|
||||
// counts as answered so LastSuccessfulRead advances (R07:H4). An all-refused driver
|
||||
// (wrong DB number) used to sit Degraded with a frozen timestamp and trip the
|
||||
// comms-loss watchdog every 90 s against a reachable PLC.
|
||||
outcome.RecordAnswered();
|
||||
foreach (var name in tagNames) WarnRefusalOnce(name, "read", ex);
|
||||
return StatusBadDeviceFailure;
|
||||
|
||||
case PlcException pex:
|
||||
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
|
||||
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
|
||||
@@ -1969,7 +2005,7 @@ public sealed class S7Driver
|
||||
// Client-supplied value of the wrong shape — not a device or comms fault.
|
||||
results[i] = new WriteResult(StatusBadTypeMismatch);
|
||||
}
|
||||
catch (PlcException pex) when (IsAccessDenied(pex))
|
||||
catch (Exception ex) when (IsAccessDenied(ex))
|
||||
{
|
||||
// PUT/GET-disabled / access-protection on write — same permanent
|
||||
// configuration fault as on read. BadNotSupported +
|
||||
@@ -1977,7 +2013,15 @@ public sealed class S7Driver
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
outcome.RecordFailure(DriverState.Faulted,
|
||||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (IsRefusal(ex))
|
||||
{
|
||||
// The CPU answered and refused the item — per-tag Bad, once-per-tag Warning, no
|
||||
// Degraded, and it counts as answered (R07:H4).
|
||||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||
outcome.RecordAnswered();
|
||||
WarnRefusalOnce(tag.Name, "write", ex);
|
||||
}
|
||||
catch (PlcException pex)
|
||||
{
|
||||
@@ -2029,16 +2073,24 @@ public sealed class S7Driver
|
||||
else if (outcome.AnySuccess)
|
||||
_writeFault = null; // a write got through — the protection fault is gone
|
||||
|
||||
var answeredAt = outcome.AnyAnswered ? DateTime.UtcNow : (DateTime?)null;
|
||||
if (worst == DriverState.Healthy)
|
||||
{
|
||||
// Success-only batch: no health write, except to retire a Faulted that WE (the write
|
||||
// side) were holding — a clean write is the only evidence the alert has cleared.
|
||||
// Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the
|
||||
// read pass's to clear, and Health still carries the write fault's own message iff it is ours.
|
||||
if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned)
|
||||
TryUpdateHealth(gen, h => h.State == DriverState.Faulted && h.LastError == owned.Message
|
||||
? new DriverHealth(DriverState.Healthy, h.LastSuccessfulRead, null)
|
||||
: null);
|
||||
// Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead
|
||||
// advances, and a non-Healthy state the WRITE side owns is retired — a Faulted only by a
|
||||
// real success (a refusal does not prove PUT/GET is enabled), a write-side comms Degraded
|
||||
// by any answer. A state the read side owns (its own message) is the read pass's to clear.
|
||||
var ownedFault = outcome.AnySuccess && _writeFault is null ? priorWriteFault?.Message : null;
|
||||
var ownedDegraded = _writeOwnedError;
|
||||
_writeOwnedError = null;
|
||||
TryUpdateHealth(gen, h =>
|
||||
{
|
||||
var lsr = answeredAt ?? h.LastSuccessfulRead;
|
||||
if ((h.State == DriverState.Faulted && ownedFault is not null && h.LastError == ownedFault)
|
||||
|| (h.State == DriverState.Degraded && ownedDegraded is not null && h.LastError == ownedDegraded))
|
||||
return new DriverHealth(DriverState.Healthy, lsr, null);
|
||||
return lsr == h.LastSuccessfulRead ? null : h with { LastSuccessfulRead = lsr };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2050,7 +2102,8 @@ public sealed class S7Driver
|
||||
state = wf.State;
|
||||
error = wf.Message;
|
||||
}
|
||||
TryUpdateHealth(gen, h => new DriverHealth(state, h.LastSuccessfulRead, error));
|
||||
if (state == DriverState.Degraded) _writeOwnedError = error;
|
||||
TryUpdateHealth(gen, h => new DriverHealth(state, answeredAt ?? h.LastSuccessfulRead, error));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2068,10 +2121,18 @@ public sealed class S7Driver
|
||||
public DriverState WorstFailure => _inner.WorstFailure;
|
||||
/// <summary>The message explaining <see cref="WorstFailure"/>.</summary>
|
||||
public string? WorstError => _inner.WorstError;
|
||||
/// <summary>True once the batch observed a success or a failure.</summary>
|
||||
public bool HasObservations => _inner.HasObservations;
|
||||
/// <summary>True once the batch observed a success, a refusal or a failure.</summary>
|
||||
public bool HasObservations => _inner.HasObservations || AnyAnswered;
|
||||
/// <summary>True once the CPU answered any item (a success or a refusal) — proof of a live device.</summary>
|
||||
public bool AnyAnswered { get; private set; }
|
||||
/// <summary>Records one successful write.</summary>
|
||||
public void RecordSuccess() => _inner.RecordSuccess();
|
||||
public void RecordSuccess()
|
||||
{
|
||||
_inner.RecordSuccess();
|
||||
AnyAnswered = true;
|
||||
}
|
||||
/// <summary>Records a write the CPU answered by refusing it: not a success, not a failure.</summary>
|
||||
public void RecordAnswered() => AnyAnswered = true;
|
||||
/// <summary>Records one failed write.</summary>
|
||||
/// <param name="state">The state the failure would have stamped alone.</param>
|
||||
/// <param name="message">The operator-facing message.</param>
|
||||
@@ -2407,19 +2468,82 @@ public sealed class S7Driver
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
|
||||
/// <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
|
||||
/// <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
|
||||
/// the response-code validator throws a plain <see cref="Exception"/> for the S7
|
||||
/// <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
|
||||
/// no typed error code for it, so the inner message is the only discriminator
|
||||
/// S7.Net exposes.
|
||||
/// True when <paramref name="ex"/> is a deterministic PLC REFUSAL of the item: the CPU answered
|
||||
/// with an S7 item error (object does not exist, address out of range, data type inconsistent /
|
||||
/// not supported, hardware fault, or an unknown status), or S7.Net raised a
|
||||
/// <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/> over a response it could
|
||||
/// not use. Excludes the access-denied refusal (its own sticky Faulted config alert) and
|
||||
/// anything carrying a socket / timeout / framing / cancellation fault in its chain — those
|
||||
/// wrap a transport failure, not an answer.
|
||||
/// </summary>
|
||||
/// <param name="pex">The exception raised by S7.Net.</param>
|
||||
/// <returns><see langword="true"/> when the PLC refused access to the object.</returns>
|
||||
internal static bool IsAccessDenied(PlcException pex)
|
||||
/// <remarks>
|
||||
/// Live-probed against the snap7 fixture (2026-10-22): S7.Net's READ paths
|
||||
/// (<c>ReadAsync(string)</c>, <c>ReadBytesAsync</c>) raise the item error as a BARE
|
||||
/// <see cref="Exception"/> — "Received error from PLC: Object does not exist." — not wrapped in
|
||||
/// a <see cref="PlcException"/>; its write paths wrap whatever they hit in a
|
||||
/// <c>PlcException(WriteData)</c>. Both shapes are matched.
|
||||
/// </remarks>
|
||||
/// <param name="ex">The exception raised by S7.Net.</param>
|
||||
/// <returns><see langword="true"/> when the CPU answered and refused.</returns>
|
||||
internal static bool IsRefusal(Exception ex)
|
||||
{
|
||||
for (Exception? e = pex; e is not null; e = e.InnerException)
|
||||
if (IsAccessDenied(ex) || IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false;
|
||||
if (ex is PlcException { ErrorCode: ErrorCode.ReadData or ErrorCode.WriteData }) return true;
|
||||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||
if (IsS7ItemError(e)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for the bare <see cref="Exception"/> S7.Net's response-code validator raises for an S7
|
||||
/// item error ("Received error from PLC: …" / "Invalid response from PLC: statusCode=…").
|
||||
/// Exact type only: a derived exception carrying similar words is not S7.Net's validator.
|
||||
/// </summary>
|
||||
/// <param name="e">One exception of a chain.</param>
|
||||
/// <returns><see langword="true"/> for the validator's item-error shape.</returns>
|
||||
private static bool IsS7ItemError(Exception e)
|
||||
=> e.GetType() == typeof(Exception)
|
||||
&& (e.Message.StartsWith("Received error from PLC", StringComparison.Ordinal)
|
||||
|| e.Message.StartsWith("Invalid response from PLC", StringComparison.Ordinal));
|
||||
|
||||
/// <summary>True when a cancellation is buried in the exception chain (a half-read PDU, not a refusal).</summary>
|
||||
/// <param name="ex">The exception to inspect.</param>
|
||||
/// <returns><see langword="true"/> when any inner exception is an <see cref="OperationCanceledException"/>.</returns>
|
||||
private static bool HasCancellationInChain(Exception ex)
|
||||
{
|
||||
for (var e = ex.InnerException; e is not null; e = e.InnerException)
|
||||
if (e is OperationCanceledException) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Logs a refusal once per tag per generation.</summary>
|
||||
/// <param name="tagName">The refused tag.</param>
|
||||
/// <param name="operation">"read" or "write".</param>
|
||||
/// <param name="ex">The refusal.</param>
|
||||
private void WarnRefusalOnce(string tagName, string operation, Exception ex)
|
||||
{
|
||||
if (!_refusalWarned.TryAdd(tagName, 0)) return;
|
||||
_logger.LogWarning(
|
||||
"S7 CPU refused the {Operation} of tag {Tag} ({Reason}); it reads Bad until the tag or the PLC program is fixed. " +
|
||||
"Driver health is unaffected. Further refusals of this tag are not logged. Driver={DriverInstanceId}",
|
||||
operation, tagName, ex.InnerException?.Message ?? ex.Message, _driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects an S7 PUT/GET-disabled / access-protection fault. S7.Net's response-code validator
|
||||
/// throws a plain <see cref="Exception"/> for the S7 <c>AccessingObjectNotAllowed</c> status —
|
||||
/// bare on the read paths, wrapped in a <see cref="PlcException"/> (<see cref="ErrorCode.WriteData"/>)
|
||||
/// on the write paths. There is no typed error code for it, so the message is the only
|
||||
/// discriminator S7.Net exposes. The top-level exception must be S7.Net-shaped (a
|
||||
/// <see cref="PlcException"/> or the validator's bare item error), so an unrelated exception that
|
||||
/// happens to say "not allowed" is not mistaken for it.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception raised by S7.Net.</param>
|
||||
/// <returns><see langword="true"/> when the PLC refused access to the object.</returns>
|
||||
internal static bool IsAccessDenied(Exception ex)
|
||||
{
|
||||
if (ex is not PlcException && !IsS7ItemError(ex)) return false;
|
||||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||
{
|
||||
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
|
||||
|| e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -128,15 +128,17 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
|
||||
// S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from
|
||||
// PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own).
|
||||
catch (Exception ex) when (S7Driver.IsAccessDenied(ex))
|
||||
{
|
||||
return new(false,
|
||||
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
|
||||
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null);
|
||||
}
|
||||
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
|
||||
catch (Exception ex) when (S7Driver.IsRefusal(ex))
|
||||
{
|
||||
return null; // address not available on this CPU — not a connection problem
|
||||
return null; // address not available on this CPU — the CPU answered; not a connection problem
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:H4 — a real CPU refusal over real S7comm. The snap7 server has no DB99, so it
|
||||
/// answers a read of <c>DB99.DBW0</c> with an S7 item error. That is a per-tag Bad with no health
|
||||
/// impact (and LastSuccessfulRead advances), not a Degraded that would trip the comms-loss watchdog
|
||||
/// against a reachable PLC.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>A refused DB read is per-tag Bad, keeps the driver Healthy, and its neighbour reads Good.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_of_a_missing_DB_is_refused_per_tag_without_degrading()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
|
||||
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
|
||||
Probe = baseOptions.Probe,
|
||||
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing/Db99", "DB99.DBW0", S7DataType.UInt16, Writable: false))],
|
||||
};
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
|
||||
// Read the refused tag ALONE first: an all-refused pass is exactly the case that used to
|
||||
// freeze LastSuccessfulRead and latch Degraded.
|
||||
var refused = await drv.ReadAsync(["Missing/Db99"], ct);
|
||||
refused[0].StatusCode.ShouldNotBe(0u);
|
||||
refused[0].Value.ShouldBeNull();
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault");
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
|
||||
|
||||
// Positive control: the same connection still serves the seeded neighbour.
|
||||
var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct);
|
||||
good[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(good[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
}
|
||||
|
||||
/// <summary>A refused write is per-tag Bad and keeps the driver Healthy; a good write still lands.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_to_a_missing_DB_is_refused_per_tag_without_degrading()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
|
||||
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
|
||||
Probe = baseOptions.Probe,
|
||||
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing/Db99W", "DB99.DBW0", S7DataType.UInt16, Writable: true))],
|
||||
};
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-write-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var refused = await drv.WriteAsync([new WriteRequest("Missing/Db99W", (ushort)5)], ct);
|
||||
refused[0].StatusCode.ShouldNotBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a CPU write refusal is not a comms fault");
|
||||
|
||||
// Positive control: a write the CPU accepts still lands (and reads back).
|
||||
var ok = await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)4711)], ct);
|
||||
ok[0].StatusCode.ShouldBe(0u);
|
||||
var back = await drv.ReadAsync([S7_1500Profile.WriteScratchTag], ct);
|
||||
Convert.ToInt32(back[0].Value).ShouldBe(4711);
|
||||
await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)0)], ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>An <see cref="ILogger{T}"/> that records (level, message) pairs.</summary>
|
||||
internal sealed class CapturingS7Logger : ILogger<S7Driver>
|
||||
{
|
||||
private readonly List<(LogLevel Level, string Message)> _entries = [];
|
||||
|
||||
/// <summary>A snapshot of every entry logged so far.</summary>
|
||||
public IReadOnlyList<(LogLevel Level, string Message)> Entries
|
||||
{
|
||||
get { lock (_entries) return [.. _entries]; }
|
||||
}
|
||||
|
||||
/// <summary>The Warning messages logged so far.</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
=> [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
|
||||
|
||||
/// <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)
|
||||
{
|
||||
lock (_entries) _entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using S7.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
|
||||
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
|
||||
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
|
||||
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
|
||||
/// trips the 90 s comms-loss watchdog against a reachable PLC.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7RefusalHealthTests
|
||||
{
|
||||
private const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>
|
||||
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
|
||||
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
|
||||
/// </summary>
|
||||
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
|
||||
|
||||
private static PlcException WriteRefusal() =>
|
||||
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
|
||||
|
||||
private static S7DriverOptions Options() => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
// Grouping off: one wire request per tag keeps each tag's outcome its own.
|
||||
SpanReadMaxGapBytes = -1,
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
|
||||
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
|
||||
};
|
||||
|
||||
private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log)> StartAsync()
|
||||
{
|
||||
var factory = new ScriptedS7PlcFactory();
|
||||
var log = new CapturingS7Logger();
|
||||
var drv = new S7Driver(Options(), "s7-refusal", factory, log);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, factory, log);
|
||||
}
|
||||
|
||||
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, log) = await StartAsync();
|
||||
using var _ = drv;
|
||||
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
||||
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
for (var pass = 0; pass < 3; pass++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Missing"], ct);
|
||||
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
|
||||
}
|
||||
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before,
|
||||
"the CPU answered, so the all-refused pass still advances LastSuccessfulRead");
|
||||
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
|
||||
|
||||
// Positive control: the good tag still reads, and nothing reopened the connection.
|
||||
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
|
||||
factory.Created.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
|
||||
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
|
||||
/// watchdog's trip condition.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_after_a_comms_fault_restores_healthy()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _) = await StartAsync();
|
||||
using var __ = drv;
|
||||
|
||||
// Positive control: a genuine comms fault DOES degrade.
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
await drv.ReadAsync(["Missing"], ct);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
||||
var r = await drv.ReadAsync(["Missing"], ct);
|
||||
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
var after = drv.GetHealth();
|
||||
after.State.ShouldBe(DriverState.Healthy);
|
||||
after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value);
|
||||
}
|
||||
|
||||
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
|
||||
|
||||
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
|
||||
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
|
||||
/// refusal fell through to BadCommunicationError + Degraded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
|
||||
|
||||
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
|
||||
}
|
||||
|
||||
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _) = await StartAsync();
|
||||
using var __ = drv;
|
||||
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
|
||||
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, log) = await StartAsync();
|
||||
using var _ = drv;
|
||||
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
|
||||
|
||||
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
|
||||
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
||||
await drv.ReadAsync(["Good"], ct);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
|
||||
w[0].StatusCode.ShouldBe(BadDeviceFailure);
|
||||
}
|
||||
|
||||
var after = drv.GetHealth();
|
||||
after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value);
|
||||
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
|
||||
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_write_side_degraded()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (drv, factory, _) = await StartAsync();
|
||||
using var _ = drv;
|
||||
|
||||
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
|
||||
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
|
||||
bad[0].StatusCode.ShouldNotBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
|
||||
|
||||
factory.Write = (_, _, _) => Task.CompletedTask;
|
||||
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
|
||||
good[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -202,9 +202,10 @@ public sealed class S7SpanReadParityTests
|
||||
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
|
||||
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
|
||||
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
// A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with
|
||||
// no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor
|
||||
// connection-fatal — the driver maps it to BadDeviceFailure + Degraded on every member.
|
||||
var fault = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
|
||||
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
||||
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
|
||||
@@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass
|
||||
/// <c>Degraded</c> even though a later member of the same block decoded fine.
|
||||
/// The SPAN path guards each member's decode: one member that fails to decode is Bad on its
|
||||
/// own while a later member of the same block decodes fine. S7.Net rejects the malformed
|
||||
/// STRING with a <c>ReadData</c> <see cref="PlcException"/> — the CPU answered, the buffer is
|
||||
/// what is wrong — so since R07:H4 it is a per-tag refusal with no Degraded (like the
|
||||
/// codec's own <c>BadOutOfRange</c>), and LastSuccessfulRead still advances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The block read itself succeeds — this is the per-member decode guard, the span-path
|
||||
@@ -480,7 +484,7 @@ public sealed class S7SpanReadParityTests
|
||||
/// last-writer-wins used to erase.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task One_undecodable_span_member_leaves_the_pass_Degraded()
|
||||
public async Task One_undecodable_span_member_is_bad_alone_without_degrading()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is
|
||||
@@ -516,7 +520,7 @@ public sealed class S7SpanReadParityTests
|
||||
results[1].Value.ShouldNotBeNull();
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased");
|
||||
health.State.ShouldBe(DriverState.Healthy, "an undecodable buffer is a data problem, not a comms fault");
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
}
|
||||
|
||||
@@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests
|
||||
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
|
||||
// observed AFTER it. C: no fault at all.
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
// maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the
|
||||
// faults can be aimed at individual addresses.
|
||||
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory);
|
||||
@@ -584,7 +588,7 @@ public sealed class S7SpanReadParityTests
|
||||
var factory = new ImageS7PlcFactory(Images());
|
||||
// A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert
|
||||
// (Faulted) after it. C: no fault at all.
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
|
||||
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
|
||||
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
@@ -224,6 +224,23 @@ public sealed class S7TsapAndPduTests
|
||||
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shapes a real read raises — S7.Net's read paths throw the CPU's item error as a BARE
|
||||
/// Exception (live-probed on the snap7 fixture). Before, the probe only matched PlcException, so a
|
||||
/// PUT/GET-disabled CPU got the generic "test read failed" text and a missing address failed the probe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Probe_classifies_the_bare_read_path_shapes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var denied = await S7DriverProbe.CheckDataAccessAsync(
|
||||
_ => throw new Exception("Received error from PLC: Accessing object not allowed."), ct);
|
||||
denied.ShouldNotBeNull().Message!.ShouldContain("PUT/GET");
|
||||
|
||||
(await S7DriverProbe.CheckDataAccessAsync(
|
||||
_ => throw new Exception("Received error from PLC: Object does not exist."), ct)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_reports_any_other_read_fault_as_a_failure()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user