@@ -115,6 +115,8 @@ public sealed class S7Driver
private const uint StatusBadNodeIdUnknown = 0x80340000 u ;
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
private const uint StatusBadNotSupported = 0x803D0000 u ;
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
private const uint StatusBadTypeMismatch = 0x80740000 u ;
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
private const uint StatusBadNotWritable = 0x803B0000 u ;
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
@@ -179,6 +181,38 @@ public sealed class S7Driver
private bool _plcDead ;
private DriverHealth _health = new ( DriverState . Unknown , null , null ) ;
/// <summary>
/// The health snapshot, accessed with volatile semantics: it is written from the gate-holding
/// read/write passes, the poll engine's error sink and the lifecycle methods, and read lock-free
/// by <see cref="GetHealth"/> (the health tick) — the same Volatile idiom <c>ModbusDriver</c> uses.
/// <see cref="DriverHealth"/> is an immutable record, so a reference swap is a complete update.
/// </summary>
private DriverHealth Health
{
get = > Volatile . Read ( ref _health ) ;
set = > Volatile . Write ( ref _health , value ) ;
}
/// <summary>
/// Per-device connect-attempt throttle (STAB-21): a dead PLC costs one connect attempt per
/// backoff window (1 s doubling to 30 s) instead of one per data call / poll tick / probe tick.
/// Not internally synchronized — only touched from <see cref="EnsureConnectedAsync"/> (under
/// <see cref="_gate"/>) and from init/shutdown, which run with every loop drained.
/// </summary>
private readonly ConnectionBackoff _connectBackoff =
new ( TimeSpan . FromSeconds ( 1 ) , TimeSpan . FromSeconds ( 30 ) ) ;
/// <summary>Clock the connect backoff is measured on. Internal so tests step it deterministically.</summary>
internal Func < DateTime > BackoffClock { get ; set ; } = static ( ) = > DateTime . UtcNow ;
/// <summary>
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
/// protected); it is cleared by the next write pass that writes something successfully.
/// Guarded by <see cref="_gate"/>.
/// </summary>
private ( DriverState State , string Message ) ? _writeFault ;
private bool _disposed ;
/// <inheritdoc />
@@ -189,7 +223,7 @@ public sealed class S7Driver
/// <inheritdoc />
public async Task InitializeAsync ( string driverConfigJson , CancellationToken cancellationToken )
{
_health = new DriverHealth ( DriverState . Initializing , null , null ) ;
Health = new DriverHealth ( DriverState . Initializing , null , null ) ;
try
{
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
@@ -263,6 +297,7 @@ public sealed class S7Driver
Plc = plc ;
_plcDead = false ;
_connectBackoff . RecordSuccess ( ) ;
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
@@ -274,7 +309,7 @@ public sealed class S7Driver
}
_initialized = true ;
_health = new DriverHealth ( DriverState . Healthy , DateTime . UtcNow , null ) ;
Health = new DriverHealth ( DriverState . Healthy , DateTime . UtcNow , null ) ;
_logger . LogInformation ( "S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}" ,
_driverInstanceId , _options . Host , _options . CpuType , _tagsByRawPath . Count ) ;
@@ -299,7 +334,7 @@ public sealed class S7Driver
Plc = null ;
_initialized = false ;
_plcDead = false ;
_health = new DriverHealth ( DriverState . Faulted , null , ex . Message ) ;
Health = new DriverHealth ( DriverState . Faulted , null , ex . Message ) ;
_logger . LogError ( ex , "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}" , _driverInstanceId , _options . Host ) ;
throw ;
}
@@ -346,13 +381,15 @@ public sealed class S7Driver
_initialized = false ;
_plcDead = false ;
_writeFault = null ;
_connectBackoff . RecordSuccess ( ) ;
try { Plc ? . Dispose ( ) ; } catch { /* best-effort — tearing down anyway */ }
Plc = null ;
_health = new DriverHealth ( DriverState . Unknown , _health . LastSuccessfulRead , null ) ;
Health = new DriverHealth ( DriverState . Unknown , Health . LastSuccessfulRead , null ) ;
}
/// <inheritdoc />
public DriverHealth GetHealth ( ) = > _health ;
public DriverHealth GetHealth ( ) = > Health ;
/// <summary>
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
@@ -453,19 +490,18 @@ public sealed class S7Driver
"Timer/Counter are read-only this phase; set Writable=false." ) ;
}
// (e) Array tag declared Writable — WriteOneAsync has no WriteArrayAsync path yet.
// Without this guard a writable array node is discovered and
// accepted, then every write returns BadCommunicationError (InvalidCastException from
// BoxValueForWrite receiving a typed array) instead of the informative BadNotSupported
// the caller could act on. Wide-type arrays are already rejected above (guard-a); thi s
// t argets non-wide types (Int16[], Byte[], Float32[], etc.) that ARE supported for
// reading but not yet for writing.
if ( t . ArrayCount is > = 1 & & t . Writable )
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
// Float32) writes as ONE contiguous block (EncodeArrayBlock) — S7-016. Wide-type array s
// are already rejected above (guard-a).
if ( t . ArrayCount is > = 1 & & t . Writable & & t . DataType = = S7DataType . Bool )
{
throw new NotSupportedException (
$"S7 tag '{t.Name}' is a {t.DataType} array (ArrayCount={t.ArrayCount}) declared Writable — " +
"array writes are not yet supported by the S7 driver. Set Writable=false for read-only array tags, " +
"or split into individual scalar tags until array-write support land s." ) ;
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
"bits). Set Writable=false, or write the bits as individual scalar Bool tag s." ) ;
}
}
}
@@ -567,7 +603,7 @@ public sealed class S7Driver
{
for ( var i = 0 ; i < fullReferences . Count ; i + + )
results [ i ] = new DataValueSnapshot ( null , StatusBadCommunicationError , null , now ) ;
_health = new DriverHealth ( DriverState . Degraded , _health . LastSuccessfulRead , ex . Message ) ;
Health = new DriverHealth ( DriverState . Degraded , Health . LastSuccessfulRead , ex . Message ) ;
return results ;
}
@@ -683,12 +719,23 @@ public sealed class S7Driver
//
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
// state — see ReadPassOutcome for why that is load-bearing.
//
// A sticky write-side Faulted (PUT/GET denied) outranks a read pass that found less
// wrong: reads can succeed against a CPU whose writes are protected, and a clean read
// must not erase the config alert. The write pass clears it on its next success.
if ( outcome . HasObservations )
{
_health = new DriverHealth (
outcome . WorstFailure ,
outcome . AnySuccess ? now : _health . LastSuccessfulRead ,
outcome . WorstFailure = = DriverState . Healthy ? null : outcome . WorstError ) ;
var state = outcome . WorstFailure ;
var error = state = = DriverState . Healthy ? null : outcome . WorstError ;
if ( _writeFault is { } wf & & ReadPassOutcome . Rank ( wf . State ) > ReadPassOutcome . Rank ( state ) )
{
state = wf . State ;
error = wf . Message ;
}
Health = new DriverHealth (
state ,
outcome . AnySuccess ? now : Health . LastSuccessfulRead ,
error ) ;
}
_gate . Release ( ) ;
@@ -698,7 +745,7 @@ public sealed class S7Driver
/// <summary>
/// The outcomes ONE <see cref="ReadAsync"/> pass observed, accumulated so the pass writes
/// <see cref="_h ealth"/> exactly once — <b>worst state wins</b>.
/// <see cref="H ealth"/> exactly once — <b>worst state wins</b>.
/// </summary>
/// <remarks>
/// <para>
@@ -780,7 +827,7 @@ public sealed class S7Driver
/// <summary>Severity order the pass ranks by: Healthy (none) < Degraded < Faulted.</summary>
/// <param name="state">The state to rank.</param>
/// <returns>Its severity, higher being worse.</returns>
private static int Rank ( DriverState state ) = > state switch
internal static int Rank ( DriverState state ) = > state switch
{
DriverState . Faulted = > 2 ,
DriverState . Degraded = > 1 ,
@@ -1129,7 +1176,7 @@ public sealed class S7Driver
/// <remarks>
/// <para>
/// <b>The health state is recorded, not applied.</b> It goes into
/// <paramref name="outcome"/> so the pass can write <see cref="_h ealth"/> once at the
/// <paramref name="outcome"/> so the pass can write <see cref="H ealth"/> once at the
/// end, worst-state-wins — writing it here is what let a later good tag erase this
/// failure. The <c>_plcDead</c> / <see cref="MarkConnectionDeadIfFatal"/> side effects
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
@@ -1691,6 +1738,8 @@ public sealed class S7Driver
ArgumentNullException . ThrowIfNull ( writes ) ;
RequireInitialized ( ) ;
var results = new WriteResult [ writes . Count ] ;
// Health is decided ONCE, at the end of the batch, worst-state-wins — see WritePassOutcome.
var outcome = new WritePassOutcome ( ) ;
await _gate . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
try
@@ -1709,7 +1758,7 @@ public sealed class S7Driver
{
for ( var i = 0 ; i < writes . Count ; i + + )
results [ i ] = new WriteResult ( StatusBadCommunicationError ) ;
_health = new DriverHealth ( DriverState . Degraded , _health . LastSuccessfulRead , ex . Message ) ;
outcome . RecordFailure ( DriverState . Degraded , ex . Message ) ;
return results ;
}
@@ -1746,6 +1795,7 @@ public sealed class S7Driver
{
await WriteOneAsync ( plc , tag , w . Value , cancellationToken ) . ConfigureAwait ( false ) ;
results [ i ] = new WriteResult ( 0 u ) ;
outcome . RecordSuccess ( ) ;
}
catch ( OperationCanceledException )
{
@@ -1761,13 +1811,18 @@ public sealed class S7Driver
{
results [ i ] = new WriteResult ( StatusBadNotSupported ) ;
}
catch ( S7WriteValueException )
{
// Client-supplied value of the wrong shape — not a device or comms fault.
results [ i ] = new WriteResult ( StatusBadTypeMismatch ) ;
}
catch ( PlcException pex ) when ( IsAccessDenied ( pex ) )
{
// PUT/GET-disabled / access-protection on write — same permanent
// configuration fault as on read. BadNotSupported +
// a config-alert health state, not a transient device failure.
results [ i ] = new WriteResult ( StatusBadNotSupported ) ;
_health = new DriverHealth ( DriverState . Faulted , _health . LastSuccessfulRead ,
outcome . RecordFailure ( DriverState . Faulted ,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}" ) ;
}
@@ -1779,7 +1834,7 @@ public sealed class S7Driver
// dead so the next call reopens.
MarkConnectionDeadIfFatal ( pex ) ;
results [ i ] = new WriteResult ( StatusBadDeviceFailure ) ;
_health = new DriverHealth ( DriverState . Degraded , _health . LastSuccessfulRead , pex . Message ) ;
outcome . RecordFailure ( DriverState . Degraded , pex . Message ) ;
}
catch ( Exception ex )
{
@@ -1788,25 +1843,84 @@ public sealed class S7Driver
// dead when it's a connection loss.
MarkConnectionDeadIfFatal ( ex ) ;
results [ i ] = new WriteResult ( StatusBadCommunicationError ) ;
_health = new DriverHealth ( DriverState . Degraded , _health . LastSuccessfulRead , ex . Message ) ;
outcome . RecordFailure ( DriverState . Degraded , ex . Message ) ;
}
}
}
finally { _gate . Release ( ) ; }
finally
{
// THE single health write of the batch (a finally, so an OCE mid-batch still flushes the
// evidence gathered so far — a pass that observed nothing writes nothing).
ApplyWritePassHealth ( outcome ) ;
_gate . Release ( ) ;
}
return results ;
}
/// <summary>
/// Publishes one write batch's verdict (STAB-22): worst state wins over every outcome the
/// batch recorded, so a Faulted access-denied item is not erased by a later Degraded or
/// successful sibling. Maintains <see cref="_writeFault"/>, the sticky Faulted a read pass
/// must not erase. Caller holds <see cref="_gate"/>.
/// </summary>
/// <param name="outcome">The batch's accumulated outcomes.</param>
private void ApplyWritePassHealth ( WritePassOutcome outcome )
{
if ( ! outcome . HasObservations ) return ;
var worst = outcome . WorstFailure ;
if ( worst = = DriverState . Faulted )
_writeFault = ( DriverState . Faulted , outcome . WorstError ! ) ;
else if ( outcome . AnySuccess )
_writeFault = null ; // a write got through — the protection fault is gone
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.
if ( outcome . AnySuccess & & _writeFault is null & & Health . State = = DriverState . Faulted )
Health = new DriverHealth ( DriverState . Healthy , Health . LastSuccessfulRead , null ) ;
return ;
}
var state = worst ;
var error = outcome . WorstError ;
// A Degraded-only batch must not downgrade a still-sticky Faulted.
if ( _writeFault is { } wf & & ReadPassOutcome . Rank ( wf . State ) > ReadPassOutcome . Rank ( state ) )
{
state = wf . State ;
error = wf . Message ;
}
Health = new DriverHealth ( state , Health . LastSuccessfulRead , error ) ;
}
/// <summary>
/// The outcomes ONE <see cref="WriteAsync"/> batch observed — the write-side sibling of
/// <see cref="ReadPassOutcome"/> (same worst-state-wins ranking and severity-pinned message),
/// accumulated so the batch writes <see cref="Health"/> once instead of per item.
/// </summary>
private sealed class WritePassOutcome
{
private readonly ReadPassOutcome _inner = new ( ) ;
/// <summary>True once any item in the batch wrote successfully.</summary>
public bool AnySuccess = > _inner . AnySuccess ;
/// <summary>The worst failure recorded; Healthy means none.</summary>
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>Records one successful write.</summary>
public void RecordSuccess ( ) = > _inner . RecordSuccess ( ) ;
/// <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>
public void RecordFailure ( DriverState state , string message ) = > _inner . RecordFailure ( state , message ) ;
}
private async Task WriteOneAsync ( IS7Plc plc , S7TagDefinition tag , object? value , CancellationToken ct )
{
// Defence-in-depth guard: authored array tags are rejected at init
// (RejectUnsupportedTagConfigs guard-e), but a transient equipment-tag ref resolved by
// _resolver bypasses that path. Both should fail with NotSupportedException → BadNotSupported,
// not InvalidCastException → BadCommunicationError from BoxValueForWrite receiving an array.
if ( tag . ArrayCount is > = 1 )
throw new NotSupportedException (
$"S7 array writes are not yet supported (tag '{tag.Name}'). " +
"Use individual scalar tags or set Writable=false for read-only arrays." ) ;
// Parse the address the same way ReadOneAsync does: every tag pre-parses at init
// (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
// wide-type write can byte-address the block (the narrow path below addresses by the raw
@@ -1815,6 +1929,17 @@ public sealed class S7Driver
? parsed
: S7AddressParser . Parse ( tag . Address ) ;
// Array path (S7-016): ONE contiguous WriteBytesAsync of count × element-bytes — the inverse
// of ReadArrayAsync. Bool / wide-type arrays throw NotSupportedException (→ BadNotSupported);
// a wrong-shaped value throws S7WriteValueException (→ BadTypeMismatch, no health impact).
if ( tag . ArrayCount is > = 1 )
{
var arrayBytes = EncodeArrayBlock ( tag , addr , value ) ;
await plc . WriteBytesAsync ( ToS7NetArea ( addr . Area ) , addr . DbNumber , addr . ByteOffset , arrayBytes , ct )
. ConfigureAwait ( false ) ;
return ;
}
// Wide/structured scalar path: encode the value to a big-endian byte block and write it
// at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
// the narrow string-based write below stays unchanged for 1/2/4-byte types.
@@ -1832,6 +1957,90 @@ public sealed class S7Driver
await plc . WriteAsync ( tag . Address , boxed , ct ) . ConfigureAwait ( false ) ;
}
/// <summary>
/// Pure encode loop — the inverse of <see cref="DecodeArrayBlock"/>: turns the caller's array
/// into the contiguous big-endian block for a single <c>WriteBytesAsync</c>. No network I/O.
/// Accepts any non-string <see cref="System.Collections.IEnumerable"/> (OPC UA hands the server
/// a typed array, but a boxed <c>object[]</c> is equally fine); each element is coerced with
/// <c>Convert.To*</c> like the scalar path.
/// </summary>
/// <param name="tag">Tag definition carrying the element type and declared array length.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <param name="value">The array to encode; must hold exactly <c>ArrayCount</c> elements.</param>
/// <returns>The big-endian block, <c>count × element-bytes</c> long.</returns>
/// <exception cref="NotSupportedException">Bool or wide-type element arrays.</exception>
/// <exception cref="S7WriteValueException">The value is not an array, has the wrong length, or an
/// element does not convert to the element type.</exception>
internal static byte [ ] EncodeArrayBlock ( S7TagDefinition tag , S7ParsedAddress addr , object? value )
{
var count = tag . ArrayCount ! . Value ;
switch ( tag . DataType , addr . Size )
{
case ( S7DataType . Bool , _ ) :
throw new NotSupportedException (
$"S7 Bool array writes are not supported (tag '{tag.Name}'): a byte-granular write would clobber neighbouring bits." ) ;
case ( S7DataType . Int64 , _ ) :
case ( S7DataType . UInt64 , _ ) :
case ( S7DataType . Float64 , _ ) :
case ( S7DataType . String , _ ) :
case ( S7DataType . DateTime , _ ) :
throw new NotSupportedException ( $"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}')." ) ;
case ( S7DataType . Byte , S7Size . Byte ) :
case ( S7DataType . UInt16 , S7Size . Word ) :
case ( S7DataType . Int16 , S7Size . Word ) :
case ( S7DataType . UInt32 , S7Size . DWord ) :
case ( S7DataType . Int32 , S7Size . DWord ) :
case ( S7DataType . Float32 , S7Size . DWord ) :
break ;
default :
throw new System . IO . InvalidDataException (
$"S7 array Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}" ) ;
}
if ( value is string | | value is not System . Collections . IEnumerable items )
throw new S7WriteValueException ( $"S7 array tag '{tag.Name}' requires an array value." ) ;
var elements = new List < object? > ( count ) ;
foreach ( var e in items ) elements . Add ( e ) ;
if ( elements . Count ! = count )
throw new S7WriteValueException (
$"S7 array tag '{tag.Name}' has {count} elements but the write supplied {elements.Count}." ) ;
var elementBytes = ElementByteSize ( addr . Size ) ;
var block = new byte [ count * elementBytes ] ;
try
{
for ( var i = 0 ; i < count ; i + + )
{
var span = block . AsSpan ( i * elementBytes , elementBytes ) ;
var element = elements [ i ] ;
switch ( tag . DataType )
{
case S7DataType . Byte : span [ 0 ] = Convert . ToByte ( element ) ; break ;
case S7DataType . UInt16 : System . Buffers . Binary . BinaryPrimitives . WriteUInt16BigEndian ( span , Convert . ToUInt16 ( element ) ) ; break ;
case S7DataType . Int16 : System . Buffers . Binary . BinaryPrimitives . WriteInt16BigEndian ( span , Convert . ToInt16 ( element ) ) ; break ;
case S7DataType . UInt32 : System . Buffers . Binary . BinaryPrimitives . WriteUInt32BigEndian ( span , Convert . ToUInt32 ( element ) ) ; break ;
case S7DataType . Int32 : System . Buffers . Binary . BinaryPrimitives . WriteInt32BigEndian ( span , Convert . ToInt32 ( element ) ) ; break ;
case S7DataType . Float32 : System . Buffers . Binary . BinaryPrimitives . WriteSingleBigEndian ( span , Convert . ToSingle ( element ) ) ; break ;
}
}
}
catch ( Exception ex ) when ( ex is FormatException or InvalidCastException or OverflowException )
{
throw new S7WriteValueException (
$"S7 array tag '{tag.Name}': an element does not convert to {tag.DataType} ({ex.Message})." ) ;
}
return block ;
}
/// <summary>
/// A caller-supplied write value the driver cannot encode (wrong shape / length / element
/// type). A client error — maps to <c>BadTypeMismatch</c> and does not touch driver health.
/// </summary>
internal sealed class S7WriteValueException ( string message ) : Exception ( message ) ;
/// <summary>
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
@@ -1893,18 +2102,15 @@ public sealed class S7Driver
/// <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
/// enforces. The read / write / probe paths all take the gate before calling it.
/// <para>
/// While the PLC is down every data call and probe tick pays a full connect attempt
/// While the PLC is down every data call and probe tick would pay a full connect attempt
/// bounded only by <c>_options.Timeout</c> (STAB-8). This method is the single
/// choke-point all S7 reconnects flow through (already gate-serialized), so the
/// fleet-wide connect-attempt throttle plugs in here. R2-09 (sub-batch B3/B4) shipped the
/// shared <see cref="ConnectionBackoff"/> primitive
/// (<c>src/Core/…/Core.Abstractions/ConnectionBackoff.cs</c>) — the S7 wiring is a
/// per-driver <c>ConnectionBackoff</c> field consulted with <c>ShouldAttempt(now)</c> at
/// the top of the slow path (fail fast + degrade inside the window) plus
/// <c>RecordFailure</c>/<c>RecordSuccess</c> arou nd the connect, all under <c>_gate</c>
/// (which satisfies the primitive's caller-synchronized contract; TwinCAT/FOCAS/AbCip
/// already wire it this way). Deliberately NOT implemented driver-locally here — owned by
/// <c>archreview/plans/R2-01-s7-fault-hardening-plan.md</c> Finding 3.
/// connect-attempt throttle lives here (STAB-21): the shared <see cref="ConnectionBackoff"/>
/// primitive is consulted with <c>ShouldAttempt(now)</c> at the top of the slow path —
/// inside the window the call fails fast with no connect attempt, which every caller
/// already degrades on — with <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect.
/// A successful connect clears the window, so recovery is never delayed past the first
/// attempt that fi nds the PLC back; the 30 s cap bounds the worst case.
/// </para>
/// </remarks>
/// <param name="ct">Cancellation token for the (re)connect attempt.</param>
@@ -1922,6 +2128,12 @@ public sealed class S7Driver
}
_plcDead = false ;
// STAB-21: inside the backoff window fail fast WITHOUT a connect attempt. A plain
// InvalidOperationException (not an OCE) so the read/write/probe catch ladders degrade on it.
if ( ! _connectBackoff . ShouldAttempt ( BackoffClock ( ) ) )
throw new InvalidOperationException (
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt." ) ;
var plc = _plcFactory . Create (
_options . CpuType , _options . Host , _options . Port , _options . Rack , _options . Slot , _options . Timeout ) ;
try
@@ -1932,6 +2144,7 @@ public sealed class S7Driver
}
catch ( OperationCanceledException ) when ( ! ct . IsCancellationRequested )
{
_connectBackoff . RecordFailure ( BackoffClock ( ) ) ;
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
// Surface it as a connection FAILURE (TimeoutException) so callers route it through
// their degrade paths: an escaping OCE reads as teardown and permanently killed the
@@ -1941,12 +2154,16 @@ public sealed class S7Driver
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
$"{(int)_options.Timeout.TotalMilliseconds} ms." ) ;
}
catch
catch ( Exception ex )
{
// Caller cancellation is teardown, not a dead PLC — it must not open a window.
if ( ! ( ex is OperationCanceledException & & ct . IsCancellationRequested ) )
_connectBackoff . RecordFailure ( BackoffClock ( ) ) ;
try { plc . Dispose ( ) ; } catch { /* best-effort */ }
throw ;
}
_connectBackoff . RecordSuccess ( ) ;
Plc = plc ;
_logger . LogInformation ( "S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}" ,
_driverInstanceId , _options . Host , _options . CpuType ) ;
@@ -2114,8 +2331,8 @@ public sealed class S7Driver
private void HandlePollError ( Exception ex )
{
_logger . LogWarning ( ex , "S7 poll reader failed. Driver={DriverInstanceId}" , _driverInstanceId ) ;
if ( _health . State ! = DriverState . Faulted )
_health = new DriverHealth ( DriverState . Degraded , _health . LastSuccessfulRead , ex . Message ) ;
if ( Health . State ! = DriverState . Faulted )
Health = new DriverHealth ( DriverState . Degraded , Health . LastSuccessfulRead , ex . Message ) ;
}
// ---- IHostConnectivityProbe ----
@@ -2257,8 +2474,10 @@ public sealed class S7Driver
_initialized = false ;
_plcDead = false ;
_writeFault = null ;
_connectBackoff . RecordSuccess ( ) ;
try { Plc ? . Dispose ( ) ; } catch { /* best-effort — tearing down anyway */ }
Plc = null ;
_health = new DriverHealth ( DriverState . Unknown , _health . LastSuccessfulRead , null ) ;
Health = new DriverHealth ( DriverState . Unknown , Health . LastSuccessfulRead , null ) ;
}
}