fix(twincat): BOOL-within-word writes RMW the parent at its own width (H18)

The bit write read and wrote the parent word as a 32-bit UDInt with no parent
check: a bit beyond a 16-bit parent was written into neighbouring bytes, and a
signed parent with its sign bit set read back >= 2^31, so the checked
conversion turned any bit write into BadOutOfRange.

The write now goes through ITwinCATClient.WriteBitAsync: the ADS client
resolves the parent symbol, checks the bit against it (TwinCATBitAccess.
CheckParent, as the notification path already did), reads exactly
parent.ByteSize raw bytes, flips one bit (TwinCATBitAccess.ApplyBitWrite) and
writes the bytes back by index group/offset or variable handle. A bit beyond
the parent is BadOutOfRange, a non-integer parent BadTypeMismatch, a short
parent read BadCommunicationError (never written back), all without a health
write. The per-parent RMW lock stays in the driver.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:10:12 -04:00
parent 4083b4aada
commit 40c7493829
7 changed files with 227 additions and 44 deletions
@@ -482,6 +482,61 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <inheritdoc />
public async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken)
{
try
{
// Resolve the parent so the RMW happens at its OWN width (the notification path's model):
// the old 32-bit UDInt round-trip wrote a 16-bit parent's neighbours and overflowed on a
// signed parent with its sign bit set.
var (parent, _, symbol, status) = await DescribeSymbolAsync(parentPath, cancellationToken)
.ConfigureAwait(false);
if (status != TwinCATStatusMapper.Good) return status;
if (TwinCATBitAccess.CheckParent(bit, parent) is not null)
return TwinCATBitAccess.ApplyBitWrite(parent, ReadOnlySpan<byte>.Empty, bit, value, out _);
var (bytes, readStatus) = await ReadRawAsync(parentPath, symbol!, parent.ByteSize, cancellationToken)
.ConfigureAwait(false);
if (readStatus != TwinCATStatusMapper.Good) return readStatus;
var modify = TwinCATBitAccess.ApplyBitWrite(parent, bytes, bit, value, out var updated);
if (modify != TwinCATStatusMapper.Good) return modify;
return await WriteRawAsync(parentPath, symbol!, updated, cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <summary>
/// Writes raw bytes to a resolved symbol — the mirror of <see cref="ReadRawAsync"/>: by its index
/// group/offset, or through a fresh variable handle for a handle-addressed symbol (0xF005).
/// </summary>
private async Task<uint> WriteRawAsync(
string symbolPath, IAdsSymbol symbol, ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.WriteAsync(symbol.IndexGroup, symbol.IndexOffset, data, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)direct.ErrorCode);
}
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (handle.ErrorCode != AdsErrorCode.NoError) return MapAndSignal((uint)handle.ErrorCode);
try
{
var write = await _client.WriteAsync(handle.Handle, data, cancellationToken).ConfigureAwait(false);
return write.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)write.ErrorCode);
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
/// <inheritdoc />
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
@@ -119,6 +119,22 @@ public interface ITwinCATClient : IDisposable
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag
/// (<c>MAIN.Flags.3</c>; ADS has no symbol for <c>WORD.N</c>). A read-modify-write of the parent's raw
/// bytes at the parent's OWN resolved width (<see cref="TwinCATBitAccess.ApplyBitWrite"/>), so a 16-bit
/// parent is never written as 32 bits and a signed parent's sign bit is just a bit. The caller
/// serialises concurrent writers to the same parent; the PLC program writing the word between the
/// read and the write is inherent to RMW.
/// </summary>
/// <param name="parentPath">The parent word's ADS symbol path (the bit path without its <c>.N</c>).</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
/// <param name="value">The bit value to write.</param>
/// <param name="cancellationToken">Cancellation token for the read and the write.</param>
/// <returns>The mapped OPC UA status: Good; the parent read's status; <c>BadOutOfRange</c> for a bit
/// beyond the parent; <c>BadTypeMismatch</c> for a parent that is not an integer word.</returns>
Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
}
/// <summary>
/// The modify step of a bit write's read-modify-write: checks the bit against the parent
/// (<see cref="CheckParent"/>) and returns the parent's bytes — exactly <c>parent.ByteSize</c> of them —
/// with one bit set or cleared. Works on raw bytes, so the parent's width and signedness play no part.
/// </summary>
/// <param name="parent">The parent symbol's resolved shape.</param>
/// <param name="current">The parent's current bytes as read from the PLC.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="updated">The bytes to write back when the result is Good; otherwise empty.</param>
/// <returns>Good; <c>BadOutOfRange</c> for a bit beyond the parent; <c>BadTypeMismatch</c> for a parent
/// that is not an integer word; <c>BadCommunicationError</c> for a read shorter than the parent
/// (writing it back would zero the missing bytes).</returns>
public static uint ApplyBitWrite(TwinCATPlcSymbolShape parent, ReadOnlySpan<byte> current, int bit, bool value,
out byte[] updated)
{
updated = [];
if (CheckParent(bit, parent) is not null)
return parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat
? TwinCATStatusMapper.BadTypeMismatch
: TwinCATStatusMapper.BadOutOfRange;
if (current.Length < parent.ByteSize) return TwinCATStatusMapper.BadCommunicationError;
var bytes = current[..parent.ByteSize].ToArray();
var mask = (byte)(1 << (bit % 8));
bytes[bit / 8] = value ? (byte)(bytes[bit / 8] | mask) : (byte)(bytes[bit / 8] & ~mask);
updated = bytes;
return TwinCATStatusMapper.Good;
}
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
/// <param name="parent">The parent word's raw bytes.</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
@@ -511,38 +511,21 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
// BOOL-within-word write — read-modify-write of the parent word. Mirrors the bit-read
// (AdsTwinCATClient.ReadValueAsync) which reads the parent as uint: read the parent as
// UDInt (-> uint), flip the bit, write it back, all under a per-parent lock.
// BOOL-within-word write — a read-modify-write of the parent word at the parent's OWN
// width and on raw bytes (the client resolves the parent and checks the bit against it, the
// notification path's model), under a per-parent lock so concurrent bit writers to the same
// word don't lose each other's updates.
if (def.DataType == TwinCATDataType.Bool && parsed?.BitIndex is int bit)
{
var parentPath = (parsed with { BitIndex = null }).ToAdsSymbolName();
var bitValue = Convert.ToBoolean(w.Value);
var gate = _bitRmwLocks.GetOrAdd(
$"{def.DeviceHostAddress}|{parentPath}", static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var (parentValue, readStatus) = await client.ReadValueAsync(
parentPath, TwinCATDataType.UDInt, null, null, cancellationToken).ConfigureAwait(false);
if (readStatus != TwinCATStatusMapper.Good)
{
results[i] = new WriteResult(readStatus);
}
else if (parentValue is null)
{
// Good status but no value — treating null as 0 would write a zeroed
// word and clear every bit set on the device. Surface a Bad status and
// write nothing rather than corrupt the parent word.
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
}
else
{
var word = Convert.ToUInt32(parentValue);
var updated = Convert.ToBoolean(w.Value) ? word | (1u << bit) : word & ~(1u << bit);
var writeStatus = await client.WriteValueAsync(
parentPath, TwinCATDataType.UDInt, null, updated, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(writeStatus);
}
results[i] = new WriteResult(await client.WriteBitAsync(
parentPath, bit, bitValue, cancellationToken).ConfigureAwait(false));
}
finally
{