diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md
index df6d60d7..511d4510 100644
--- a/docs/drivers/S7-Test-Fixture.md
+++ b/docs/drivers/S7-Test-Fixture.md
@@ -20,6 +20,8 @@ python-snap7 `Server` via `Docker/docker-compose.yml --profile s7_1500`
on `10.100.0.35:1102` (the shared Docker host; override via `S7_SIM_ENDPOINT`;
pinned `python:3.12-slim-bookworm` base +
`python-snap7>=2.0`). Docker is the only supported launch path.
+Tests that WRITE to the endpoint (currently `S7_1500ArrayWriteTests`) additionally require
+`S7_SIM_ALLOW_WRITES=1`, because `S7_SIM_ENDPOINT` can be pointed at a real CPU; they skip without it.
`Snap7ServerFixture` probes the port at collection init + skips with a
clear message when unreachable (matches the pymodbus pattern).
`server.py` (baked into the image under `Docker/`) reads a JSON profile
diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md
index af025019..71ca8772 100644
--- a/docs/drivers/S7.md
+++ b/docs/drivers/S7.md
@@ -252,7 +252,9 @@ is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the i
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
-touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`.
+touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
+rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
+**not atomic** — a mid-write failure can leave the PLC array partly updated.
Note the *server* still presents every array node read-only to OPC UA clients ([Uns.md §Array
tags](../Uns.md#array-tags-1-d), #610 item 1) — the driver path is reachable today through the Client
CLI / driver-level callers and is proven by the unit suite plus the snap7 fixture test
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index 70615c41..bc461bdf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -812,6 +812,9 @@ public sealed class GalaxyDriver
return ReadViaSubscribeOnceAsync(dialledRefs, cancellationToken);
}
+ /// Upper bound on one subscribe-once read; internal so tests can shrink it.
+ internal TimeSpan ReadViaSubscribeDeadline { get; set; } = TimeSpan.FromSeconds(30);
+
///
/// Production read path. MxAccess has no one-shot Read RPC — every value comes
/// through the event stream. We synthesise a Read by:
@@ -826,9 +829,6 @@ public sealed class GalaxyDriver
/// caller's cancellation token fires, return a Bad-status snapshot in input order
/// so the caller still sees one snapshot per requested reference.
///
- /// Upper bound on one subscribe-once read; internal so tests can shrink it.
- internal TimeSpan ReadViaSubscribeDeadline { get; set; } = TimeSpan.FromSeconds(30);
-
private async Task> ReadViaSubscribeOnceAsync(
IReadOnlyList fullReferences, CancellationToken callerToken)
{
@@ -868,11 +868,36 @@ public sealed class GalaxyDriver
var bufferedIntervalMs = _options.MxAccess.PublishingIntervalMs;
IReadOnlyList results;
+ // The RPC gets only the CALLER's token: the internal deadline bounds our wait, not the RPC, so a slow
+ // gateway can never surface an OperationCanceledException to a caller that did not cancel, and the
+ // handles it eventually creates are known (and unsubscribed below) rather than lost to a cancelled call.
+ var subscribeTask = _subscriber!.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, callerToken);
try
{
- results = await _subscriber!
- .SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken)
- .ConfigureAwait(false);
+ results = await subscribeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
+ {
+ // Internal deadline fired while the subscribe RPC was still in flight.
+ pump.OnDataChange -= handler;
+ var subscriber = _subscriber!;
+ var logger = _logger;
+ _ = subscribeTask.ContinueWith(t =>
+ {
+ if (!t.IsCompletedSuccessfully) return Task.CompletedTask;
+ var late = t.Result.Where(r => r.WasSuccessful && r.ItemHandle > 0).Select(r => r.ItemHandle).ToArray();
+ return late.Length == 0
+ ? Task.CompletedTask
+ : subscriber.UnsubscribeBulkAsync(late, CancellationToken.None).ContinueWith(
+ u => logger.LogWarning(u.Exception,
+ "GalaxyDriver.ReadViaSubscribeOnceAsync late UnsubscribeBulk failed for {Count} handle(s).", late.Length),
+ TaskContinuationOptions.OnlyOnFaulted);
+ }, TaskScheduler.Default).Unwrap();
+ var now = DateTime.UtcNow;
+ var timedOut = new DataValueSnapshot[fullReferences.Count];
+ for (var i = 0; i < timedOut.Length; i++)
+ timedOut[i] = new DataValueSnapshot(null, StatusCodeMap.BadTimeout, null, now);
+ return timedOut;
}
catch
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 8aa78144..e0110870 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -1869,6 +1869,7 @@ public sealed class S7Driver
if (!outcome.HasObservations) return;
var worst = outcome.WorstFailure;
+ var priorWriteFault = _writeFault;
if (worst == DriverState.Faulted)
_writeFault = (DriverState.Faulted, outcome.WorstError!);
else if (outcome.AnySuccess)
@@ -1878,7 +1879,10 @@ public sealed class S7Driver
{
// 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)
+ // 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
+ && Health.State == DriverState.Faulted && Health.LastError == owned.Message)
Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null);
return;
}
@@ -2004,6 +2008,9 @@ public sealed class S7Driver
var elements = new List