Merge master 4fb04a9f (lane D-a) into remed/lane-D-b

ScriptEdit.razor conflict resolved keeping both D-a's on-demand Monaco sync +
Problems list and D-b's delete confirm.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 18:14:53 -04:00
85 changed files with 4618 additions and 812 deletions
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
/// <summary>
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
/// </summary>
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")]
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")]
public string? Aggregate { get; init; }
/// <summary>
@@ -199,24 +199,33 @@ public class HistoryReadCommand : CommandBase
}
}
private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames<AggregateType>());
private static AggregateType ParseAggregateType(string name)
{
return name.Trim().ToLowerInvariant() switch
var trimmed = name.Trim();
var alias = trimmed.ToLowerInvariant() switch
{
"average" or "avg" => AggregateType.Average,
"minimum" or "min" => AggregateType.Minimum,
"maximum" or "max" => AggregateType.Maximum,
"count" => AggregateType.Count,
"start" or "first" => AggregateType.Start,
"end" or "last" => AggregateType.End,
"standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
"timeaverage" or "timeavg" => AggregateType.TimeAverage,
"timeaverage2" or "timeavg2" => AggregateType.TimeAverage2,
"total" => AggregateType.Total,
"total2" => AggregateType.Total2,
"interpolative" or "interp" => AggregateType.Interpolative,
_ => throw new ArgumentException(
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")
"avg" => AggregateType.Average,
"min" => AggregateType.Minimum,
"max" => AggregateType.Maximum,
"first" => AggregateType.Start,
"last" => AggregateType.End,
"timeavg" => AggregateType.TimeAverage,
"timeavg2" => AggregateType.TimeAverage2,
"interp" => AggregateType.Interpolative,
"mintime" => AggregateType.MinimumActualTime,
"maxtime" => AggregateType.MaximumActualTime,
_ => (AggregateType?)null
};
if (alias is { } a) return a;
// Every enum name (the served set; a parity test pins it), case-insensitive. Numeric strings are refused:
// Enum.TryParse would accept "6" or "99" as an undefined value.
if (!int.TryParse(trimmed, out _) &&
Enum.TryParse<AggregateType>(trimmed, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed))
return parsed;
throw new ArgumentException($"Unknown aggregate: '{name}'. Supported: {SupportedAggregates}");
}
}
@@ -115,22 +115,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return (nextCp, nextRefs ?? []);
}
/// <inheritdoc />
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
{
var (_, _, references) = await _session.BrowseAsync(
null,
null,
nodeId,
1u,
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
0u);
return references != null && references.Count > 0;
}
/// <inheritdoc />
public async Task<Models.HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
{
@@ -164,46 +148,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
ReturnBounds = false
};
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId }
};
var allValues = new List<DataValue>();
byte[]? continuationPoint = null;
do
{
if (continuationPoint != null)
nodesToRead[0].ContinuationPoint = continuationPoint;
// Use the async overload so this method is genuinely asynchronous,
// honors the cancellation token, and does not block the caller's thread
// (which would block the UI dispatcher for client.ui consumers).
var response = await _session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Source,
continuationPoint != null,
nodesToRead,
ct).ConfigureAwait(false);
var results = response.Results;
if (results == null || results.Count == 0)
break;
var result = results[0];
// A refused read (BadHistoryOperationUnsupported, BadUserAccessDenied, ...) must surface; only
// BadNoData is an empty result. Swallowing it printed "0 values returned" for a refusal.
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
continuationPoint = result.ContinuationPoint;
} while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
return allValues;
// Continuation pages are READ, never released, until the server finishes or the cap is reached; a read stopped
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
// silently truncating every raw read to its first server page.
return await HistoryReadPaging.ReadAllAsync(
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
? historyData.DataValues
: [],
maxValues, nodeId, ct).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -219,59 +173,41 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
ReturnBounds = false
};
return await HistoryReadPaging.ReadAllAsync(
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
ToModifiedEntries, maxValues, nodeId, ct).ConfigureAwait(false);
}
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
{
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
for (var i = 0; i < modified.DataValues.Count; i++)
{
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
yield return new Models.HistoryModifiedEntry(
modified.DataValues[i],
info?.ModificationTime ?? DateTime.MinValue,
info?.UpdateType ?? default,
info?.UserName);
}
}
/// <summary>Issues one single-node HistoryRead (or a release of its continuation point).</summary>
private async Task<HistoryReadResult?> ReadHistoryPageAsync(
ReadRawModifiedDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
TimestampsToReturn timestamps, CancellationToken ct)
{
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId }
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
};
var all = new List<Models.HistoryModifiedEntry>();
byte[]? continuationPoint = null;
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
// client.ui consumers).
var response = await _session.HistoryReadAsync(
null, new ExtensionObject(details), timestamps, release, nodesToRead, ct).ConfigureAwait(false);
do
{
if (continuationPoint != null)
nodesToRead[0].ContinuationPoint = continuationPoint;
var response = await _session.HistoryReadAsync(
null,
new ExtensionObject(details),
TimestampsToReturn.Both,
false,
nodesToRead,
ct).ConfigureAwait(false);
var results = response.Results;
if (results == null || results.Count == 0)
break;
var result = results[0];
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryModifiedData modified)
{
for (var i = 0; i < modified.DataValues.Count; i++)
{
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
all.Add(new Models.HistoryModifiedEntry(
modified.DataValues[i],
info?.ModificationTime ?? DateTime.MinValue,
info?.UpdateType ?? default,
info?.UserName));
}
}
continuationPoint = result.ContinuationPoint;
} while (continuationPoint != null && continuationPoint.Length > 0 && all.Count < maxValues);
// A read stopped by the client's own cap leaves a server cursor behind; release it.
if (continuationPoint is { Length: > 0 })
{
nodesToRead[0].ContinuationPoint = continuationPoint;
await _session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
nodesToRead, ct).ConfigureAwait(false);
}
return all;
return response.Results is { Count: > 0 } results ? results[0] : null;
}
/// <inheritdoc />
@@ -327,9 +263,22 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
};
_session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
try
{
await subscription.CreateAsync(ct);
}
catch
{
// A subscription the server never created must not linger in Session.Subscriptions either.
try { await _session.RemoveSubscriptionAsync(subscription, CancellationToken.None); }
catch (Exception ex) { Logger.Warning(ex, "Error removing a subscription that failed to create"); }
throw;
}
return new DefaultSubscriptionAdapter(subscription);
// Deleting the adapter also removes the subscription from the SDK session, so a re-targeted alarm/data
// subscription does not accumulate dead Subscription objects in Session.Subscriptions.
return new DefaultSubscriptionAdapter(subscription,
async (s, token) => await _session.RemoveSubscriptionAsync(s, token).ConfigureAwait(false));
}
/// <inheritdoc />
@@ -12,14 +12,23 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
private static readonly ILogger Logger = Log.ForContext<DefaultSubscriptionAdapter>();
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
private readonly Subscription _subscription;
private readonly Func<Subscription, CancellationToken, Task>? _detach;
private int _detached;
/// <summary>
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
/// </summary>
/// <param name="subscription">The underlying OPC UA subscription that owns monitored items for this client workflow.</param>
public DefaultSubscriptionAdapter(Subscription subscription)
/// <param name="detach">
/// Removes the subscription from its owning SDK <c>Session</c> once it is deleted. Without it a deleted
/// subscription stays in <c>Session.Subscriptions</c> until the session is disposed, so a UI that re-targets
/// alarms repeatedly accumulates dead subscriptions the SDK still iterates (transfer / recreate on reconnect).
/// </param>
public DefaultSubscriptionAdapter(Subscription subscription,
Func<Subscription, CancellationToken, Task>? detach = null)
{
_subscription = subscription;
_detach = detach;
}
/// <inheritdoc />
@@ -113,17 +122,39 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
Logger.Warning(ex, "Error deleting subscription");
}
// Detach whether or not the server-side delete succeeded: the adapter is finished with it either way.
await DetachAsync(ct);
_monitoredItems.Clear();
}
private async Task DetachAsync(CancellationToken ct)
{
if (_detach == null || Interlocked.Exchange(ref _detached, 1) != 0) return;
try
{
await _detach(_subscription, ct);
}
catch (Exception ex)
{
Logger.Warning(ex, "Error removing subscription from its session");
}
}
/// <summary>
/// Releases the wrapped OPC UA subscription and clears tracked monitored items held by the adapter.
/// </summary>
public void Dispose()
{
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
try
{
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
DisposeBridge.Run(async ct =>
{
try { await _subscription.DeleteAsync(true, ct); }
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
await DetachAsync(ct);
});
}
catch
{
@@ -90,14 +90,6 @@ internal interface ISessionAdapter : IDisposable
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
/// <summary>
/// Checks whether a node has any forward hierarchical child references.
/// </summary>
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
/// <returns><c>true</c> when the node has at least one forward hierarchical child reference; otherwise <c>false</c>.</returns>
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Reads raw historical data.
/// </summary>
@@ -23,12 +23,17 @@ public static class AggregateTypeMapper
AggregateType.Count => ObjectIds.AggregateFunction_Count,
AggregateType.Start => ObjectIds.AggregateFunction_Start,
AggregateType.End => ObjectIds.AggregateFunction_End,
AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
AggregateType.Total => ObjectIds.AggregateFunction_Total,
AggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
AggregateType.Range => ObjectIds.AggregateFunction_Range,
AggregateType.Delta => ObjectIds.AggregateFunction_Delta,
AggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
};
}
@@ -0,0 +1,96 @@
using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
/// <summary>
/// The client-side HistoryRead paging loop shared by the raw and modified reads. Continuation pages are
/// READ (<c>releaseContinuationPoints = false</c>) while the caller still wants values; a read stopped by the
/// caller's own cap while the server still holds a cursor releases that cursor so it does not occupy one of the
/// session's continuation-point slots until eviction.
/// </summary>
internal static class HistoryReadPaging
{
private static readonly ILogger Logger = Log.ForContext(typeof(HistoryReadPaging));
/// <summary>Reads (or, with <paramref name="releaseContinuationPoints"/>, releases) one page.</summary>
/// <param name="continuationPoint">The cursor to resume, or <see langword="null"/> for the first page.</param>
/// <param name="releaseContinuationPoints"><see langword="true"/> to release the cursor instead of reading.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>The per-node result, or <see langword="null"/> when the response carried none.</returns>
public delegate Task<HistoryReadResult?> PageReader(
byte[]? continuationPoint, bool releaseContinuationPoints, CancellationToken ct);
/// <summary>The bound on a best-effort release that does not ride the caller's (possibly cancelled) token.</summary>
internal static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(5);
/// <summary>Reads pages until the server finishes or the caller's cap is reached.</summary>
/// <typeparam name="T">The element type extracted from each page.</typeparam>
/// <param name="readPage">Issues one HistoryRead for the single node.</param>
/// <param name="extract">Extracts the page's elements from a result.</param>
/// <param name="maxValues">The caller's cap; must be positive. The result is trimmed to it.</param>
/// <param name="nodeId">The node being read, for refusal messages.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>At most <paramref name="maxValues"/> elements.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxValues"/> is not positive: an uncapped client
/// read would materialise a tag's whole window (the client side of review 08 1.1).</exception>
/// <exception cref="ServiceResultException">A page was refused (see <see cref="HistoryReadStatus"/>).</exception>
/// <exception cref="OperationCanceledException"><paramref name="ct"/> was cancelled mid-read; the outstanding
/// cursor is released (best effort) first.</exception>
public static async Task<List<T>> ReadAllAsync<T>(
PageReader readPage, Func<HistoryReadResult, IEnumerable<T>> extract, int maxValues, NodeId nodeId,
CancellationToken ct)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
var all = new List<T>();
byte[]? continuationPoint = null;
try
{
while (true)
{
var result = await readPage(continuationPoint, false, ct).ConfigureAwait(false);
if (result == null) return Trim(all, maxValues);
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
all.AddRange(extract(result));
continuationPoint = result.ContinuationPoint;
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
if (all.Count >= maxValues) break;
}
}
catch (OperationCanceledException)
{
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
// occupying one of the session's continuation-point slots, then surface the cancellation.
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
throw;
}
// Stopped by the client's own cap: the server still holds a cursor for this session. Release it — on its own
// bounded token, so a cancellation racing the release cannot lose the values already in hand.
await ReleaseAsync(readPage, continuationPoint, nodeId);
return Trim(all, maxValues);
}
private static List<T> Trim<T>(List<T> all, int maxValues)
{
if (all.Count > maxValues) all.RemoveRange(maxValues, all.Count - maxValues);
return all;
}
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
{
try
{
using var cts = new CancellationTokenSource(ReleaseTimeout);
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warning(ex, "Releasing the HistoryRead continuation point for {NodeId} failed", nodeId);
}
}
}
@@ -1,43 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// Aggregate functions for processed history reads.
/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
/// serve (<c>AggregateFamilies.Served</c>; a parity test pins it). The Gateway backend serves a subset and refuses
/// the rest with <c>BadAggregateNotSupported</c>.
/// </summary>
/// <remarks>
/// Values are explicit so the pre-existing members keep their numbers. 6 (the former <c>StandardDeviation</c>,
/// which no backend serves) is retired; do not reuse it.
/// </remarks>
public enum AggregateType
{
/// <summary>Average of values in the interval.</summary>
Average,
Average = 0,
/// <summary>Minimum value in the interval.</summary>
Minimum,
Minimum = 1,
/// <summary>Maximum value in the interval.</summary>
Maximum,
Maximum = 2,
/// <summary>Count of values in the interval.</summary>
Count,
Count = 3,
/// <summary>First value in the interval.</summary>
Start,
Start = 4,
/// <summary>Last value in the interval.</summary>
End,
/// <summary>Population standard deviation of values in the interval.</summary>
StandardDeviation,
End = 5,
/// <summary>Time-weighted average, interpolated bounds (Part 13 TimeAverage).</summary>
TimeAverage,
TimeAverage = 7,
/// <summary>Time-weighted average, stepped bounds (Part 13 TimeAverage2).</summary>
TimeAverage2,
TimeAverage2 = 8,
/// <summary>Time-weighted integral over the interval (Part 13 Total).</summary>
Total,
Total = 9,
/// <summary>Time-weighted integral, stepped bounds (Part 13 Total2).</summary>
Total2,
Total2 = 10,
/// <summary>Interpolated value at the interval start (Part 13 Interpolative).</summary>
Interpolative
}
Interpolative = 11,
/// <summary>Maximum minus minimum in the interval (Part 13 Range).</summary>
Range = 12,
/// <summary>Last minus first value in the interval (Part 13 Delta).</summary>
Delta = 13,
/// <summary>Minimum value with its own timestamp (Part 13 MinimumActualTime).</summary>
MinimumActualTime = 14,
/// <summary>Maximum value with its own timestamp (Part 13 MaximumActualTime).</summary>
MaximumActualTime = 15,
/// <summary>Percentage of the interval with Good data (Part 13 PercentGood).</summary>
PercentGood = 16,
/// <summary>Percentage of the interval with Bad data (Part 13 PercentBad).</summary>
PercentBad = 17
}
@@ -34,7 +34,9 @@ public sealed class BrowseResult
public string NodeClass { get; }
/// <summary>
/// Whether the node has child references.
/// Whether the node MAY have child references: <see langword="true"/> for every Object, confirmed lazily when the
/// caller browses it (an expanded empty folder simply yields no children). Variables and Methods report
/// <see langword="false"/>.
/// </summary>
public bool HasChildren { get; }
}
@@ -15,16 +15,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
// Guards all access to the subscription-bookkeeping state below
// (_activeDataSubscriptions and _activeAlarmSubscription). The dictionary
// and tuple are mutated from the caller thread, the keep-alive failover
// path, and DisconnectAsync, so every read/write must be inside this lock.
// Guards all access to the subscription state below (_activeDataSubscriptions, _activeAlarmSubscription,
// _dataSubscription, _alarmSubscription). They are touched from the caller thread, the keep-alive failover path
// and DisconnectAsync, so every read/write is inside this lock — including failover's null-outs.
private readonly object _subscriptionLock = new();
// Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers
// cannot both pass the _alarmSubscription == null check and create duplicate event
// subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block).
private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1);
// Serialises every subscription operation that awaits I/O (Subscribe/Unsubscribe, SubscribeAlarms/
// UnsubscribeAlarms) AND the failover's teardown + reconnect + replay. A subscribe is therefore either
// wholly before a failover (recorded, then replayed onto the new session) or wholly after it (on the new
// session) — never half on the dying session. Async-friendly (no thread block). Deliberately never disposed:
// async-only waits hold no kernel handle, and a failover still running at Dispose releases it afterwards.
private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
// Track active data subscriptions for replay after failover
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
@@ -34,6 +35,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
// run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
private int _failoverInProgress;
// The connection epoch (review I1): ConnectAsync and DisconnectAsync each start a new one (under
// _subscriptionLock). A failover runs under the epoch it started in and abandons itself — disposing any session it
// opened, making no state transition — the moment the epoch moves, so a user Disconnect + Connect during a
// failover is never undone by it. A keep-alive from a previous connection's session cannot start one either.
private long _connectionEpoch;
/// <summary>The most recent keep-alive-triggered failover task (test seam: lets a test await it).</summary>
internal Task? LastFailover { get; private set; }
private readonly IApplicationConfigurationFactory _configFactory;
private readonly IEndpointDiscovery _endpointDiscovery;
@@ -99,6 +109,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
settings.Validate();
// Overwriting a live (or reconnecting) session would leak it and orphan its subscriptions and keep-alive
// handler. The caller must disconnect first.
if (_state != ConnectionState.Disconnected || _session != null)
throw new InvalidOperationException(
$"Already {_state.ToString().ToLowerInvariant()}; call DisconnectAsync before connecting again.");
long epoch;
lock (_subscriptionLock) epoch = ++_connectionEpoch;
_settings = settings;
_allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
_currentEndpointIndex = 0;
@@ -108,11 +127,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
try
{
var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
_session = session;
lock (_subscriptionLock) _session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) _ = HandleKeepAliveFailureAsync();
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
@@ -133,6 +152,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (_state == ConnectionState.Disconnected)
return;
// A new epoch first: a failover still connecting abandons itself instead of resurrecting the connection.
lock (_subscriptionLock) _connectionEpoch++;
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
// Snapshot the subscription adapter references under the lock so a concurrent
@@ -224,18 +246,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
while (references.Count > 0)
{
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
foreach (var reference in references)
{
var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
var hasChildren = reference.NodeClass == NodeClass.Object &&
await _session.HasChildrenAsync(childNodeId, ct);
results.Add(new BrowseResult(
reference.NodeId.ToString(),
reference.DisplayName?.Text ?? string.Empty,
reference.NodeClass.ToString(),
hasChildren));
}
reference.NodeClass == NodeClass.Object));
if (continuationPoint is not { Length: > 0 } pending) break;
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
@@ -251,20 +270,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfNotConnected();
var nodeIdStr = nodeId.ToString();
lock (_subscriptionLock)
await _subscriptionGate.WaitAsync(ct);
try
{
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
return; // Already subscribed
// A failover may have run (and failed) while this call waited for the gate.
ThrowIfNotConnected();
ISubscriptionAdapter? subscription;
ISessionAdapter session;
lock (_subscriptionLock)
{
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
return; // Already subscribed
subscription = _dataSubscription;
session = _session!;
}
if (subscription == null)
{
subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
lock (_subscriptionLock) _dataSubscription = subscription;
}
var handle = await subscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, ct);
lock (_subscriptionLock)
{
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
}
}
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, ct);
lock (_subscriptionLock)
finally
{
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
_subscriptionGate.Release();
}
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
@@ -276,18 +314,35 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
var nodeIdStr = nodeId.ToString();
(NodeId NodeId, int IntervalMs, uint Handle) sub;
lock (_subscriptionLock)
await _subscriptionGate.WaitAsync(ct);
try
{
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
return; // Not subscribed, safe to ignore
(NodeId NodeId, int IntervalMs, uint Handle) sub;
ISubscriptionAdapter? subscription;
lock (_subscriptionLock)
{
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
return; // Not subscribed, safe to ignore
subscription = _dataSubscription;
}
try
{
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
}
finally
{
// The caller asked to stop: drop the bookkeeping even when the server-side remove failed (a session
// that died under us), or a later failover would replay the item onto the new session (review m5).
lock (_subscriptionLock)
{
_activeDataSubscriptions.Remove(nodeIdStr);
}
}
}
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
lock (_subscriptionLock)
finally
{
_activeDataSubscriptions.Remove(nodeIdStr);
_subscriptionGate.Release();
}
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
@@ -303,17 +358,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
// Serialise check-and-create under the semaphore: concurrent
// callers cannot both pass the null-check and create duplicate alarm subscriptions.
// The semaphore is async-friendly; the subscription I/O runs inside the critical section.
await _alarmSubscribeSemaphore.WaitAsync(ct);
await _subscriptionGate.WaitAsync(ct);
try
{
if (_alarmSubscription != null)
return; // Already subscribed to alarms
ThrowIfNotConnected();
ISessionAdapter session;
lock (_subscriptionLock)
{
if (_alarmSubscription != null)
return; // Already subscribed to alarms
session = _session!;
}
var monitorNode = sourceNodeId ?? ObjectIds.Server;
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
var subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
lock (_subscriptionLock) _alarmSubscription = subscription;
var filter = CreateAlarmEventFilter();
await _alarmSubscription.AddEventMonitoredItemAsync(
await subscription.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
lock (_subscriptionLock)
@@ -325,7 +388,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
finally
{
_alarmSubscribeSemaphore.Release();
_subscriptionGate.Release();
}
}
@@ -334,15 +397,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
ThrowIfDisposed();
if (_alarmSubscription == null)
return;
await _alarmSubscription.DeleteAsync(ct);
_alarmSubscription = null;
lock (_subscriptionLock)
await _subscriptionGate.WaitAsync(ct);
try
{
_activeAlarmSubscription = null;
ISubscriptionAdapter? subscription;
lock (_subscriptionLock)
{
subscription = _alarmSubscription;
if (subscription == null) return;
}
await subscription.DeleteAsync(ct);
lock (_subscriptionLock)
{
_alarmSubscription = null;
_activeAlarmSubscription = null;
}
}
finally
{
_subscriptionGate.Release();
}
Logger.Debug("Unsubscribed from alarm events");
@@ -354,10 +429,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
if (_alarmSubscription == null)
ISubscriptionAdapter? subscription;
lock (_subscriptionLock) subscription = _alarmSubscription;
if (subscription == null)
throw new InvalidOperationException("No alarm subscription is active.");
await _alarmSubscription.ConditionRefreshAsync(ct);
await subscription.ConditionRefreshAsync(ct);
Logger.Debug("Condition refresh requested");
}
@@ -640,13 +717,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_subscriptionLock)
{
if (_disposed) return;
_disposed = true;
_connectionEpoch++; // a failover still running abandons itself
}
_dataSubscription?.Dispose();
_alarmSubscription?.Dispose();
_session?.Dispose();
_alarmSubscribeSemaphore.Dispose();
lock (_subscriptionLock)
{
@@ -688,19 +768,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
ct);
}
private async Task HandleKeepAliveFailureAsync()
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
private void OnKeepAliveFailed(long epoch)
{
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly
// while a session is down, so multiple bad keep-alives can fire before
// the first failover loop finishes. CompareExchange atomically claims
// the failover slot; a re-entrant call sees 1 and returns immediately,
// guaranteeing exactly one failover loop runs at a time.
// A late keep-alive from a previous connection's session is not about the current one.
if (!IsCurrentEpoch(epoch)) return;
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly while a session is down, so several bad
// keep-alives can fire before the first failover loop finishes. CompareExchange claims the failover slot; a
// re-entrant call sees 1 and returns, so exactly one loop runs — and only the winner sets the test seam
// (review m8: a re-entrant call used to overwrite it with an already-completed no-op).
if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0)
return;
LastFailover = HandleKeepAliveFailureAsync(epoch);
}
private async Task HandleKeepAliveFailureAsync(long epoch)
{
try
{
await RunFailoverAsync();
await RunFailoverAsync(epoch);
}
finally
{
@@ -708,74 +796,136 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
}
private async Task RunFailoverAsync()
private bool IsCurrentEpoch(long epoch)
{
lock (_subscriptionLock) return _connectionEpoch == epoch && !_disposed;
}
/// <summary>
/// A state transition made only while <paramref name="epoch"/> is still current (checked and applied under
/// <see cref="_subscriptionLock"/>, the event raised outside it).
/// </summary>
/// <returns><see langword="false"/> when the epoch has moved on (the caller must abandon).</returns>
private bool TransitionStateIfCurrent(long epoch, ConnectionState newState, string endpointUrl)
{
ConnectionState oldState;
lock (_subscriptionLock)
{
if (_connectionEpoch != epoch || _disposed) return false;
oldState = _state;
if (oldState == newState) return true;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
return true;
}
private async Task RunFailoverAsync(long epoch)
{
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
return;
var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
TransitionState(ConnectionState.Reconnecting, oldEndpoint);
var oldSession = _session;
var oldEndpoint = oldSession?.EndpointUrl ?? string.Empty;
if (!TransitionStateIfCurrent(epoch, ConnectionState.Reconnecting, oldEndpoint)) return;
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
// Close old session
if (_session != null)
// Dispose the dead session BEFORE taking the gate: an in-flight subscribe holding the gate is blocked on
// that session, and disposing it is what makes the call fail fast and release the gate.
if (oldSession != null)
{
try
{
_session.Dispose();
oldSession.Dispose();
}
catch
{
}
_session = null;
}
_dataSubscription = null;
_alarmSubscription = null;
if (_settings == null || _allEndpointUrls == null)
// The rest runs under the subscription gate (and its null-outs under _subscriptionLock): no subscribe can
// interleave with the teardown, the reconnect or the replay.
await _subscriptionGate.WaitAsync();
try
{
TransitionState(ConnectionState.Disconnected, oldEndpoint);
return;
}
// Try each endpoint
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
{
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
var url = _allEndpointUrls[_currentEndpointIndex];
try
// The user's settings / endpoint list as of THIS epoch: a Connect during the failover replaces the fields.
ConnectionSettings? settings;
string[]? endpoints;
lock (_subscriptionLock)
{
Logger.Information("Failover attempt to {EndpointUrl}", url);
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
_session = session;
if (_connectionEpoch != epoch || _disposed) return; // the user disconnected / reconnected meanwhile
if (ReferenceEquals(_session, oldSession)) _session = null;
_dataSubscription = null;
_alarmSubscription = null;
settings = _settings;
endpoints = _allEndpointUrls;
}
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) _ = HandleKeepAliveFailureAsync();
});
CurrentConnectionInfo = BuildConnectionInfo(session);
TransitionState(ConnectionState.Connected, url);
Logger.Information("Failover succeeded to {EndpointUrl}", url);
// Replay subscriptions
await ReplaySubscriptionsAsync();
if (settings == null || endpoints == null)
{
TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint);
return;
}
catch (Exception ex)
{
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
}
}
Logger.Error("All failover endpoints unreachable");
TransitionState(ConnectionState.Disconnected, oldEndpoint);
// Try each endpoint
for (var attempt = 0; attempt < endpoints.Length; attempt++)
{
if (!IsCurrentEpoch(epoch)) return; // stop trying once the user has moved on (m5)
_currentEndpointIndex = (_currentEndpointIndex + 1) % endpoints.Length;
var url = endpoints[_currentEndpointIndex];
try
{
Logger.Information("Failover attempt to {EndpointUrl}", url);
var session = await ConnectToEndpointAsync(settings, url, CancellationToken.None);
// A Disconnect / Connect / Dispose that ran while this attempt was connecting wins: never
// resurrect the old connection or overwrite the user's new session. Checked and installed
// atomically against the epoch.
bool installed;
lock (_subscriptionLock)
{
installed = _connectionEpoch == epoch && !_disposed;
if (installed) _session = session;
}
if (!installed)
{
session.Dispose();
return;
}
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
if (!TransitionStateIfCurrent(epoch, ConnectionState.Connected, url)) return;
Logger.Information("Failover succeeded to {EndpointUrl}", url);
// Replay subscriptions
await ReplaySubscriptionsAsync(session);
return;
}
catch (Exception ex)
{
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
}
}
if (TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint))
Logger.Error("All failover endpoints unreachable");
}
finally
{
_subscriptionGate.Release();
}
}
private async Task ReplaySubscriptionsAsync()
/// <summary>Re-creates the recorded subscriptions on <paramref name="session"/>. Runs under the subscription gate.</summary>
private async Task ReplaySubscriptionsAsync(ISessionAdapter session)
{
// Snapshot the bookkeeping state under the lock, then clear it so the
// replayed handles can be recorded fresh as each monitored item is
@@ -791,13 +941,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
// Replay data subscriptions
ISubscriptionAdapter? dataSubscription = null;
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
try
{
if (_dataSubscription == null)
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
if (dataSubscription == null)
{
dataSubscription = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
lock (_subscriptionLock) _dataSubscription = dataSubscription;
}
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
lock (_subscriptionLock)
@@ -817,9 +971,10 @@ public sealed class OpcUaClientService : IOpcUaClientService
try
{
var monitorNode = sourceNodeId ?? ObjectIds.Server;
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
var replayedAlarms = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
var filter = CreateAlarmEventFilter();
await _alarmSubscription.AddEventMonitoredItemAsync(
await replayedAlarms.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
lock (_subscriptionLock)
@@ -937,9 +1092,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
private void TransitionState(ConnectionState newState, string endpointUrl)
{
var oldState = _state;
if (oldState == newState) return;
_state = newState;
ConnectionState oldState;
lock (_subscriptionLock)
{
oldState = _state;
if (oldState == newState) return;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
@@ -43,23 +43,9 @@ public partial class HistoryViewModel : ObservableObject
_dispatcher = dispatcher;
}
/// <summary>Available aggregate types (null means "Raw").</summary>
/// <summary>Available aggregate types (null means "Raw"): every client aggregate, which is the served set.</summary>
public IReadOnlyList<AggregateType?> AggregateTypes { get; } =
[
null,
AggregateType.Average,
AggregateType.Minimum,
AggregateType.Maximum,
AggregateType.Count,
AggregateType.Start,
AggregateType.End,
AggregateType.StandardDeviation,
AggregateType.TimeAverage,
AggregateType.TimeAverage2,
AggregateType.Total,
AggregateType.Total2,
AggregateType.Interpolative
];
[null, .. Enum.GetValues<AggregateType>().Select(a => (AggregateType?)a)];
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
public bool IsAggregateRead => SelectedAggregateType != null;
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
@@ -19,6 +20,11 @@ public partial class SubscriptionsViewModel : ObservableObject
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
// NodeId -> its rows, kept in step with ActiveSubscriptions by OnActiveSubscriptionsChanged, so a notification is
// one lookup instead of a scan of every row on the UI thread (2 000 items at 1 Hz was 4M string compares/s).
// Touched only on the UI thread, like the collection itself.
private readonly Dictionary<string, List<SubscriptionItemViewModel>> _rowsByNodeId = new(StringComparer.Ordinal);
/// <summary>
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
@@ -49,9 +55,13 @@ public partial class SubscriptionsViewModel : ObservableObject
{
_service = service;
_dispatcher = dispatcher;
ActiveSubscriptions.CollectionChanged += OnActiveSubscriptionsChanged;
_service.DataChanged += OnDataChanged;
}
/// <summary>The number of distinct NodeIds in the notification index (test seam).</summary>
internal int IndexedNodeCount => _rowsByNodeId.Count;
/// <summary>Currently active subscriptions.</summary>
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
@@ -62,16 +72,42 @@ public partial class SubscriptionsViewModel : ObservableObject
{
_dispatcher.Post(() =>
{
foreach (var item in ActiveSubscriptions)
if (item.NodeId == e.NodeId)
{
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
}
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
foreach (var item in rows)
{
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
}
});
}
private void OnActiveSubscriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
{
_rowsByNodeId.Clear();
foreach (var item in ActiveSubscriptions) Index(item);
return;
}
if (e.OldItems != null)
foreach (SubscriptionItemViewModel item in e.OldItems)
if (_rowsByNodeId.TryGetValue(item.NodeId, out var rows) && rows.Remove(item) && rows.Count == 0)
_rowsByNodeId.Remove(item.NodeId);
if (e.NewItems != null)
foreach (SubscriptionItemViewModel item in e.NewItems)
Index(item);
}
private void Index(SubscriptionItemViewModel item)
{
if (!_rowsByNodeId.TryGetValue(item.NodeId, out var rows))
_rowsByNodeId[item.NodeId] = rows = [];
rows.Add(item);
}
private bool CanAddSubscription()
{
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
@@ -149,7 +185,7 @@ public partial class SubscriptionsViewModel : ObservableObject
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
// Skip if already subscribed
if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return;
if (_rowsByNodeId.ContainsKey(nodeIdStr)) return;
try
{
@@ -20,11 +20,24 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// until this table follows.
/// </para>
/// <list type="bullet">
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default
/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
/// Bool-in-register arrays have no array encoder; input regions are read-only.</item>
/// <item><b>Modbus</b> — tags addressed by the <c>region</c> + <c>address</c> KEYS (the
/// <c>ModbusTagAddressResolution</c> rule: keys win whenever both are present), read as strictly as the
/// driver reads them (<c>address</c> an integer 0–65535; a present <c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> / <c>stringByteOrder</c> string must name a member). <c>region: Coils</c>: FC15, at most
/// 1968 coils, with ANY <c>dataType</c> — the resolver coerces a bit region to Bool and never refuses it.
/// <c>region: HoldingRegisters</c> (FC16), never Bool: a fixed-width numeric <c>dataType</c>,
/// <c>String</c> (<c>(stringLength+1)/2</c> registers per element, <c>stringLength &gt;= 1</c>) or
/// <c>BitInRegister</c> (one register per element; <c>bitIndex</c> 0–15), whose
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default 123 —
/// the driver refuses rather than chunks a longer FC16) and never more than FC16's protocol ceiling of 123,
/// however high the cap is configured (a conformant device refuses a longer FC16, so that write always
/// fails). A BitInRegister array is read-modify-written — one unchunked FC03 of the span, then one FC16 —
/// so it is also bounded by <c>maxRegistersPerRead</c> (default and protocol ceiling 125). Every span
/// (registers or coils) ends at or before address 65535. FC23 is opt-in, capped lower and falls back to
/// FC16, so it never changes the answer. Input
/// regions are read-only. A string-only tag (an <c>addressString</c> with no keys) is resolved by the
/// driver with ITS family through a parser this assembly cannot reference, so it cannot be proven
/// writable here and stays read-only.</item>
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
/// DateTime element types have no array encoder.</item>
@@ -61,9 +74,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
/// <c>int[]</c> / <c>List&lt;int&gt;</c> to Int16 nodes; the node manager converts a numeric array to the
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>); Modbus <c>Convert</c>s each element to
/// the register / coil type the same way. So those rows do not key on an exact node <c>DataType</c>; they leave
/// out only the node types whose elements the server cannot promise to convert: a <c>DateTime</c> node over a
/// numeric element (it never converts) or a <c>String</c> node over one (it converts only when the text happens
/// to parse, <c>"1"</c> / <c>"true"</c>) — on Modbus over a numeric or bit element, and a Modbus String element
/// takes any node type; likewise on AbCip, AbLegacy and TwinCAT — TwinCAT keeps
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
@@ -74,13 +90,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
public static class DriverArrayWriteSupport
{
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
/// (<c>ModbusDriver</c> resolves it the same way). A configured value only ever LOWERS it — see
/// <see cref="ModbusProtocolMaxRegistersPerWrite"/>.</summary>
public const int ModbusDefaultMaxRegistersPerWrite = 123;
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
public const int ModbusMaxCoilsPerWrite = 1968;
/// <summary>Modbus FC16's protocol ceiling on registers per request (0x7B). A configured
/// <c>maxRegistersPerWrite</c> above it never lifts it: a conformant device refuses a longer FC16.</summary>
public const int ModbusProtocolMaxRegistersPerWrite = 123;
/// <summary>Modbus FC03's protocol ceiling on registers per request (0x7D) — the bound on a BitInRegister[]
/// write's read-modify-write read, together with a configured <c>maxRegistersPerRead</c>.</summary>
public const int ModbusProtocolMaxRegistersPerRead = 125;
/// <summary>The last Modbus address (registers and coils alike): a span ending past it is refused by every
/// conformant device (exception 02).</summary>
private const long ModbusLastAddress = ushort.MaxValue;
// Element type → registers per element (ModbusDriver.RegisterCount).
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -89,6 +118,30 @@ public static class DriverArrayWriteSupport
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
};
// The Modbus enums' member names (ModbusRegion / ModbusDataType / ModbusByteOrder / ModbusStringByteOrder in
// Driver.Modbus.Addressing, which this assembly cannot reference). The parity tests enumerate the real enums, so a
// member added there without being added here fails a test.
private static readonly HashSet<string> ModbusRegions = new(StringComparer.OrdinalIgnoreCase)
{
"Coils", "DiscreteInputs", "InputRegisters", "HoldingRegisters",
};
private static readonly HashSet<string> ModbusDataTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Bool", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float32", "Float64",
"BitInRegister", "String", "Bcd16", "Bcd32",
};
private static readonly HashSet<string> ModbusByteOrders = new(StringComparer.OrdinalIgnoreCase)
{
"BigEndian", "WordSwap", "ByteSwap", "FullReverse",
};
private static readonly HashSet<string> ModbusStringByteOrders = new(StringComparer.OrdinalIgnoreCase)
{
"HighByteFirst", "LowByteFirst",
};
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
@@ -148,22 +201,7 @@ public static class DriverArrayWriteSupport
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
case DriverTypeNames.Modbus:
{
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
// HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
|| ReadArrayLength(root) is not { } count)
return false;
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
var native = ReadEnumName(root, "dataType") ?? "Int16";
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
return ModbusSupports(dataType, tagConfig, driverConfigs);
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
@@ -185,6 +223,81 @@ public static class DriverArrayWriteSupport
}
}
// ModbusTagAddressResolution + ModbusTagDefinitionFactory + ModbusDriver.WriteAsync, KEYS shape only:
// - `region` AND `address` both present (Classify == Keys). Anything else is a string-only (`addressString`) blob the
// driver resolves with ITS family through ModbusAddressParser, which this assembly cannot reference — so it is
// never proven writable here and stays read-only (conservative: a hidden write, never an advertised refusal).
// - the strict key reads: `address` an integer 0–65535; a present string `region` / `dataType` / `byteOrder` /
// `stringByteOrder` must be a member name (a non-string token is read as absent ⇒ the default).
// - Coils: the resolver COERCES any type to Bool (never refuses); one FC15 of at most 1968 coils.
// - HoldingRegisters: never Bool; fixed-width numeric, String ((stringLength+1)/2 registers, stringLength >= 1) or
// BitInRegister (one register each, bitIndex 0–15); arrayLength × registers per element within
// maxRegistersPerWrite clamped to FC16's 123 (the driver refuses rather than chunks; FC23 is opt-in and capped
// lower, falling back to FC16, so it never changes the answer). BitInRegister[] also READS its span first in one
// unchunked FC03, so it is bounded by maxRegistersPerRead (clamped to FC03's 125) too.
// - Every span ends at or before address 65535 (a conformant device refuses one past it with exception 02).
// - Elements CONVERT, so the node type is not exact-matched. A DateTime node over a numeric or bit element never
// converts; a String node over one converts only when the text happens to parse ("1", "true"), which the server
// cannot promise — both are left out. A String element Convert.ToString()s any node type.
private static bool ModbusSupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
{
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.Number
|| !addressEl.TryGetInt32(out var address) || address is < 0 or > ushort.MaxValue
|| ReadArrayLength(root) is not { } count
|| !TryReadModbusEnum(root, "region", ModbusRegions, out var region)
|| !TryReadModbusEnum(root, "dataType", ModbusDataTypes, out var native)
|| !TryReadModbusEnum(root, "byteOrder", ModbusByteOrders, out _)
|| !TryReadModbusEnum(root, "stringByteOrder", ModbusStringByteOrders, out _))
return false;
region ??= "HoldingRegisters";
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
return count <= ModbusMaxCoilsPerWrite && address + (long)count - 1 <= ModbusLastAddress
&& dataType is not ("String" or "DateTime");
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
native ??= "Int16";
int registersPerElement;
if (native.Equals("String", StringComparison.OrdinalIgnoreCase))
{
// The factory narrows to ushort ((ushort)ReadInt) and refuses a zero length.
var stringLength = (ushort)ReadInt(root, "stringLength");
if (stringLength < 1) return false;
registersPerElement = (stringLength + 1) / 2;
}
else
{
if (dataType is "String" or "DateTime") return false;
if (native.Equals("BitInRegister", StringComparison.OrdinalIgnoreCase))
{
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15. The RMW reads the whole span
// in ONE unchunked FC03 before writing it, so the read cap bounds it too.
if ((byte)ReadInt(root, "bitIndex") > 15 || count > ModbusMaxRegistersPerRead(driverConfigs)) return false;
registersPerElement = 1;
}
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
{
return false; // Bool in a register region: the factory refuses the pair
}
}
var registers = (long)count * registersPerElement;
return registers <= ModbusMaxRegistersPerWrite(driverConfigs) && address + registers - 1 <= ModbusLastAddress;
}
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
// caller applies the default); a string must name a member (trimmed, case-insensitive), else the tag is refused.
// Numeric spellings are not member names, so they are refused exactly as ClassifyEnumText refuses them.
private static bool TryReadModbusEnum(JsonElement root, string name, HashSet<string> members, out string? value)
{
value = ReadEnumName(root, name)?.Trim();
return value is null || members.Contains(value);
}
private static int ReadInt(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : 0;
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
@@ -338,9 +451,24 @@ public static class DriverArrayWriteSupport
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
// The effective Modbus FC16 write cap: the smallest maxRegistersPerWrite configured in any config (absent /
// non-positive ⇒ the driver default, 123), CLAMPED to FC16's protocol ceiling of 123. The driver trusts a larger
// configured cap and sends the over-ceiling FC16 (past 127 registers its one-byte byte-count field even wraps), which
// every conformant device refuses — so such a write always fails and is never advertised.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) =>
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerWrite") ?? ModbusDefaultMaxRegistersPerWrite,
ModbusProtocolMaxRegistersPerWrite);
// The effective Modbus FC03 read cap: the smallest maxRegistersPerRead configured (absent / non-positive ⇒ the driver
// default, 125), clamped to FC03's protocol ceiling of 125. Only the BitInRegister[] write reads (its RMW issues one
// unchunked FC03 of the whole span), so only that shape is bounded by it.
private static int ModbusMaxRegistersPerRead(string?[]? driverConfigs) =>
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerRead") ?? ModbusProtocolMaxRegistersPerRead,
ModbusProtocolMaxRegistersPerRead);
// The smallest positive Int32 value of `key` (matched case-insensitively, like the driver's DTO binding) at the root
// of any config; null when none carries one.
private static int? SmallestPositive(string?[]? driverConfigs, string key)
{
int? cap = null;
foreach (var config in driverConfigs ?? [])
@@ -348,7 +476,7 @@ public static class DriverArrayWriteSupport
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
{
@@ -356,7 +484,7 @@ public static class DriverArrayWriteSupport
}
}
}
return cap ?? ModbusDefaultMaxRegistersPerWrite;
return cap;
}
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
@@ -44,17 +44,21 @@ public sealed class ProbeCommand : AbCipCommandBase
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"Family: {Family}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"Family: {Family}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
});
}
finally
{
@@ -45,9 +45,13 @@ public sealed class ReadCommand : AbCipCommandBase
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
});
}
finally
{
@@ -50,23 +50,22 @@ public sealed class SubscribeCommand : AbCipCommandBase
await using var driver = new AbCipDriver(options, DriverInstanceId);
ISubscriptionHandle? handle = null;
// The poll engine raises OnDataChange on a background thread: serialise its lines against each other and
// against this thread's own, and never let a failed console write fault the poll loop (review 07 U7).
var writeLock = new object();
try
{
await driver.InitializeAsync("{}", ct);
await console.Output.WriteLineAsync(
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
};
driver.OnDataChange += CreateChangeLineWriter(console.Output, writeLock);
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
lock (writeLock)
{
console.Output.WriteLine($"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
}
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
@@ -76,6 +75,10 @@ public sealed class SubscribeCommand : AbCipCommandBase
// Expected on Ctrl+C.
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Ctrl+C while still connecting/subscribing: a clean exit, not a stack trace (review 07 U7).
}
finally
{
if (handle is not null)
@@ -57,9 +57,13 @@ public sealed class WriteCommand : AbCipCommandBase
await using var driver = new AbCipDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
});
}
finally
{
@@ -32,27 +32,6 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
/// Any other exception — and an OCE the operator did not ask for — still propagates.
/// </summary>
/// <param name="console">The CLI console.</param>
/// <param name="ct">The console's Ctrl-C token.</param>
/// <param name="body">The command body.</param>
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
{
try
{
await body();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await console.Error.WriteLineAsync("Cancelled.");
}
}
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -79,5 +79,61 @@ public abstract class DriverCommandBase : ICommand
/// </summary>
protected static void FlushLogging() => Log.CloseAndFlush();
/// <summary>
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
/// Any other exception — and an OCE the operator did not ask for — still propagates.
/// </summary>
/// <param name="console">The CLI console.</param>
/// <param name="ct">The console's Ctrl-C token.</param>
/// <param name="body">The command body.</param>
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
public static async ValueTask RunCancellableAsync(IConsole console, CancellationToken ct, Func<Task> body)
{
try
{
await body();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await console.Error.WriteLineAsync("Cancelled.");
}
}
/// <summary>
/// Builds the <c>OnDataChange</c> handler a <c>subscribe</c> command attaches: one
/// <c>[HH:mm:ss.fff] ref = value (status)</c> line per change. Writes are serialised on
/// <paramref name="writeLock"/> (the poll engine raises from a background thread, concurrently with the
/// command thread's own lines), and a failing console write (closed pipe, redirected stream error) is logged
/// and swallowed so it cannot fault the driver's poll loop (review 07 U7).
/// </summary>
/// <param name="output">The console output writer.</param>
/// <param name="writeLock">The lock every console write of the command takes.</param>
/// <returns>The event handler.</returns>
public static EventHandler<ZB.MOM.WW.OtOpcUa.Core.Abstractions.DataChangeEventArgs> CreateChangeLineWriter(
TextWriter output, object writeLock)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(writeLock);
return (_, e) =>
{
try
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
lock (writeLock)
{
output.WriteLine(line);
}
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "subscribe: console write failed for {Tag}; continuing the poll loop.",
e.FullReference);
}
};
}
private bool _loggingConfigured;
}
@@ -101,7 +101,11 @@ public static class SnapshotFormatter
return sb.ToString().TrimEnd();
}
/// <summary>Formats a value for console output, handling null, bool, string, and formattable types.</summary>
/// <summary>
/// Formats a value for console output, handling null, bool, string, formattable and array/list types. An
/// array renders its elements (<c>[1, 2, 3]</c>, each formatted as a scalar) rather than its CLR type name
/// (<c>System.Int32[]</c>).
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A formatted string representation of the value.</returns>
public static string FormatValue(object? value) => value switch
@@ -110,9 +114,23 @@ public static class SnapshotFormatter
bool b => b ? "true" : "false",
string s => $"\"{s}\"",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
System.Collections.IEnumerable items => FormatSequence(items),
_ => value.ToString() ?? "<null>",
};
private static string FormatSequence(System.Collections.IEnumerable items)
{
var sb = new System.Text.StringBuilder("[");
var first = true;
foreach (var item in items)
{
if (!first) sb.Append(", ");
sb.Append(FormatValue(item));
first = false;
}
return sb.Append(']').ToString();
}
/// <summary>Formats an OPC UA status code as a hexadecimal string with named severity classification.</summary>
/// <param name="statusCode">The OPC UA status code to format.</param>
/// <returns>A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)".</returns>
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// An <see cref="AbTransportException"/> whose status came OFF THE WIRE: the device (or the bridge's Execute PCCC
/// object) replied with a non-zero CIP envelope status or PCCC STS. It lets a driver tell a refusal the device
/// ANSWERED from the same <see cref="AbStatus"/> raised locally before anything was sent — most importantly
/// <see cref="AbStatus.Unsupported"/>, which a native runtime also throws for operations it refuses pre-wire
/// (review lane K I2).
/// </summary>
/// <param name="status">The status mapped from the reply.</param>
/// <param name="message">The exception message.</param>
public sealed class AbDeviceReplyException(AbStatus status, string message) : AbTransportException(status, message);
@@ -1,39 +1,81 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// Maps wire statuses (CIP general/extended, PCCC STS/EXT STS) to the transport-neutral <see cref="AbStatus"/> the
/// drivers classify. The class an <see cref="AbStatus"/> lands in is load-bearing for driver health (#527/#737/#738):
/// an ANSWERED refusal (NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
/// DeviceStateConflict, and for AbCip Unsupported) proves the device replied; <see cref="AbStatus.BadConnection"/>
/// is the no-reply comms class; <see cref="AbStatus.BadReply"/> / <see cref="AbStatus.Partial"/> are a reply that
/// cannot be classified. Every code not named below is deliberately <see cref="AbStatus.BadReply"/>.
/// </summary>
public static class AbStatusCodes
{
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// 0x01 (connection failure, incl. a routing hop's unconnected-send timeout) and 0x07 (connection lost) stay
/// <see cref="AbStatus.BadReply"/> on purpose: a bridge reports a dead END device that way, so they are neither an
/// answer nor proof of no reply (AbLegacy #738 "opaque"). A Forward Open refused with one of the request-level
/// codes (0x1C / 0x20 / 0xFF-0x2104) stays a connection failure — see
/// <c>ConnectionManagerServices.ParseForwardOpenReply</c>.
/// </remarks>
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
0x00 or 0x0B => AbStatus.Ok, // success / already in requested mode
0x04 or 0x05 or 0x16 => AbStatus.NotFound, // path segment error / path unknown / object does not exist
0x06 => AbStatus.Partial, // partial transfer (fragmented read continues)
0x08 => AbStatus.Unsupported, // service not supported
0x0A => AbStatus.OutOfBounds, // attribute list error
0x0C or 0x10 => AbStatus.DeviceStateConflict, // object state conflict / device state conflict
0x0E or 0x0F => AbStatus.NotAllowed, // attribute not settable / privilege violation
0x13 => AbStatus.TooSmall, // not enough data
0x15 => AbStatus.TooLarge, // too much data
// Lane K F1 (#737): the controller answered and refused this one request — not a garbled reply, so not a
// comms fault that degrades the driver and evicts the runtime.
0x1C => AbStatus.Unsupported, // missing attribute list entry data
0x20 => AbStatus.Unsupported, // invalid parameter
0xFF when extended.Length > 0 && extended[0] == 0x2104 => AbStatus.OutOfBounds, // offset beyond end of tag
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds, // beyond end of the object
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch, // data type mismatch
_ => AbStatus.BadReply,
};
/// <summary>
/// True for the CIP statuses Lane K F1 moved from <see cref="AbStatus.BadReply"/> to a request-level refusal.
/// A Forward Open refused with one of them is still a connection failure.
/// </summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns><see langword="true"/> for 0x1C, 0x20 and 0xFF/0x2104.</returns>
internal static bool IsRequestLevelRefusal(byte general, ReadOnlySpan<ushort> extended) =>
general is 0x1C or 0x20 || (general == 0xFF && extended.Length > 0 && extended[0] == 0x2104);
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
/// <param name="sts">The PCCC STS byte.</param>
/// <param name="extSts">The extended status byte, when present.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// Per the DF1/PCCC STS table (1770-6.5.16). Lane K F3 (AbLegacy F8, review 07 r0 I4): 0x20, 0x30 and 0x90 are
/// reported by the HOST or a BRIDGE (a DHRIO, a 1761-NET-ENI) when the END device cannot be reached, so they are
/// the no-reply comms class. Mapping them to an answered refusal made a dead DH+/DH-485 node behind a bridge count
/// as reachable forever — no fail-fast backoff, no comms-loss watchdog.
/// </remarks>
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
0x10 => AbStatus.Unsupported, // illegal command or format
0x20 => AbStatus.BadConnection, // host has a problem and will not communicate
0x30 => AbStatus.BadConnection, // remote node host missing, disconnected or shut down
0x40 => AbStatus.DeviceStateConflict, // host could not complete: hardware fault
0x50 => AbStatus.NotFound, // addressing problem or memory protect rungs
0x60 => AbStatus.NotAllowed, // function not allowed due to command protection
0x70 => AbStatus.DeviceStateConflict, // processor is in Program mode
0x90 => AbStatus.BadConnection, // remote node cannot buffer command
0xB0 => AbStatus.DeviceStateConflict, // remote node problem due to download
0xF0 when extSts is 0x06 => AbStatus.NotFound, // EXT: address does not point to something usable
0xF0 when extSts is 0x07 => AbStatus.OutOfBounds, // EXT: file is wrong size
_ => AbStatus.BadReply, // 0x80, 0xA0, 0xC0, other EXT STS: unclassified
};
}
@@ -85,8 +85,15 @@ public static class ConnectionManagerServices
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
reply.GeneralStatus, reply.ExtendedStatus);
{
// An unclassified refusal, or one of the request-level codes AbStatusCodes maps to a per-request refusal
// for TAG services (0x1C / 0x20 / 0xFF-0x2104), is a connection failure here: no tag can be served.
var status = reply.Status == AbStatus.BadReply
|| AbStatusCodes.IsRequestLevelRefusal(reply.GeneralStatus, reply.ExtendedStatus)
? AbStatus.BadConnection
: reply.Status;
throw new ForwardOpenRefusedException(status, reply.GeneralStatus, reply.ExtendedStatus);
}
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
@@ -47,6 +47,10 @@ public sealed class EncapsulationClient : IAsyncDisposable
private readonly SemaphoreSlim _gate = new(1, 1);
private long _ctx;
private ushort _seq; // connected sequence count; only touched under _gate
// R07:L5: the 24-byte header of the frame being read. Frames are read only by RunExchangeAsync, which holds _gate
// for the whole exchange, so one buffer per session serves every read (it used to be a fresh array per reply).
private readonly byte[] _head = new byte[EncapsulationHeader.Size];
private volatile bool _faulted;
private int _disposed;
@@ -282,11 +286,12 @@ public sealed class EncapsulationClient : IAsyncDisposable
private async Task<byte[]> ReadFrameAsync(CancellationToken ct)
{
var head = new byte[EncapsulationHeader.Size];
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
var len = head[2] | (head[3] << 8);
// Only called under _gate (RunExchangeAsync), so the shared header buffer is never read concurrently. The frame
// itself is allocated exactly: the matched reply's CIP payload is returned as a slice of it and outlives the call.
await _stream.ReadExactlyAsync(_head, ct).ConfigureAwait(false);
var len = _head[2] | (_head[3] << 8);
var frame = new byte[EncapsulationHeader.Size + len];
head.CopyTo(frame, 0);
_head.CopyTo(frame, 0);
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
return frame;
}
@@ -1,3 +1,4 @@
using System.Buffers;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
@@ -52,34 +53,61 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct,
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, 0)).ConfigureAwait(false);
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
var data = new List<byte>(first.Length);
data.AddRange(first.Span);
var lastChunk = first.Length;
while (reply.GeneralStatus == StatusPartial)
// R07:L5: the common single-reply read is one copy of the reply data (it used to go through a List<byte> and a
// second ToArray); a fragmented read reassembles in a pooled buffer and copies out once at the end.
if (reply.GeneralStatus != StatusPartial) return (type, handle, first.ToArray());
var data = ArrayPool<byte>.Shared.Rent(Math.Max(first.Length * 4, 1024));
var count = 0;
try
{
if (lastChunk == 0)
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
reply = await _scheduler.SendAsync(
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct,
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, data.Count)).ConfigureAwait(false);
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
if (fragmentType != type)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
// A mid-read change of the structure handle means the tag's type changed under us (a
// download / online edit); stitching the halves would yield a value of neither type.
if (fragmentHandle != handle)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
data.AddRange(chunk.Span);
lastChunk = chunk.Length;
if (data.Count > MaxReadBytes)
throw new AbTransportException(AbStatus.TooLarge,
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
}
Append(ref data, ref count, first.Span);
var lastChunk = first.Length;
return (type, handle, data.ToArray());
while (reply.GeneralStatus == StatusPartial)
{
if (lastChunk == 0)
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
reply = await _scheduler.SendAsync(
LogixServices.ReadTagFragmented(path, elements, (uint)count), ct,
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, count)).ConfigureAwait(false);
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
if (fragmentType != type)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
// A mid-read change of the structure handle means the tag's type changed under us (a
// download / online edit); stitching the halves would yield a value of neither type.
if (fragmentHandle != handle)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
Append(ref data, ref count, chunk.Span);
lastChunk = chunk.Length;
if (count > MaxReadBytes)
throw new AbTransportException(AbStatus.TooLarge,
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
}
return (type, handle, data.AsSpan(0, count).ToArray());
}
finally
{
ArrayPool<byte>.Shared.Return(data);
}
}
/// <summary>Appends <paramref name="chunk"/> to a pooled buffer, growing it (rent bigger, copy, return) as needed.</summary>
private static void Append(ref byte[] buffer, ref int count, ReadOnlySpan<byte> chunk)
{
if (count + chunk.Length > buffer.Length)
{
var bigger = ArrayPool<byte>.Shared.Rent(Math.Max(buffer.Length * 2, count + chunk.Length));
buffer.AsSpan(0, count).CopyTo(bigger);
ArrayPool<byte>.Shared.Return(buffer);
buffer = bigger;
}
chunk.CopyTo(buffer.AsSpan(count));
count += chunk.Length;
}
private static string FormatHandle(ushort? handle) => handle is { } h ? $"0x{h:X4}" : "(none)";
@@ -113,7 +113,7 @@ public static class PcccServices
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
throw new AbDeviceReplyException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var idLen = r.U8();
if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
@@ -131,7 +131,7 @@ public static class PcccServices
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
byte? ext = sts == 0xF0 ? r.U8() : null;
if (sts != 0)
throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
throw new AbDeviceReplyException(AbStatusCodes.FromPccc(sts, ext),
$"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
return reply.Data[r.Position..];
}
@@ -1,17 +1,38 @@
using System.Buffers;
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
/// <remarks>
/// R07:L5: the working buffer is rented from <see cref="ArrayPool{T}.Shared"/> (a 256-byte array per request used
/// to be allocated and thrown away) and handed back by <see cref="ToArray"/>, which therefore COMPLETES the writer:
/// the result is the caller's own array, a second <see cref="ToArray"/> returns another copy of it, and any further
/// write throws <see cref="InvalidOperationException"/> (the buffer belongs to the pool again). A writer abandoned
/// before <see cref="ToArray"/> (a builder that threw) simply leaves its buffer to the GC.
/// </remarks>
public sealed class CipWriter
{
private byte[] _buf = new byte[256];
private const int InitialCapacity = 256;
private byte[]? _buf = ArrayPool<byte>.Shared.Rent(InitialCapacity);
private byte[]? _result;
/// <summary>Gets the number of bytes written so far.</summary>
public int Length { get; private set; }
private byte[] Buffer => _buf ?? throw new InvalidOperationException(
"This CipWriter was completed by ToArray(); its buffer has been returned to the pool.");
private Span<byte> Take(int n)
{
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
var s = _buf.AsSpan(Length, n);
var buf = Buffer;
if (Length + n > buf.Length)
{
var bigger = ArrayPool<byte>.Shared.Rent(Math.Max(buf.Length * 2, Length + n));
buf.AsSpan(0, Length).CopyTo(bigger);
ArrayPool<byte>.Shared.Return(buf);
_buf = buf = bigger;
}
var s = buf.AsSpan(Length, n);
Length += n;
return s;
}
@@ -45,14 +66,27 @@ public sealed class CipWriter
/// <param name="v">The replacement value.</param>
/// <exception cref="ArgumentOutOfRangeException">The two bytes at <paramref name="offset"/> are not already
/// written (an offset in the spare buffer capacity would otherwise be patched silently and lost).</exception>
/// <exception cref="InvalidOperationException">The writer was completed by <see cref="ToArray"/>.</exception>
public void PatchU16(int offset, ushort v)
{
var buf = Buffer;
if (offset < 0 || offset > Length - 2)
throw new ArgumentOutOfRangeException(nameof(offset), offset, $"Cannot patch 2 bytes at {offset}: only {Length} bytes written.");
BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(offset, 2), v);
}
/// <summary>Copies the written bytes to a new array.</summary>
/// <returns>The bytes written so far.</returns>
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
/// <summary>
/// Copies the written bytes to a new array and returns the working buffer to the pool, completing the writer
/// (see the remarks on <see cref="CipWriter"/>).
/// </summary>
/// <returns>The bytes written, in an array the caller owns.</returns>
public byte[] ToArray()
{
if (_result is { } done) return (byte[])done.Clone();
var buf = Buffer;
_result = buf.AsSpan(0, Length).ToArray();
_buf = null;
ArrayPool<byte>.Shared.Return(buf);
return _result;
}
}
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
/// <para>
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
/// comms faults.
/// The native transport maps CIP general statuses 0x1C (missing attribute list entry) and 0x20 (invalid
/// parameter) to <see cref="AbStatus.Unsupported"/> and 0xFF/0x2104 (offset beyond end) to
/// <see cref="AbStatus.OutOfBounds"/> (<c>AbStatusCodes.FromCip</c>, Lane K F1), so they classify here as
/// device refusals. A Forward Open refused with one of them stays a connection failure.
/// </para>
/// </remarks>
internal static class AbCipRefusals
@@ -789,9 +789,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
return (OpVerdict.Neutral, null);
}
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
{
// The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception.
// The native runtime raises a device's refusal (e.g. STS 0x50 "addressing problem") as an exception.
WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
return (OpVerdict.Answered, null);
@@ -838,10 +838,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// reachability (backoff reset, <c>LastSuccessfulRead</c>, Healthy).
/// </summary>
/// <remarks>
/// ⚠️ Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): it maps STS 0x30
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to <c>NotAllowed</c>/<c>NotFound</c>,
/// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
/// classification needs no change once it lands.
/// Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>). Since Lane K F3 (F8, review
/// 07 r0 I4) STS 0x20 / 0x30 / 0x90 — the host or a bridge reporting an unreachable END device — map to
/// <see cref="AbStatus.BadConnection"/> (no reply), so a dead DH+/DH-485 node behind a bridge is a comms fault,
/// not an answer (<c>NativePcccBridgeStatusHealthTests</c>).
/// </remarks>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a device refusal.</returns>
@@ -1035,7 +1035,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
outcome.FaultMessage ??= tex.Message;
}
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
{
device.RecordAnswered();
outcome.AnyAnswered = true;
@@ -1210,7 +1210,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
if (IsAnsweredRefusal(ex)) state.RecordAnswered();
else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
}
@@ -1478,7 +1478,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
{
return (null, AbLegacyStatusMapper.BadNotSupported, false, false);
}
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
{
// The device answered: the index file/element does not exist. Per-tag, not a comms fault.
return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false);
@@ -1617,10 +1617,26 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
/// device's reachability, so it must not evict the handle or degrade driver health.
/// </summary>
/// <remarks>
/// An Unsupported that came OFF THE WIRE (<see cref="AbDeviceReplyException"/>: CIP 0x08 / 0x1C / 0x20 on the
/// Execute PCCC envelope, PCCC STS 0x10) is NOT local — the device answered — and is classified by
/// <see cref="IsAnsweredRefusal"/> instead (review lane K I2).
/// </remarks>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> if the exception is a local refusal that never reached the wire.</returns>
internal static bool IsLocalRefusal(Exception ex) =>
ex is AbTransportException { Status: AbStatus.Unsupported };
ex is AbTransportException { Status: AbStatus.Unsupported } and not AbDeviceReplyException;
/// <summary>
/// True for an exception proving the device ANSWERED and refused this operation: a device-refusal status
/// (<see cref="IsDeviceRefusal"/>), or an <see cref="AbStatus.Unsupported"/> that came off the wire
/// (<see cref="AbDeviceReplyException"/>). Per-tag Bad, keeps the handle, advances <c>LastSuccessfulRead</c>.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> for an answered refusal.</returns>
internal static bool IsAnsweredRefusal(Exception ex) =>
ex is AbTransportException ate
&& (IsDeviceRefusal(ate.Status) || ate is AbDeviceReplyException { Status: AbStatus.Unsupported });
/// <summary>
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
@@ -1603,7 +1603,8 @@ public sealed class ModbusDriver
for (var i = 0; i < values.Length; i++)
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
var qty = (ushort)values.Length;
var pdu15 = new byte[6 + 1 + byteCount];
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount (no trailing byte past the declared data).
var pdu15 = new byte[6 + byteCount];
pdu15[0] = 0x0F;
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
@@ -1638,7 +1639,8 @@ public sealed class ModbusDriver
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
if (await TryWriteViaFc23Async(transport, tag, bytes, qty, ct).ConfigureAwait(false)) return;
var pdu = new byte[6 + 1 + bytes.Length];
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
var pdu = new byte[6 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
@@ -1685,7 +1687,8 @@ public sealed class ModbusDriver
if (!_options.UseFC23ForMultiRegisterWrites || _options.DisableFC23
|| qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
var pdu = new byte[10 + 1 + bytes.Length];
// [fc][readAddr:2][readQty:2][writeAddr:2][writeQty:2][writeByteCount] + data: exactly 10 + byteCount.
var pdu = new byte[10 + bytes.Length];
pdu[0] = 0x17;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); // read start
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); // read quantity
@@ -1824,7 +1827,8 @@ public sealed class ModbusDriver
payload[i * 2] = (byte)(reg >> 8);
payload[i * 2 + 1] = (byte)(reg & 0xFF);
}
var pdu = new byte[6 + 1 + payload.Length];
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
var pdu = new byte[6 + payload.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(count >> 8); pdu[4] = (byte)(count & 0xFF);
@@ -1,3 +1,4 @@
using System.Buffers;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
@@ -55,12 +56,33 @@ public static class ModbusRtuFraming
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
{
var frame = new byte[1 + pdu.Length];
frame[0] = unitId;
pdu.CopyTo(frame.AsSpan(1));
return ModbusCrc.Append(frame);
var frame = new byte[AduLength(pdu.Length)];
WriteAdu(unitId, pdu, frame);
return frame;
}
/// <summary>The length of the RTU ADU for a PDU of <paramref name="pduLength"/> bytes (unit id + PDU + CRC).</summary>
/// <param name="pduLength">The bare PDU length.</param>
/// <returns>The ADU length.</returns>
internal static int AduLength(int pduLength) => 1 + pduLength + 2;
/// <summary>Writes the RTU ADU into <paramref name="destination"/> (exactly <see cref="AduLength"/> bytes).</summary>
/// <param name="unitId">The RTU slave/unit address.</param>
/// <param name="pdu">The bare PDU.</param>
/// <param name="destination">The ADU buffer, exactly <see cref="AduLength"/>(<paramref name="pdu"/>.Length) long.</param>
internal static void WriteAdu(byte unitId, ReadOnlySpan<byte> pdu, Span<byte> destination)
{
destination[0] = unitId;
pdu.CopyTo(destination[1..]);
var body = destination[..(1 + pdu.Length)];
var crc = ModbusCrc.Compute(body);
destination[1 + pdu.Length] = (byte)(crc & 0xFF); // CRC low byte first
destination[2 + pdu.Length] = (byte)(crc >> 8);
}
/// <summary>The largest RTU response frame: addr + fc + byteCount + 255 data bytes + CRC.</summary>
private const int MaxResponseFrame = 3 + 255 + 2;
/// <summary>
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
@@ -82,58 +104,56 @@ public static class ModbusRtuFraming
public static async Task<byte[]> ReadResponsePduAsync(
Stream stream, byte expectedUnit, CancellationToken ct)
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
var header = new byte[2];
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
var fc = header[1];
// R07:L5: the whole frame is read into ONE pooled buffer and validated in place; only the returned PDU is
// allocated (it used to be five arrays: header, byteCount, rest, the reassembled frame and the PDU).
var frame = ArrayPool<byte>.Shared.Rent(MaxResponseFrame);
try
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
await ReadExactlyAsync(stream, frame.AsMemory(0, 2), ct).ConfigureAwait(false);
var fc = frame[1];
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
{
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
// byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
var rest = new byte[byteCount + 2];
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, bc, rest);
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
// variable-length and handled above; any NEW variable-length response FC must be added there
// or it is mis-sized here and surfaces as a desync.
trailing = 4 + 2;
}
int length; // the whole frame, addr through CRC
var already = 2; // bytes of the frame read so far
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
length = 2 + 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
{
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
// byteCount data bytes + CRC(2).
await ReadExactlyAsync(stream, frame.AsMemory(2, 1), ct).ConfigureAwait(false);
length = 3 + frame[2] + 2;
already = 3;
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
// variable-length and handled above; any NEW variable-length response FC must be added there
// or it is mis-sized here and surfaces as a desync.
length = 2 + 4 + 2;
}
var tail = new byte[trailing];
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
await ReadExactlyAsync(stream, frame.AsMemory(already, length - already), ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, frame.AsSpan(0, length));
}
finally
{
ArrayPool<byte>.Shared.Return(frame);
}
}
/// <summary>
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
/// except the CRC bytes, then validates the response address against
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
/// Validates the trailing CRC over everything except the CRC bytes, then validates the response address
/// against <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
/// </summary>
private static byte[] ValidateAndStrip(
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
private static byte[] ValidateAndStrip(byte expectedUnit, ReadOnlySpan<byte> frame)
{
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
var frame = new byte[header.Length + middle.Length + tail.Length];
header.CopyTo(frame);
middle.CopyTo(frame.AsSpan(header.Length));
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
// CRC covers everything except the trailing 2 CRC bytes.
var body = frame.AsSpan(0, frame.Length - 2);
var body = frame[..^2];
var expected = ModbusCrc.Compute(body);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
@@ -172,12 +192,12 @@ public static class ModbusRtuFraming
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
/// </summary>
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
var n = await stream.ReadAsync(buf[read..], ct).ConfigureAwait(false);
if (n == 0)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -150,14 +151,17 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
var stream = CurrentStream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId. Built in a pooled buffer (R07:L5); the same bytes
// ModbusRtuFraming.BuildAdu produces (pinned by a test).
var aduLength = ModbusRtuFraming.AduLength(pdu.Length);
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
ModbusRtuFraming.WriteAdu(unitId, pdu, adu.AsSpan(0, aduLength));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
@@ -181,6 +185,11 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
finally
{
// The write has completed, or was cancelled and the stream torn down, so nothing still reads it.
ArrayPool<byte>.Shared.Return(adu);
}
}
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -59,6 +60,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
internal ModbusSocketLifecycle Lifecycle => _life;
// R07:L5: the 7-byte MBAP header of the reply being read. SendOnceAsync runs only under _gate (single-flight), so
// one buffer per transport serves every transaction (it used to be a fresh array per reply).
private readonly byte[] _header = new byte[7];
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
@@ -139,11 +144,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
if (stream is null) throw new InvalidOperationException("Transport not connected");
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
var adu = new byte[7 + pdu.Length];
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU. Built in a pooled buffer (R07:L5): the request
// never escapes, and the write has completed (or been cancelled and the socket torn down) before the finally
// hands it back. Only the reply PDU, which the caller keeps, is allocated.
// (One async method on purpose: a second awaited helper costs a state-machine box per call, which ate the saving.)
var aduLength = 7 + pdu.Length;
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
adu[0] = (byte)(txId >> 8);
adu[1] = (byte)(txId & 0xFF);
// protocol id already zero
adu[2] = 0; // protocol id
adu[3] = 0;
var len = (ushort)(1 + pdu.Length); // unit id + pdu
adu[4] = (byte)(len >> 8);
adu[5] = (byte)(len & 0xFF);
@@ -154,10 +164,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
var header = _header;
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
@@ -211,6 +221,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
finally
{
// The write has completed, or was cancelled and the socket torn down, so nothing still reads it.
ArrayPool<byte>.Shared.Return(adu);
}
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
@@ -12,6 +12,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -31,7 +32,9 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
@* OnSubmit (not OnValidSubmit): the editor text is pulled from Monaco FIRST, then validated — the bound
SourceCode is only refreshed at blur, so validating before the pull could judge stale text. *@
<EditForm Model="_form" OnSubmit="SubmitAsync" FormName="scriptEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger py-2 px-3" />
<section class="panel rise">
@@ -58,7 +61,9 @@ else
<section class="panel rise mt-3">
<div class="panel-head">Source</div>
<div style="padding:1rem">
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
<MonacoEditor @ref="_editor" @bind-Value="_form.SourceCode" Height="420px"
MarkersChanged="OnMarkersChanged" />
<ScriptProblemsList Report="_markers" OnSelect="RevealProblemAsync" />
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
</div>
</section>
@@ -91,6 +96,13 @@ else
private bool _busy;
private string? _error;
private bool _confirmDeleteVisible;
private MonacoEditor? _editor;
private ScriptProblemReport _markers = ScriptProblemReport.Empty;
private void OnMarkersChanged(ScriptProblemReport report) => _markers = report;
private Task RevealProblemAsync(DiagnosticMarker m)
=> _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
protected override async Task OnInitializedAsync()
{
@@ -112,11 +124,21 @@ else
_loaded = true;
}
private async Task SubmitAsync()
private async Task SubmitAsync(EditContext editContext)
{
_busy = true; _error = null;
try
{
// The editor reports its text only at blur, so read the authoritative current value now. A read
// failure (e.g. the script is too large to send) aborts the save rather than persisting stale source.
if (_editor is not null)
{
var read = await _editor.GetValueAsync();
if (!read.Ok) { _error = $"Not saved: {read.Error}"; return; }
_form.SourceCode = read.Value!;
}
if (!editContext.Validate()) return;
var sourceHash = HashSource(_form.SourceCode);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -1,5 +1,6 @@
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using Microsoft.Extensions.Logging
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@@ -20,9 +21,23 @@
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@if (_tooLargeBytes > 0)
{
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
<div class="text-warning small mt-1" role="status">
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is
@ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
</div>
}
@code {
/// <summary>
/// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per
/// keystroke (each report is one circuit message carrying the whole script). A page that persists the text
/// must read the authoritative current value with <see cref="GetValueAsync"/> at save time.
/// </summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>Raised with the editor text when the editor loses focus after an edit (and from <see cref="GetValueAsync"/>).</summary>
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
@@ -37,17 +52,20 @@
[Parameter] public string? EquipmentId { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
/// later task wires the Roslyn-backed diagnostics.
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
/// markers are sent from JS only when this callback has a subscriber, and at most
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
/// circuit message.
/// </summary>
[Parameter] public EventCallback<object[]> MarkersChanged { get; set; }
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
private readonly string _id = Guid.NewGuid().ToString("N");
private string _lastSentValue = "";
private bool _initialized;
private int _tooLargeBytes;
private bool _wrap;
private bool _minimap;
private bool _dark;
@@ -69,7 +87,11 @@
value = Value ?? "",
language = Language,
readOnly = ReadOnly,
equipmentId = EquipmentId
equipmentId = EquipmentId,
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
reportMarkers = MarkersChanged.HasDelegate,
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
},
_dotNetRef);
_initialized = true;
@@ -126,9 +148,57 @@
return ValueChanged.InvokeAsync(_lastSentValue);
}
/// <summary>
/// Reads the editor's current text on demand (call it at Save), raises <see cref="ValueChanged"/> when it
/// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init)
/// the bound <see cref="Value"/> is authoritative and is returned as-is.
/// </summary>
/// <returns>The current text, or an error (e.g. the script is too large to send) — never a stale value.</returns>
public async Task<EditorReadResult> GetValueAsync()
{
if (!_initialized) return new EditorReadResult(Value ?? "", null);
string? current;
try
{
current = await JS.InvokeAsync<string?>("MonacoBlazor.getValue", _id);
}
catch (JSDisconnectedException)
{
return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again.");
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id);
// JSException.Message carries the JS stack after the first line; the author needs only the reason.
return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim());
}
if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed
_tooLargeBytes = 0; // it was read, so it fits
await OnValueChanged(current);
return new EditorReadResult(current, null);
}
/// <summary>The outcome of <see cref="GetValueAsync"/>: the text, or why it could not be read.</summary>
/// <param name="Value">The editor text; null when <paramref name="Error"/> is set.</param>
/// <param name="Error">Why the text could not be read; null on success.</param>
public sealed record EditorReadResult(string? Value, string? Error)
{
/// <summary>True when <see cref="Value"/> holds the editor text.</summary>
public bool Ok => Error is null;
}
[JSInvokable]
public Task OnMarkersChanged(object[] markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<object>());
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
/// <summary>The JS reports, at blur, that the text has grown past (bytes &gt; 0) or back under (0) the sync limit.</summary>
[JSInvokable]
public void OnSyncRefused(int bytes)
{
if (_tooLargeBytes == bytes) return;
_tooLargeBytes = bytes;
StateHasChanged();
}
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
@@ -0,0 +1,60 @@
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@* The script editor's Problems list. Fed by MonacoEditor.MarkersChanged; clicking a row calls OnSelect, which the
host wires to MonacoEditor.RevealLineAsync so the caret jumps to the problem. Ordering and wording live in
ScriptProblems (unit-tested). *@
@{
var report = Report ?? ScriptProblemReport.Empty;
var ordered = ScriptProblems.Order(report.Markers);
}
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
<div class="d-flex align-items-center gap-2 text-muted mb-1">
<span class="fw-semibold">Problems</span>
<span>@ScriptProblems.Summary(report with { Markers = ordered })</span>
</div>
@if (ordered.Count > 0)
{
<ul class="list-group list-group-flush border rounded" style="max-height: 10rem; overflow-y: auto;">
@foreach (var m in ordered.Take(MaxRows))
{
<li class="list-group-item list-group-item-action py-1 px-2 d-flex gap-2 align-items-baseline"
role="button" tabindex="0" style="cursor: pointer;"
title="Go to line @m.StartLineNumber, column @m.StartColumn"
@onclick="() => OnSelect.InvokeAsync(m)"
@onkeydown="e => OnKeyAsync(e, m)">
<span class="badge @BadgeClass(m.Severity)">@ScriptProblems.SeverityLabel(m.Severity)</span>
<span class="mono text-muted">@m.StartLineNumber:@m.StartColumn</span>
<span class="flex-grow-1">@m.Message</span>
<span class="mono text-muted">@m.Code</span>
</li>
}
</ul>
@if (report.Total > Math.Min(ordered.Count, MaxRows))
{
@* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the
rest are still squiggled in the editor. *@
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
}
}
</div>
@code {
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
[Parameter] public ScriptProblemReport? Report { get; set; }
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
[Parameter] public EventCallback<DiagnosticMarker> OnSelect { get; set; }
private Task OnKeyAsync(KeyboardEventArgs e, DiagnosticMarker m)
=> e.Key is "Enter" or " " ? OnSelect.InvokeAsync(m) : Task.CompletedTask;
private static string BadgeClass(int severity) => severity switch
{
ScriptProblems.Error => "text-bg-danger",
ScriptProblems.Warning => "text-bg-warning",
_ => "text-bg-secondary",
};
}
@@ -7,6 +7,7 @@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@inject IUnsTreeService Svc
@if (Visible)
@@ -99,7 +100,9 @@
Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect all of them.
</div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
MarkersChanged="OnScriptMarkersChanged" />
<ScriptProblemsList Report="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
@@ -217,6 +220,24 @@
private bool _scriptSaving;
private bool _scriptSaved;
private string? _scriptError;
private MonacoEditor? _scriptEditor;
private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty;
private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report;
private Task RevealScriptProblemAsync(DiagnosticMarker m)
=> _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
// The inline editor reports its text only at blur; pull the authoritative current value before acting on it.
// Returns false (with _scriptError set) when the text could not be read, so nothing stale is saved.
private async Task<bool> PullScriptSourceAsync()
{
if (_scriptEditor is null) return true;
var read = await _scriptEditor.GetValueAsync();
if (!read.Ok) { _scriptError = $"Not saved: {read.Error}"; return false; }
_scriptSource = read.Value!;
return true;
}
// "New script" action state (shown only when no script is bound yet).
private bool _scriptCreating;
@@ -227,6 +248,9 @@
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
// Closing removes the editor component: drop its reference and its problems.
_scriptEditor = null;
_scriptMarkers = ScriptProblemReport.Empty;
return;
}
@@ -278,6 +302,9 @@
{
_scriptSaved = false;
_scriptError = null;
// A different script (or a reload) — drop the previous script's problems. The editor instance itself stays:
// while the panel is open the same MonacoEditor component receives the new source via its Value.
_scriptMarkers = ScriptProblemReport.Empty;
if (string.IsNullOrEmpty(_form.ScriptId))
{
@@ -358,8 +385,16 @@
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
/// </summary>
private void ToggleScriptPanel()
private async Task ToggleScriptPanel()
{
if (_scriptExpanded)
{
// Collapsing disposes the editor: keep its unsaved text in _scriptSource so re-opening shows it.
// A failed read keeps the panel open (with the error) rather than discarding the edits.
if (!await PullScriptSourceAsync()) return;
_scriptEditor = null;
_scriptMarkers = ScriptProblemReport.Empty;
}
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded)
{
@@ -384,6 +419,7 @@
_scriptError = null;
try
{
if (!await PullScriptSourceAsync()) return;
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
if (result.Ok)
{
@@ -57,7 +57,7 @@ public static class EndpointRouteBuilderExtensions
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddAdminUI(this IServiceCollection services)
{
services.AddRazorComponents().AddInteractiveServerComponents();
services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize);
services.AddOtOpcUaDriverStatusServices();
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
@@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog(
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
IEnumerable<string> query = paths;
if (!string.IsNullOrWhiteSpace(filter))
{
query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
}
return query
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.Take(MaxResults)
.ToList();
// The sorted, de-duplicated path array is built once per snapshot; a keystroke is a binary search.
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
return index.FindByPrefix(filter, MaxResults);
}
/// <inheritdoc />
public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
{
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
// Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal
// via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would
// not resolve at runtime — hover must report it as "not a known configured tag path".
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
return index.TryGet(path);
}
/// <inheritdoc />
@@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation);
public record FormatRequest(string Code);
public record FormatResponse(string Code);
public record InlayHintsRequest(string Code);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
@@ -8,20 +8,37 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
/// <summary>Status returned when the editor abandoned the request (Monaco cancelled it or the fetch was
/// aborted). Nobody reads the body; it only keeps a routine cancellation out of the error logs.</summary>
private const int ClientClosedRequest = 499;
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
/// <returns>The same route builder, for chaining.</returns>
/// <remarks>Every analysis handler binds the request's <see cref="CancellationToken"/> (<c>HttpContext.RequestAborted</c>)
/// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion.</remarks>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.DiagnoseAsync(r, ct), ct));
group.MapPost("/completions", (CompletionsRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.CompleteAsync(r, ct), ct));
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct));
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
return endpoints;
}
private static async Task<IResult> Run<T>(Func<Task<T>> analyse, CancellationToken ct)
{
try
{
return Results.Ok(await analyse());
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return Results.StatusCode(ClientClosedRequest);
}
}
}
@@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <see cref="VirtualTagContext"/> context.
/// </summary>
/// <remarks>
/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
/// are wired and backed by the real Roslyn document/analysis pipeline. Only InlayHints is
/// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is
/// not implemented.
/// <para>All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
/// are wired and backed by the real Roslyn document/analysis pipeline.</para>
/// <para><b>Cost model.</b> Every analysis derives from one process-wide <see cref="BaseCompilation"/>
/// via <see cref="CSharpCompilation.ReplaceSyntaxTree"/>, so the sandbox reference set is bound once rather
/// than per keystroke, and every entry point takes the request's <see cref="CancellationToken"/>
/// (<c>HttpContext.RequestAborted</c>) so an abandoned request stops instead of running to completion.</para>
/// </remarks>
public sealed class ScriptAnalysisService
{
@@ -56,16 +58,39 @@ public sealed class ScriptAnalysisService
+ " var ctx = globals.ctx;\n#line 1\n";
}
// userSource is appended after Preamble, then the method/class are closed.
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource)
private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct)
=> CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct);
/// <summary>
/// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
/// (forced here by resolving <c>System.Object</c>); <see cref="CSharpCompilation.ReplaceSyntaxTree"/> keeps
/// that binding, so a request only re-binds the user's tree instead of the whole reference set. It is
/// immutable, so sharing it across circuits and requests is thread-safe.
/// </summary>
internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation();
private static CSharpCompilation CreateBaseCompilation()
{
var prefix = Preamble;
var full = prefix + (userSource ?? "") + "\n }\n}\n";
var tree = CSharpSyntaxTree.ParseText(full);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length);
var compilation = CSharpCompilation.Create(
"ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions);
_ = compilation.GetSpecialType(SpecialType.System_Object);
return compilation;
}
// userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight
// from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives
// for one HTTP request and a per-instance lineage would never be more than one step long.
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
string userSource, CancellationToken ct)
{
var tree = WrapAndParse(userSource, ct);
var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree);
return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length);
}
/// <summary>Test seam: the compilation one analysis of <paramref name="userSource"/> runs against.</summary>
internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation;
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
@@ -87,14 +112,16 @@ public sealed class ScriptAnalysisService
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
/// (forbidden types, disallowed dynamic tag paths).</summary>
/// <param name="req">The diagnose request carrying the script source.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
public DiagnoseResponse Diagnose(DiagnoseRequest req)
public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.Code);
var (tree, compilation, _, preambleLength) = Analyze(code);
var (tree, compilation, _, preambleLength) = Analyze(code, ct);
var markers = new List<DiagnosticMarker>();
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
@@ -107,7 +134,7 @@ public sealed class ScriptAnalysisService
// makes GetMappedLineSpan report user-source coords (GetLineSpan would return the
// physical wrapper line and be wrong); the preambleLength guard also drops phantom
// wrapper diagnostics like CS0161 on Run() when the user hasn't typed `return` yet.
foreach (var d in compilation.GetDiagnostics())
foreach (var d in compilation.GetDiagnostics(ct))
{
if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
if (d.Location.SourceSpan.Start < preambleLength) continue;
@@ -115,13 +142,23 @@ public sealed class ScriptAnalysisService
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
}
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
ct.ThrowIfCancellationRequested();
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
ct.ThrowIfCancellationRequested();
// (3) DependencyExtractor — spans are offsets in the normalized user source; map directly.
foreach (var r in DependencyExtractor.Extract(code).Rejections)
markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
// (4) ctx.SetVirtualTag(...) is inert at runtime — a WARNING (severity 4), never an error: publish
// accepts the script, so the editor must too; the marker just puts the hover's caveat in the Problems list.
foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength))
markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT"));
return new DiagnoseResponse(markers.Distinct().ToList());
return new DiagnoseResponse(Bound(markers));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
@@ -144,10 +181,11 @@ public sealed class ScriptAnalysisService
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req, CancellationToken ct = default)
{
var baseResp = Diagnose(req);
var baseResp = Diagnose(req, ct);
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
return baseResp;
try
@@ -156,7 +194,7 @@ public sealed class ScriptAnalysisService
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
return baseResp;
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, ct);
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
var markers = new List<DiagnosticMarker>(baseResp.Markers);
@@ -173,7 +211,11 @@ public sealed class ScriptAnalysisService
$"reference named '{refName}'. Add a matching reference or correct the path.",
"OTSCRIPT_EQUIPREF"));
}
return new DiagnoseResponse(markers.Distinct().ToList());
return new DiagnoseResponse(Bound(markers));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
@@ -182,6 +224,35 @@ public sealed class ScriptAnalysisService
}
}
// Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from):
// de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to
// MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still
// rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste.
private static IReadOnlyList<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> markers)
=> ScriptProblems.Order(markers.Distinct())
.Take(ScriptEditorLimits.MaxDiagnosticMarkers)
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars
? m
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) })
.ToList();
private const string SetVirtualTagInertMessage =
"ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " +
"cascade engine is not wired into the host). The script compiles and publishes, but this write will not " +
"take effect.";
// Every ctx.SetVirtualTag(...) invocation in the user source — the same `ctx`-anchored receiver guard as the
// tag-path completion (TryGetTagPathLiteral), so comments, string literals and other receivers are ignored.
private static IEnumerable<InvocationExpressionSyntax> SetVirtualTagCalls(SyntaxNode root, int preambleLength)
=> root.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Where(inv => inv.SpanStart >= preambleLength
&& inv.Expression is MemberAccessExpressionSyntax
{
Expression: IdentifierNameSyntax { Identifier.ValueText: "ctx" },
Name.Identifier.ValueText: nameof(ScriptContext.SetVirtualTag),
});
private static int Sev(DiagnosticSeverity s) => s switch
{
DiagnosticSeverity.Error => 8,
@@ -210,16 +281,18 @@ public sealed class ScriptAnalysisService
}
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
/// <param name="req">The completions request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved list of completion items, empty when none apply.</returns>
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var root = await tree.GetRootAsync();
var root = await tree.GetRootAsync(ct);
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
var token = root.FindToken(Math.Max(0, position - 1));
@@ -233,26 +306,31 @@ public sealed class ScriptAnalysisService
if (string.IsNullOrEmpty(req.EquipmentId))
return new CompletionsResponse(Array.Empty<CompletionItem>());
var names = await _catalog.GetEquipmentReferenceNamesAsync(
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct);
return new CompletionsResponse(names
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
.ToList());
}
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
var paths = await _catalog.GetPathsAsync(pathPrefix, ct);
return new CompletionsResponse(
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
}
var dot = TryGetDotMembers(token, model);
var dot = TryGetDotMembers(token, model, ct);
if (dot != null) return new CompletionsResponse(dot);
ct.ThrowIfCancellationRequested();
var scoped = model.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
return new CompletionsResponse(scoped);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script completion failed; returning none.");
@@ -260,12 +338,12 @@ public sealed class ScriptAnalysisService
}
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
@@ -322,16 +400,18 @@ public sealed class ScriptAnalysisService
}
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
/// <param name="req">The hover request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
public async Task<HoverResponse> Hover(HoverRequest req)
public async Task<HoverResponse> Hover(HoverRequest req, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
// Tag-path hover takes priority over C# symbol resolution: when the caret sits on a
// ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info
@@ -350,7 +430,7 @@ public sealed class ScriptAnalysisService
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
}
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
var info = await _catalog.GetTagInfoAsync(tagPath, ct);
var tagMd = info is null
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
@@ -368,21 +448,22 @@ public sealed class ScriptAnalysisService
// Resolve the token's node; if that yields nothing, climb to the enclosing
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
// carries no symbol info, but its parent member-access binds to the method).
var symbol = ResolveSymbol(model, node);
var symbol = ResolveSymbol(model, node, ct);
if (symbol is null) return new HoverResponse(null);
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
var summary = TryGetXmlSummary(symbol);
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
return new HoverResponse(md);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); }
}
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node)
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node, CancellationToken ct)
{
foreach (var candidate in ResolutionCandidates(node))
{
var info = model.GetSymbolInfo(candidate);
var info = model.GetSymbolInfo(candidate, ct);
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
if (symbol is not null) return symbol;
}
@@ -420,24 +501,26 @@ public sealed class ScriptAnalysisService
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req)
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default)
{
var empty = new SignatureHelpResponse(null, null, 0);
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
InvocationExpressionSyntax? inv = null;
for (var n = token.Parent; n is not null; n = n.Parent)
if (n is InvocationExpressionSyntax found) { inv = found; break; }
if (inv is null) return empty;
var info = model.GetSymbolInfo(inv);
var info = model.GetSymbolInfo(inv, ct);
var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol;
if (method is null) return empty;
@@ -453,6 +536,7 @@ public sealed class ScriptAnalysisService
return new SignatureHelpResponse(label, ps, active);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
}
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
@@ -474,9 +558,4 @@ public sealed class ScriptAnalysisService
return new FormatResponse(req.Code);
}
}
/// <summary>Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint
/// exists for future use but the feature is not implemented.</summary>
/// <param name="req">The inlay-hints request (unused).</param>
/// <returns>An always-empty inlay-hints response.</returns>
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());
}
@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>Size limits for moving Monaco editor text across the Blazor Server circuit.</summary>
/// <remarks>
/// The editor text crosses JS → .NET only on demand (at blur and when a page saves), never per keystroke. Each
/// crossing is ONE SignalR message, bounded by the Blazor hub's <c>MaximumReceiveMessageSize</c>. The framework
/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the
/// stale source), so <c>AddAdminUI</c> raises it to <see cref="HubMaximumReceiveMessageSize"/>. The editor JS
/// refuses to send a value whose serialized form exceeds <see cref="MaxEditorSyncBytes"/> — an oversize frame
/// would tear the circuit down — and the save surfaces that refusal to the author instead.
/// </remarks>
public static class ScriptEditorLimits
{
/// <summary>The Blazor hub's receive limit, in bytes (1 MB).</summary>
public const long HubMaximumReceiveMessageSize = 1024 * 1024;
/// <summary>The largest serialized editor value the JS will send, leaving 64 KB of frame headroom.</summary>
public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024;
/// <summary>
/// The most diagnostic markers the editor reports to .NET (for the Problems list) in one circuit message.
/// A non-C# paste produces several Roslyn errors per line; uncapped, a few thousand markers exceed the hub
/// limit and tear the circuit down. The JS sends the first N in display order (errors first) plus the total.
/// Monaco's own in-editor squiggles are local and stay uncapped (up to <see cref="MaxDiagnosticMarkers"/>).
/// </summary>
public const int MaxReportedMarkers = 200;
/// <summary>Each reported marker's message is truncated to this many characters before it crosses the circuit.</summary>
public const int MaxReportedMessageChars = 300;
/// <summary>
/// The most markers the diagnostics endpoint returns (errors first). Bounds the analysis response and the
/// marker set Monaco renders for pathological input; an error is always kept when one exists, so the
/// editor still rejects exactly what publish rejects.
/// </summary>
public const int MaxDiagnosticMarkers = 1000;
/// <summary>Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message
/// can quote an arbitrarily long pasted token).</summary>
public const int MaxDiagnosticMessageChars = 1000;
}
@@ -0,0 +1,103 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// Presentation rules for the script editor's Problems list — the markers Monaco reports through
/// <c>MonacoEditor.MarkersChanged</c> (the same <see cref="DiagnosticMarker"/> shape the diagnostics endpoint
/// returns), rendered by <c>ScriptProblemsList</c>.
/// </summary>
public static class ScriptProblems
{
/// <summary>Monaco's error severity.</summary>
public const int Error = 8;
/// <summary>Monaco's warning severity.</summary>
public const int Warning = 4;
/// <summary>Errors first, then warnings, then the rest; within a severity, by position.</summary>
/// <param name="markers">The reported markers (null treated as none).</param>
/// <returns>The markers in display order.</returns>
public static IReadOnlyList<DiagnosticMarker> Order(IEnumerable<DiagnosticMarker>? markers)
=> (markers ?? Array.Empty<DiagnosticMarker>())
.OrderByDescending(m => m.Severity)
.ThenBy(m => m.StartLineNumber)
.ThenBy(m => m.StartColumn)
.ToList();
/// <summary>The label for a Monaco severity value.</summary>
/// <param name="severity">8 = error, 4 = warning, 2 = info, otherwise hint.</param>
/// <returns>The display label.</returns>
public static string SeverityLabel(int severity) => severity switch
{
Error => "Error",
Warning => "Warning",
2 => "Info",
_ => "Hint",
};
/// <summary>A one-line count, e.g. <c>"2 errors, 1 warning"</c>.</summary>
/// <param name="markers">The reported markers.</param>
/// <returns>The summary, or <c>"No problems"</c>.</returns>
public static string Summary(IReadOnlyCollection<DiagnosticMarker> markers)
{
if (markers.Count == 0) return "No problems";
var errors = markers.Count(m => m.Severity == Error);
var warnings = markers.Count(m => m.Severity == Warning);
var other = markers.Count - errors - warnings;
var parts = new List<string>(3);
if (errors > 0) parts.Add(Plural(errors, "error"));
if (warnings > 0) parts.Add(Plural(warnings, "warning"));
if (other > 0) parts.Add($"{other} other");
return string.Join(", ", parts);
}
/// <summary>A one-line count for a (possibly capped) report, e.g. <c>"200 errors (showing 200 of 4321)"</c>.</summary>
/// <param name="report">The report the editor sent.</param>
/// <returns>The summary of the shown markers, with the shown/total suffix when some were left out.</returns>
public static string Summary(ScriptProblemReport report)
{
var summary = Summary(report.Markers.ToList());
return report.Omitted > 0 ? $"{summary} (showing {report.Markers.Count} of {report.Total})" : summary;
}
/// <summary>Cuts <paramref name="text"/> to at most <paramref name="maxChars"/> characters, ending in an ellipsis.</summary>
/// <param name="text">The text.</param>
/// <param name="maxChars">The maximum length, ellipsis included (at least 1).</param>
/// <returns>The text, unchanged when it already fits.</returns>
public static string Truncate(string text, int maxChars)
=> text.Length <= maxChars ? text : string.Concat(text.AsSpan(0, Math.Max(0, maxChars - 1)), "…");
private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
}
/// <summary>
/// What the editor reported for the Problems list: at most <see cref="ScriptEditorLimits.MaxReportedMarkers"/>
/// markers in display order (errors first) plus the <see cref="Total"/> the diagnostics pass produced. The JS caps
/// the report before it crosses the circuit — one hub message, bounded by the 1 MB receive limit — and
/// <see cref="FromEditor"/> clamps it again on arrival.
/// </summary>
/// <param name="Markers">The markers shown.</param>
/// <param name="Total">How many markers the diagnostics pass produced (≥ <c>Markers.Count</c>).</param>
public sealed record ScriptProblemReport(IReadOnlyList<DiagnosticMarker> Markers, int Total)
{
/// <summary>No problems.</summary>
public static readonly ScriptProblemReport Empty = new(Array.Empty<DiagnosticMarker>(), 0);
/// <summary>How many markers were left out of <see cref="Markers"/>.</summary>
public int Omitted => Math.Max(0, Total - Markers.Count);
/// <summary>Builds a report from what the editor JS sent, re-applying the circuit caps defensively.</summary>
/// <param name="markers">The markers sent (already capped by the JS).</param>
/// <param name="total">The total the JS counted.</param>
/// <returns>The clamped report.</returns>
public static ScriptProblemReport FromEditor(IReadOnlyList<DiagnosticMarker>? markers, int total)
{
if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty<DiagnosticMarker>(), total);
var shown = ScriptProblems.Order(markers)
.Take(ScriptEditorLimits.MaxReportedMarkers)
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars
? m
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxReportedMessageChars) })
.ToList();
return new ScriptProblemReport(shown, Math.Max(total, markers.Count));
}
}
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// The query-ready form of one <see cref="ScriptTagCatalogSnapshotCache"/> snapshot: the raw entries, a sorted
/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover.
/// </summary>
/// <remarks>
/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a
/// <see cref="HashSet{T}"/> of every fleet path and sort it on each request, and hover scanned the entries
/// linearly; on a 50k-tag fleet that was tens of MB of garbage per typed character. The semantics are
/// unchanged: completion is case-insensitive <c>StartsWith</c>, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>,
/// with case-only duplicates collapsed to the first-seen casing; hover is an exact Ordinal match (the runtime
/// dependency mux is Ordinal) returning the first entry for a path.
/// </remarks>
public sealed class ScriptTagCatalogIndex
{
private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase;
private readonly Dictionary<string, ScriptTagInfo> _byPath;
private readonly string[] _sortedPaths;
private ScriptTagCatalogIndex(IReadOnlyList<ScriptTagInfo> entries, string[] sortedPaths, Dictionary<string, ScriptTagInfo> byPath)
{
Entries = entries;
_sortedPaths = sortedPaths;
_byPath = byPath;
}
/// <summary>The projection the index was built from.</summary>
public IReadOnlyList<ScriptTagInfo> Entries { get; }
/// <summary>Distinct (case-insensitively) paths, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>.</summary>
public IReadOnlyList<string> SortedPaths => _sortedPaths;
/// <summary>Builds the index over a catalog projection.</summary>
/// <param name="entries">The catalog entries, in projection order (first occurrence wins on duplicates).</param>
/// <returns>The built index.</returns>
public static ScriptTagCatalogIndex Build(IReadOnlyList<ScriptTagInfo> entries)
{
// A stable sort keeps projection order among case-only duplicates, so keeping the first of each run
// reproduces the first-added casing a HashSet(OrdinalIgnoreCase) kept.
var sorted = new List<string>(entries.Count);
foreach (var path in entries.Select(e => e.Path).OrderBy(p => p, SortComparer))
{
if (sorted.Count > 0 && SortComparer.Equals(sorted[^1], path)) continue;
sorted.Add(path);
}
var byPath = new Dictionary<string, ScriptTagInfo>(entries.Count, StringComparer.Ordinal);
foreach (var e in entries) byPath.TryAdd(e.Path, e);
return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath);
}
/// <summary>Paths starting (case-insensitively) with <paramref name="prefix"/>, in sorted order.</summary>
/// <param name="prefix">The literal prefix; null/whitespace returns the first <paramref name="max"/> paths.</param>
/// <param name="max">Upper bound on the returned count.</param>
/// <returns>At most <paramref name="max"/> matching paths.</returns>
public IReadOnlyList<string> FindByPrefix(string? prefix, int max)
{
var paths = _sortedPaths;
if (string.IsNullOrWhiteSpace(prefix)) return paths.Take(max).ToArray();
// Under an ordinal-style (char-by-char) comparer every string with prefix P sorts at or after P and the
// matches form one contiguous run, so a lower-bound binary search plus a forward scan is exact.
var start = Array.BinarySearch(paths, prefix, SortComparer);
if (start < 0) start = ~start;
var result = new List<string>(Math.Min(max, 32));
for (var i = start; i < paths.Length && result.Count < max; i++)
{
if (!paths[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) break;
result.Add(paths[i]);
}
return result;
}
/// <summary>Exact (Ordinal) lookup of a path.</summary>
/// <param name="path">The path to look up.</param>
/// <returns>The first entry for that path, or <see langword="null"/>.</returns>
public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path);
}
@@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
CancellationToken ct)
=> (await GetOrBuildIndexAsync(build, ct)).Entries;
/// <summary>
/// As <see cref="GetOrBuildAsync"/>, but returns the snapshot's query-ready
/// <see cref="ScriptTagCatalogIndex"/> (sorted paths + path dictionary), which is built once per rebuild
/// rather than once per completion keystroke.
/// </summary>
/// <param name="build">Builds a fresh projection; invoked at most once per TTL window.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The index over a fresh snapshot, or over one just built.</returns>
public async Task<ScriptTagCatalogIndex> GetOrBuildIndexAsync(
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
CancellationToken ct)
{
var current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
if (current is not null && !IsStale(current)) return current.Index;
await _rebuildGate.WaitAsync(ct);
try
{
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
if (current is not null && !IsStale(current)) return current.Index;
// A throw here leaves _snapshot untouched (the previous one, if any, keeps serving) and
// propagates to this caller only; the finally still releases the gate.
var entries = await build(ct);
var index = ScriptTagCatalogIndex.Build(await build(ct));
// Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot.
_snapshot = new Snapshot(entries, timeProvider.GetUtcNow());
return entries;
_snapshot = new Snapshot(index, timeProvider.GetUtcNow());
return index;
}
finally
{
@@ -82,5 +95,5 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
private sealed record Snapshot(IReadOnlyList<ScriptTagInfo> Entries, DateTimeOffset TakenAt);
private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt);
}
@@ -23,6 +23,30 @@
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
// POSTs to a script-analysis endpoint, honouring Monaco's CancellationToken: when Monaco abandons a request
// (the caret moved, the user kept typing) the fetch is aborted, which cancels HttpContext.RequestAborted
// server-side so the Roslyn analysis stops instead of running to completion. Resolves to null when the
// request was cancelled or failed, so a provider never applies a superseded answer.
async function postAnalysis(path, body, token) {
if (token && token.isCancellationRequested) return null;
const ac = new AbortController();
const sub = token && token.onCancellationRequested ? token.onCancellationRequested(function () { ac.abort(); }) : null;
try {
const resp = await fetch("/api/script-analysis/" + path, {
method: "POST", credentials: "same-origin", signal: ac.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!resp.ok) return null;
const data = await resp.json();
return (token && token.isCancellationRequested) ? null : data;
} catch (e) {
return null;
} finally {
if (sub && sub.dispose) sub.dispose();
}
}
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
@@ -31,15 +55,12 @@
function registerCSharpProviders() {
monaco.languages.registerCompletionItemProvider("csharp", {
triggerCharacters: [".", "(", "\""],
provideCompletionItems: async function (model, position) {
provideCompletionItems: async function (model, position, context, token) {
try {
const resp = await fetch("/api/script-analysis/completions", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return { suggestions: [] };
const data = await resp.json();
const data = await postAnalysis("completions",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
token);
if (!data) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const wordRange = {
startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
@@ -72,67 +93,34 @@
}
});
monaco.languages.registerHoverProvider("csharp", {
provideHover: async function (model, position) {
provideHover: async function (model, position, token) {
try {
const resp = await fetch("/api/script-analysis/hover", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return null;
const data = await resp.json();
if (!data.markdown) return null;
const data = await postAnalysis("hover",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
token);
if (!data || !data.markdown) return null;
return { contents: [{ value: data.markdown }] };
} catch (e) { return null; }
}
});
monaco.languages.registerDocumentFormattingEditProvider("csharp", {
provideDocumentFormattingEdits: async function (model) {
provideDocumentFormattingEdits: async function (model, options, token) {
try {
const resp = await fetch("/api/script-analysis/format", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue() })
});
if (!resp.ok) return [];
const data = await resp.json();
if (typeof data.code !== "string" || data.code === model.getValue()) return [];
const data = await postAnalysis("format", { code: model.getValue() }, token);
if (!data || typeof data.code !== "string" || data.code === model.getValue()) return [];
return [{ range: model.getFullModelRange(), text: data.code }];
} catch (e) { return []; }
}
});
monaco.languages.registerInlayHintsProvider("csharp", {
provideInlayHints: async function (model) {
try {
const resp = await fetch("/api/script-analysis/inlay-hints", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue() })
});
if (!resp.ok) return { hints: [], dispose: function () {} };
const data = await resp.json();
return {
hints: (data.hints || []).map(function (h) {
return { position: { lineNumber: h.line, column: h.column }, label: h.label, kind: 2, paddingRight: true };
}),
dispose: function () {}
};
} catch (e) { return { hints: [], dispose: function () {} }; }
}
});
monaco.languages.registerSignatureHelpProvider("csharp", {
signatureHelpTriggerCharacters: ["(", ","],
signatureHelpRetriggerCharacters: [","],
provideSignatureHelp: async function (model, position) {
provideSignatureHelp: async function (model, position, token) {
try {
const resp = await fetch("/api/script-analysis/signature-help", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
});
if (!resp.ok) return null;
const data = await resp.json();
if (!data.label) return null;
const data = await postAnalysis("signature-help",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column },
token);
if (!data || !data.label) return null;
return {
value: {
signatures: [{
@@ -149,17 +137,74 @@
});
}
async function fetchDiagnostics(model) {
// Resolves to the marker array, or null when the request was aborted (superseded by a newer edit) — the
// caller must then leave the markers alone. A server/network failure still resolves to [] (clears markers).
async function fetchDiagnostics(model, signal) {
try {
const resp = await fetch("/api/script-analysis/diagnostics", {
method: "POST", credentials: "same-origin",
method: "POST", credentials: "same-origin", signal: signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return [];
const data = await resp.json();
return data.markers || [];
} catch (e) { return []; }
} catch (e) { return (signal && signal.aborted) ? null : []; }
}
// The editor text crosses the Blazor circuit (JS -> .NET) only on demand — at blur and when the page saves —
// never per keystroke. Each crossing is one SignalR message, bounded by the hub's MaximumReceiveMessageSize;
// an oversize message would tear the circuit down, so a value whose serialized form would not fit is refused
// here instead (getValue throws a descriptive error that .NET surfaces to the author).
// Blazor carries an interop value as a JSON string inside the hub frame; measuring it escaped twice is a
// conservative upper bound whichever hub protocol (BlazorPack or JSON) the circuit negotiated.
function serializedBytes(v) { return new TextEncoder().encode(JSON.stringify(JSON.stringify(v))).length; }
function tooLargeMessage(e, v) {
if (!e.maxSyncBytes) return null;
const bytes = serializedBytes(v);
return bytes > e.maxSyncBytes
? "The script is too large for the editor to send to the server (" + bytes + " bytes; the limit is " + e.maxSyncBytes + " bytes)."
: null;
}
function syncValue(e) {
if (!e.dirty) return;
const v = e.editor.getValue();
if (tooLargeMessage(e, v)) {
// Stays dirty; the explicit save read reports the error. Tell .NET once (a tiny message) so the
// author sees a non-blocking notice now rather than only at Save.
if (!e.tooLargeNotified) {
e.tooLargeNotified = true;
e.dotNetRef.invokeMethodAsync("OnSyncRefused", serializedBytes(v)).catch(function () {});
}
return;
}
if (e.tooLargeNotified) {
e.tooLargeNotified = false;
e.dotNetRef.invokeMethodAsync("OnSyncRefused", 0).catch(function () {});
}
e.dirty = false;
e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; });
}
// The Problems-list report is ONE circuit message too. A non-C# paste yields thousands of markers, so only the
// first maxReportedMarkers in display order (errors first, then by position — ScriptProblems.Order) cross, each
// message cut to maxReportedMessageChars, plus the total so the list can say how many were left out.
// Monaco's own squiggles (setModelMarkers) are local and stay uncapped.
function severityOrder(a, b) {
return (b.severity - a.severity) || (a.startLineNumber - b.startLineNumber) || (a.startColumn - b.startColumn);
}
function markerReport(e, markers) {
const max = e.maxReportedMarkers || 200, maxChars = e.maxReportedMessageChars || 300;
const shown = markers.slice().sort(severityOrder).slice(0, max).map(function (m) {
const msg = typeof m.message === "string" && m.message.length > maxChars
? m.message.substring(0, maxChars - 1) + "\u2026" : m.message;
return {
severity: m.severity, startLineNumber: m.startLineNumber, startColumn: m.startColumn,
endLineNumber: m.endLineNumber, endColumn: m.endColumn, message: msg,
code: typeof m.code === "string" ? m.code.substring(0, 64) : m.code
};
});
return { markers: shown, total: markers.length };
}
// A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints).
@@ -200,22 +245,43 @@
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
let diagTimer = null;
const scheduleDiagnostics = function () {
if (diagTimer) clearTimeout(diagTimer);
diagTimer = setTimeout(async function () {
const model = editor.getModel(); if (!model) return;
const markers = await fetchDiagnostics(model);
monaco.editor.setModelMarkers(model, "otopcua", markers);
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
}, 500);
const e = {
editor: editor, dotNetRef: dotNetRef, dirty: false,
maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers,
maxReportedMarkers: options.maxReportedMarkers || 200, maxReportedMessageChars: options.maxReportedMessageChars || 300,
tooLargeNotified: false,
diagTimer: null, diagGeneration: 0, diagAbort: null
};
// Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController
// make sure only the response for the CURRENT text is applied. Every edit bumps the generation and aborts
// the in-flight request, so a slow older response can never overwrite newer markers.
const runDiagnostics = async function () {
const model = editor.getModel(); if (!model) return;
const generation = ++e.diagGeneration;
const ac = new AbortController();
e.diagAbort = ac;
const markers = await fetchDiagnostics(model, ac.signal);
if (markers === null || generation !== e.diagGeneration || editors[id] !== e) return; // superseded
e.diagAbort = null;
monaco.editor.setModelMarkers(model, "otopcua", markers);
if (e.reportMarkers) {
const report = markerReport(e, markers);
dotNetRef.invokeMethodAsync("OnMarkersChanged", report.markers, report.total).catch(function () {});
}
};
e.scheduleDiagnostics = function () {
e.diagGeneration++;
if (e.diagAbort) { e.diagAbort.abort(); e.diagAbort = null; }
if (e.diagTimer) clearTimeout(e.diagTimer);
e.diagTimer = setTimeout(runDiagnostics, 500);
};
editor.onDidChangeModelContent(function () {
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
if (options.language === "csharp") scheduleDiagnostics();
e.dirty = true;
if (options.language === "csharp") e.scheduleDiagnostics();
});
editors[id] = { editor: editor, dotNetRef: dotNetRef };
if (options.language === "csharp") scheduleDiagnostics();
editor.onDidBlurEditorText(function () { syncValue(e); });
editors[id] = e;
if (options.language === "csharp") e.scheduleDiagnostics();
}
function setEditorOption(id, name, value) {
const e = editors[id]; if (!e) return;
@@ -228,10 +294,28 @@
const e = editors[id]; if (!e) return;
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
}
function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
function setValue(id, v) {
const e = editors[id]; if (!e) return;
if (e.editor.getValue() !== v) e.editor.setValue(v || "");
e.dirty = false; // the text now equals what .NET holds
}
// Reads the current text on demand (Save). Throws when it would not fit in one circuit message.
function getValue(id) {
const e = editors[id]; if (!e) return null;
const v = e.editor.getValue();
const err = tooLargeMessage(e, v);
if (err) throw new Error(err);
e.dirty = false;
return v;
}
function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }
function dispose(id) {
const e = editors[id]; if (!e) return;
if (e.diagTimer) clearTimeout(e.diagTimer);
if (e.diagAbort) e.diagAbort.abort();
try { e.editor.dispose(); } catch (x) {}
delete editors[id];
}
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
})();