Merge feat/804-mtconnect-parity
This commit is contained in:
@@ -256,7 +256,8 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <b>This check alone does NOT cover every ring-buffer overflow.</b> It can only judge
|
||||
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
|
||||
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
|
||||
/// document served under <b>HTTP 200</b>, which this client surfaces as an
|
||||
/// document — served under <b>HTTP 200</b> by older Agents and under <b>HTTP 400</b> by a 2.x
|
||||
/// one (both lifted since #804) — which this client surfaces as an
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync(long, CancellationToken)"/>, never as a chunk.
|
||||
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
|
||||
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
|
||||
|
||||
@@ -175,7 +175,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
||||
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
|
||||
using var response = await SendAsync(_probeUri, "MTConnectDevices", "/probe response", ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||
using var response = await SendAsync(_currentUri, "MTConnectStreams", "streams response", ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -432,7 +432,13 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
XElement? root;
|
||||
try
|
||||
{
|
||||
root = XDocument.Parse(body).Root;
|
||||
root = MTConnectXml.ParseHardened(body).Root;
|
||||
}
|
||||
catch (XmlException) when (body.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The hardened parse refuses a DOCTYPE outright (#804). Such an answer cannot be read, so it cannot be
|
||||
// CONFIRMED either — reporting it as an accepted write would be the one wrong answer here.
|
||||
return "The Agent answered with a document carrying a DOCTYPE, which this driver refuses to parse.";
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
@@ -491,7 +497,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
$"{_sampleUriBase}?from={from}{_sampleQuerySuffix}&count={count ?? _sampleCount}&heartbeat={_heartbeatMs}"),
|
||||
UriKind.Absolute);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
private async Task<HttpResponseMessage> SendAsync(
|
||||
Uri uri, string expectedRootName, string subject, CancellationToken ct)
|
||||
{
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
@@ -510,7 +517,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureAgentAnsweredAsync(response, expectedRootName, subject, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -537,11 +544,79 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await EnsureAgentAnsweredAsync(response, "MTConnectStreams", "streams response", deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much of a <b>failure</b> body is read looking for the Agent's <c>MTConnectError</c> document. An Agent's
|
||||
/// error document is a few hundred bytes; a proxy's HTML error page never needs to be held in full.
|
||||
/// </summary>
|
||||
internal const int MaxErrorBodyBytes = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Passes a 2xx response through; otherwise throws — the Agent's own refusal (a refusal-marked
|
||||
/// <see cref="InvalidDataException"/>, see <see cref="MTConnectXml.AgentRefusalFromErrorBody"/>) when the
|
||||
/// failure body is an <c>MTConnectError</c> document, else the plain <see cref="HttpRequestException"/>
|
||||
/// <see cref="HttpResponseMessage.EnsureSuccessStatusCode"/> raises. The response is disposed on every throw.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// At most <see cref="MaxErrorBodyBytes"/> of the body are read, under the caller's deadline. A body that
|
||||
/// cannot be read in time is not worth a different failure: the status alone is reported, exactly as before.
|
||||
/// </remarks>
|
||||
private static async Task EnsureAgentAnsweredAsync(
|
||||
HttpResponseMessage response, string expectedRootName, string subject, CancellationToken deadline)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? head = null;
|
||||
try
|
||||
{
|
||||
head = await ReadHeadAsync(response.Content, deadline).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
// Unreadable body: fall through to the status-only failure below — what was reported before.
|
||||
}
|
||||
|
||||
var refusal = MTConnectXml.AgentRefusalFromErrorBody(head, (int)response.StatusCode, expectedRootName, subject);
|
||||
if (refusal is not null)
|
||||
{
|
||||
throw refusal;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads at most <see cref="MaxErrorBodyBytes"/> of a failure body as UTF-8.</summary>
|
||||
private static async Task<string> ReadHeadAsync(HttpContent content, CancellationToken ct)
|
||||
{
|
||||
await using var body = await content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var buffer = new byte[MaxErrorBodyBytes];
|
||||
var filled = 0;
|
||||
int read;
|
||||
while (filled < buffer.Length &&
|
||||
(read = await body.ReadAsync(buffer.AsMemory(filled, buffer.Length - filled), ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
filled += read;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer, 0, filled);
|
||||
}
|
||||
|
||||
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
|
||||
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
|
||||
{
|
||||
|
||||
@@ -161,6 +161,17 @@ public sealed partial class MTConnectDriver
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan TeardownWait = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// How many consecutive <c>/current</c> re-baselines with no <c>/sample</c> chunk delivered between them
|
||||
/// report the stream Degraded (#804; the Java driver's <c>MAX_REBASELINES_WITHOUT_PROGRESS</c>). One
|
||||
/// re-baseline is a genuine ring-buffer overrun resolving; three in a row is an Agent and a cursor/count
|
||||
/// that cannot agree.
|
||||
/// </summary>
|
||||
internal const int MaxRebaselinesWithoutProgress = 3;
|
||||
|
||||
/// <summary>The type-only <c>LastError</c> a stalled stream (<see cref="MaxRebaselinesWithoutProgress"/>) reports.</summary>
|
||||
internal const string StalledStreamReason = "repeated /sample re-baselines with no observations in between";
|
||||
|
||||
/// <summary>
|
||||
/// Config-JSON reader options, mirroring the sibling driver factories. Note there is
|
||||
/// deliberately <b>no</b> <c>JsonStringEnumConverter</c>: enum-carrying DTO fields stay
|
||||
@@ -1433,6 +1444,9 @@ public sealed partial class MTConnectDriver
|
||||
var devices = 0;
|
||||
var variables = 0;
|
||||
|
||||
// Siblings that resolve to one folder name share ONE folder (#804) — see SiblingFolders.
|
||||
var deviceFolders = new SiblingFolders(builder);
|
||||
|
||||
foreach (var device in model.Devices)
|
||||
{
|
||||
if (device is null || !InDeviceScope(device, deviceScope))
|
||||
@@ -1454,7 +1468,7 @@ public sealed partial class MTConnectDriver
|
||||
}
|
||||
|
||||
devices++;
|
||||
variables += StreamContainer(device, builder.Folder(name, name), writeEnabled);
|
||||
variables += StreamContainer(device, deviceFolders.Get(name), writeEnabled);
|
||||
}
|
||||
|
||||
// A configured scope that matches NO device is an authoring error — almost always a typo or
|
||||
@@ -1555,6 +1569,8 @@ public sealed partial class MTConnectDriver
|
||||
streamed++;
|
||||
}
|
||||
|
||||
var componentFolders = new SiblingFolders(scope);
|
||||
|
||||
foreach (var component in container.Components)
|
||||
{
|
||||
if (component is null)
|
||||
@@ -1574,12 +1590,42 @@ public sealed partial class MTConnectDriver
|
||||
continue;
|
||||
}
|
||||
|
||||
streamed += StreamContainer(component, scope.Folder(name, name), writeEnabled);
|
||||
streamed += StreamContainer(component, componentFolders.Get(name), writeEnabled);
|
||||
}
|
||||
|
||||
return streamed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One builder folder per distinct name under one parent scope: two sibling containers that resolve to the
|
||||
/// same folder name (two components named alike, or one's <c>name</c> equal to another's <c>id</c>) are
|
||||
/// streamed into a SINGLE shared folder, deterministically, rather than as two same-named folders.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why (#804, Java-driver parity).</b> A browse picker keys a folder by its path, so two same-named sibling
|
||||
/// folders share one key: the universal browser's capture (<c>CapturingAddressSpaceBuilder</c>) indexes the
|
||||
/// FIRST and every expand of that path returns the first one's children — the second container's data items
|
||||
/// were listed in the tree but unreachable. Merging is what the Java driver does for the same collision, and
|
||||
/// it loses nothing: every data item is still streamed, keyed by its own DataItem id. A data item whose name
|
||||
/// equals a sibling folder's is NOT a collision here — a leaf is keyed by its DataItem id, never its path.
|
||||
/// </remarks>
|
||||
private sealed class SiblingFolders(IAddressSpaceBuilder parent)
|
||||
{
|
||||
private readonly Dictionary<string, IAddressSpaceBuilder> _folders = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The folder named <paramref name="name"/> under the parent, created on first use.</summary>
|
||||
public IAddressSpaceBuilder Get(string name)
|
||||
{
|
||||
if (!_folders.TryGetValue(name, out var folder))
|
||||
{
|
||||
folder = parent.Folder(name, name);
|
||||
_folders[name] = folder;
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="WriteAsync"/> would accept a write to <paramref name="dataItem"/> — the
|
||||
/// browse-time answer, asked of the same two facts the runtime asks of.
|
||||
@@ -2186,6 +2232,9 @@ public sealed partial class MTConnectDriver
|
||||
var counts = new SampleCountState(options.SampleCount);
|
||||
Volatile.Write(ref _effectiveSampleCount, counts.Count);
|
||||
|
||||
// #804 (Java-driver parity) — consecutive re-baselines with no /sample chunk delivered in between.
|
||||
var rebaselinesWithoutProgress = 0;
|
||||
|
||||
try
|
||||
{
|
||||
if (republishOnStart)
|
||||
@@ -2215,7 +2264,15 @@ public sealed partial class MTConnectDriver
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = await ConsumeStreamAsync(client, options, cursor, instanceId, generation, counts, ct).ConfigureAwait(false);
|
||||
var outcome = await ConsumeStreamAsync(
|
||||
client, options, cursor, instanceId, generation, counts,
|
||||
holdStalled: rebaselinesWithoutProgress >= MaxRebaselinesWithoutProgress, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (outcome.DeliveredAny)
|
||||
{
|
||||
rebaselinesWithoutProgress = 0;
|
||||
}
|
||||
cursor = outcome.Cursor;
|
||||
instanceId = outcome.InstanceId;
|
||||
|
||||
@@ -2291,6 +2348,25 @@ public sealed partial class MTConnectDriver
|
||||
attempt = outcome.DeliveredAny ? 0 : attempt + 1;
|
||||
_reconnectAttempts = attempt;
|
||||
|
||||
// #804 — the backoff above PACES a stream that only ever re-baselines, but each /current
|
||||
// also marked the stream delivered, so it read Healthy for ever while no /sample chunk
|
||||
// arrived (a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on
|
||||
// EVERY open). After MaxRebaselinesWithoutProgress in a row it is reported Degraded, and
|
||||
// stays so (holdStalled) until a chunk is delivered.
|
||||
if (!outcome.DeliveredAny &&
|
||||
++rebaselinesWithoutProgress == MaxRebaselinesWithoutProgress)
|
||||
{
|
||||
if (!TryHoldStreamStalled(generation, StalledStreamReason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
outcome.Error,
|
||||
"MTConnect driver {DriverInstanceId}: {Count} consecutive /current re-baselines of {AgentUri} without a single /sample chunk in between; reporting the stream Degraded until one is delivered. Values are re-baselined, not streamed. An Agent answering every /sample with OUT_OF_RANGE usually means SampleCount is at or above its bufferSize.",
|
||||
_driverInstanceId, rebaselinesWithoutProgress, AgentDisplay(options));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2354,6 +2430,7 @@ public sealed partial class MTConnectDriver
|
||||
long instanceId,
|
||||
long generation,
|
||||
SampleCountState counts,
|
||||
bool holdStalled,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// The RUNNING cursor the gap check is made against: the previous chunk's nextSequence, equal
|
||||
@@ -2402,23 +2479,13 @@ public sealed partial class MTConnectDriver
|
||||
// Dropped BEFORE the re-baseline, and the subscribers are told: if the /current
|
||||
// then fails, holding stale values from a dead device model would be worse than
|
||||
// holding none.
|
||||
if (!TryCaptureSessionState(generation, out var restartIndex, out var restartTracker))
|
||||
if (!TryDropForAgentRestart(generation, chunk.InstanceId, options))
|
||||
{
|
||||
return new StreamOutcome(StreamVerdict.Cancelled, expected, instanceId, delivered, null);
|
||||
}
|
||||
|
||||
// Not live while the index is empty (review m2): reads go to /current until the re-baseline lands.
|
||||
_pumpLive = false;
|
||||
restartIndex.Clear();
|
||||
restartTracker.Clear();
|
||||
FanOut(SubscribedReferences(), restartIndex);
|
||||
|
||||
// The driver's own state is consistent by this point, so it is safe to hand the
|
||||
// restart to arbitrary caller code — which may call straight back into
|
||||
// DiscoverAsync.
|
||||
AnnounceAgentRestart(chunk.InstanceId, options);
|
||||
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
|
||||
return await RebaselineAsync(
|
||||
client, options, expected, chunk.InstanceId, delivered, generation, holdStalled, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -2428,7 +2495,7 @@ public sealed partial class MTConnectDriver
|
||||
"MTConnect driver {DriverInstanceId} fell out of {AgentUri}'s buffer (expected sequence {Expected}, oldest retained {FirstSequence}); re-baselining from /current.",
|
||||
_driverInstanceId, AgentDisplay(options), expected, chunk.FirstSequence);
|
||||
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, holdStalled, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -2501,7 +2568,7 @@ public sealed partial class MTConnectDriver
|
||||
"MTConnect driver {DriverInstanceId} could not resume {AgentUri}'s /sample stream at sequence {Cursor} (the Agent rejected it as out of range); re-baselining from /current.",
|
||||
_driverInstanceId, AgentDisplay(options), cursor);
|
||||
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, ct)
|
||||
return await RebaselineAsync(client, options, expected, instanceId, delivered, generation, holdStalled, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2532,6 +2599,7 @@ public sealed partial class MTConnectDriver
|
||||
long fallbackInstanceId,
|
||||
bool deliveredAny,
|
||||
long generation,
|
||||
bool holdStalled,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -2545,8 +2613,31 @@ public sealed partial class MTConnectDriver
|
||||
StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
|
||||
}
|
||||
|
||||
// #804 — the restart test runs on the SNAPSHOT too (the Java driver's rule). On a real 2.x Agent this
|
||||
// is how a restart is usually first seen: the restarted process refuses the dead one's cursor with
|
||||
// HTTP 400 OUT_OF_RANGE, so no chunk ever reaches the in-stream instanceId check above, and the
|
||||
// re-baseline used to adopt the new instanceId silently — no rediscovery prompt, and the dead model's
|
||||
// held values (data items the new Agent may not declare) kept being served.
|
||||
if (current.InstanceId != fallbackInstanceId)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} saw {AgentUri} restart while re-baselining (instanceId {Previous} -> {Current}); dropping every held observation.",
|
||||
_driverInstanceId, AgentDisplay(options), fallbackInstanceId, current.InstanceId);
|
||||
|
||||
if (!TryDropForAgentRestart(generation, current.InstanceId, options))
|
||||
{
|
||||
return new StreamOutcome(
|
||||
StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
|
||||
}
|
||||
}
|
||||
|
||||
PublishAgentCursor(generation, current.InstanceId, current.NextSequence);
|
||||
if (!ApplyAndFanOut(current, generation, baseline: true) || !TryMarkStreamDelivered(generation))
|
||||
// A stalled stream (#804) keeps its Degraded verdict across the re-baseline: the /current answered, but
|
||||
// it is not the /sample progress the verdict is about. Only a delivered chunk clears it.
|
||||
if (!ApplyAndFanOut(current, generation, baseline: true) ||
|
||||
!(holdStalled && !deliveredAny
|
||||
? TryHoldStreamStalled(generation, StalledStreamReason)
|
||||
: TryMarkStreamDelivered(generation)))
|
||||
{
|
||||
return new StreamOutcome(
|
||||
StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
|
||||
@@ -2732,6 +2823,63 @@ public sealed partial class MTConnectDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Agent restart (a changed <c>instanceId</c>) seen by the pump started under <paramref name="generation"/>:
|
||||
/// drop every held observation and condition state — the device model they describe may no longer exist —
|
||||
/// tell the subscribers, and raise rediscovery (once per distinct id).
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when the pump's session has been replaced (nothing dropped).</returns>
|
||||
private bool TryDropForAgentRestart(long generation, long newInstanceId, MTConnectDriverOptions options)
|
||||
{
|
||||
if (!TryCaptureSessionState(generation, out var restartIndex, out var restartTracker))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not live while the index is empty (review m2): reads go to /current until the re-baseline lands.
|
||||
_pumpLive = false;
|
||||
restartIndex.Clear();
|
||||
restartTracker.Clear();
|
||||
FanOut(SubscribedReferences(), restartIndex);
|
||||
|
||||
// The driver's own state is consistent by this point, so it is safe to hand the restart to arbitrary
|
||||
// caller code — which may call straight back into DiscoverAsync.
|
||||
AnnounceAgentRestart(newInstanceId, options);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #804: the pump started under <paramref name="generation"/> has re-baselined
|
||||
/// <see cref="MaxRebaselinesWithoutProgress"/> times in a row without one <c>/sample</c> chunk: degrade the
|
||||
/// stream — generation-checked, like <see cref="TryDegradeStream"/> — but stamp
|
||||
/// <c>LastSuccessfulRead</c> now, because each <c>/current</c> it re-baselined from IS the Agent answering.
|
||||
/// That is deliberate: the comms-loss watchdog reconnects on non-Healthy AND a frozen stamp, and a reconnect
|
||||
/// cannot fix an Agent that refuses the count or cursor — it would only reconnect-loop the driver (the
|
||||
/// "an Agent refusal is a reply" health contract).
|
||||
/// </summary>
|
||||
/// <param name="generation">The pump's session generation.</param>
|
||||
/// <param name="message">The type-only reason.</param>
|
||||
/// <returns><see langword="false"/> when the pump's session has been replaced (nothing written).</returns>
|
||||
private bool TryHoldStreamStalled(long generation, string message)
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_sessionGeneration != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_streamDegraded = true;
|
||||
if (ReadHealth().State != DriverState.Faulted)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Degraded, DateTime.UtcNow, message));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1: the stream of the pump started under <paramref name="generation"/> delivered — clear its
|
||||
/// degradation, mark it live and publish Healthy, all under the generation check and in one critical section,
|
||||
|
||||
@@ -558,6 +558,13 @@ internal sealed class MTConnectObservationIndex
|
||||
case DriverDataType.Float32:
|
||||
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
|
||||
{
|
||||
// .NET parses an overflowing literal ("1e39") to ±Infinity and accepts the NaN/Infinity
|
||||
// spellings outright; publishing either as a Good reading is a fabricated value (#804).
|
||||
if (!float.IsFinite(single))
|
||||
{
|
||||
return NonFiniteStatus(text);
|
||||
}
|
||||
|
||||
value = single;
|
||||
return Good;
|
||||
}
|
||||
@@ -567,6 +574,11 @@ internal sealed class MTConnectObservationIndex
|
||||
case DriverDataType.Float64:
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
|
||||
{
|
||||
if (!double.IsFinite(real))
|
||||
{
|
||||
return NonFiniteStatus(text);
|
||||
}
|
||||
|
||||
value = real;
|
||||
return Good;
|
||||
}
|
||||
@@ -636,8 +648,65 @@ internal sealed class MTConnectObservationIndex
|
||||
|
||||
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
|
||||
// operator whether to retype the tag or widen it.
|
||||
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
||||
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
|
||||
: BadTypeMismatch; // not a number at all
|
||||
if (!decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var exact))
|
||||
{
|
||||
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
||||
? BadOutOfRange // a number the authored type cannot represent (beyond even decimal's range)
|
||||
: BadTypeMismatch; // not a number at all
|
||||
}
|
||||
|
||||
// #804 (Java-driver parity): Agents do format integer-valued items as "42.000000". A decimal whose value
|
||||
// IS an integer converts losslessly, so it is served. A real fraction ("3.5") is still refused rather than
|
||||
// truncated (stricter than the Java driver, which truncates toward zero): truncating is a fabricated value.
|
||||
return decimal.Truncate(exact) == exact && TryIntegral(exact, type, out value)
|
||||
? Good
|
||||
: BadOutOfRange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The status for a float text that parsed to a non-finite value: an overflowing numeric literal is a
|
||||
/// number the type cannot represent (<c>BadOutOfRange</c>); a NaN/Infinity spelling is not a measurement
|
||||
/// at all (<c>BadTypeMismatch</c>).
|
||||
/// </summary>
|
||||
private static uint NonFiniteStatus(string text)
|
||||
{
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (c is not ((>= '0' and <= '9') or '+' or '-' or '.' or 'e' or 'E'))
|
||||
{
|
||||
return BadTypeMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
return BadOutOfRange;
|
||||
}
|
||||
|
||||
/// <summary>Range-checked conversion of an integer-valued decimal onto an integral driver type.</summary>
|
||||
private static bool TryIntegral(decimal exact, DriverDataType type, out object? value)
|
||||
{
|
||||
value = null;
|
||||
switch (type)
|
||||
{
|
||||
case DriverDataType.Int16 when exact is >= short.MinValue and <= short.MaxValue:
|
||||
value = (short)exact;
|
||||
return true;
|
||||
case DriverDataType.Int32 when exact is >= int.MinValue and <= int.MaxValue:
|
||||
value = (int)exact;
|
||||
return true;
|
||||
case DriverDataType.Int64 when exact is >= long.MinValue and <= long.MaxValue:
|
||||
value = (long)exact;
|
||||
return true;
|
||||
case DriverDataType.UInt16 when exact is >= ushort.MinValue and <= ushort.MaxValue:
|
||||
value = (ushort)exact;
|
||||
return true;
|
||||
case DriverDataType.UInt32 when exact is >= uint.MinValue and <= uint.MaxValue:
|
||||
value = (uint)exact;
|
||||
return true;
|
||||
case DriverDataType.UInt64 when exact is >= ulong.MinValue and <= ulong.MaxValue:
|
||||
value = (ulong)exact;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ internal static class MTConnectProbeParser
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Parse(xml);
|
||||
document = MTConnectXml.ParseHardened(xml);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
@@ -101,7 +101,7 @@ internal static class MTConnectProbeParser
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Load(stream);
|
||||
document = MTConnectXml.LoadHardened(stream);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
@@ -125,7 +125,7 @@ internal static class MTConnectProbeParser
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
|
||||
|
||||
var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
|
||||
var devices = ComponentLikeChildren(devicesContainer).Select(ReadDevice).ToList();
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
@@ -162,11 +162,25 @@ internal static class MTConnectProbeParser
|
||||
}
|
||||
|
||||
return MTConnectXml.ChildrenNamed(container, "Components")
|
||||
.SelectMany(components => components.Elements())
|
||||
.SelectMany(ComponentLikeChildren)
|
||||
.Select(element => ReadComponent(element, depth))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Element names that can sit beside components but are MTConnect's own structural children, never a
|
||||
/// component or device. Defence in depth (#804, Java-driver parity): a schema-valid document never puts one
|
||||
/// directly under <c><Components></c> or <c><Devices></c>, but the "every element child is a
|
||||
/// component" rule is deliberately permissive, and without this list a stray <c><Description></c> there
|
||||
/// would fail the WHOLE probe on its missing <c>id</c> — every data item lost over one misplaced element.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> NotAComponent =
|
||||
new(StringComparer.Ordinal) { "DataItems", "Description", "Configuration", "References", "Compositions" };
|
||||
|
||||
/// <summary>A container's element children minus <see cref="NotAComponent"/>.</summary>
|
||||
private static IEnumerable<XElement> ComponentLikeChildren(XElement container) =>
|
||||
container.Elements().Where(child => !NotAComponent.Contains(child.Name.LocalName));
|
||||
|
||||
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
||||
MTConnectXml.ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
|
||||
|
||||
@@ -109,6 +109,17 @@ internal static class MTConnectStreamsParser
|
||||
/// <summary>How those messages name the header element.</summary>
|
||||
private const string HeaderOwner = "the <Header>";
|
||||
|
||||
/// <summary>
|
||||
/// Bound on element nesting (#804, Java-driver parity). <see cref="ReadElement"/> and
|
||||
/// <see cref="ReadChildren"/> recurse once per nesting level, and the document comes from an
|
||||
/// unauthenticated peer: a part nested a few hundred thousand levels deep (well inside the
|
||||
/// multipart reader's 32 MiB part cap) would otherwise recurse into a
|
||||
/// <see cref="StackOverflowException"/>, which .NET cannot catch — it would take the whole server
|
||||
/// process down, not just this driver. A real streams document nests about eight levels
|
||||
/// (<c>MTConnectStreams/Streams/DeviceStream/ComponentStream/Samples/observation/Entry/Cell</c>).
|
||||
/// </summary>
|
||||
internal const int MaxElementDepth = 64;
|
||||
|
||||
/// <summary>
|
||||
/// How the whole document is read. Two settings are load-bearing rather than incidental.
|
||||
/// </summary>
|
||||
@@ -381,6 +392,12 @@ internal static class MTConnectStreamsParser
|
||||
ValueSink? textSink,
|
||||
List<MTConnectDataSetEntry>? entryCollector = null)
|
||||
{
|
||||
if (reader.Depth >= MaxElementDepth)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect {Subject} nests elements more than {MaxElementDepth} levels deep; refusing to recurse further.");
|
||||
}
|
||||
|
||||
var isObservation = parent != ContainerKind.None;
|
||||
var isCondition = parent == ContainerKind.Condition;
|
||||
var kind = ContainerKindOf(reader);
|
||||
|
||||
@@ -28,6 +28,96 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </remarks>
|
||||
internal static class MTConnectXml
|
||||
{
|
||||
/// <summary>
|
||||
/// The reader settings every <b>tree-building</b> parse of an Agent document uses (the
|
||||
/// <c>/probe</c> model, an error body, a write answer). The Agent is an unauthenticated peer on
|
||||
/// the plant network, so a DOCTYPE is refused outright and nothing external is ever resolved —
|
||||
/// no legitimate MTConnect document carries one. <see cref="XDocument.Parse(string)"/> on its own
|
||||
/// <i>parses</i> DTDs (it expands internal entities up to ten million characters); the streams
|
||||
/// parser already prohibited them, and this is what brings the other parses into line (#804).
|
||||
/// <see cref="XmlReaderSettings.IgnoreWhitespace"/> reproduces <c>XDocument.Parse</c>'s default.
|
||||
/// </summary>
|
||||
private static readonly XmlReaderSettings HardenedTreeSettings = new()
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null,
|
||||
IgnoreWhitespace = true,
|
||||
CloseInput = false,
|
||||
};
|
||||
|
||||
/// <summary>Parses <paramref name="xml"/> into a tree with DTDs prohibited (see <see cref="HardenedTreeSettings"/>).</summary>
|
||||
/// <param name="xml">The document text.</param>
|
||||
/// <returns>The parsed document.</returns>
|
||||
/// <exception cref="XmlException">The text is not well-formed, or carries a DOCTYPE.</exception>
|
||||
public static XDocument ParseHardened(string xml)
|
||||
{
|
||||
using var text = new StringReader(xml);
|
||||
using var reader = XmlReader.Create(text, HardenedTreeSettings);
|
||||
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
|
||||
/// <summary>Loads <paramref name="stream"/> into a tree with DTDs prohibited (see <see cref="HardenedTreeSettings"/>).</summary>
|
||||
/// <param name="stream">The document bytes; not closed.</param>
|
||||
/// <returns>The parsed document.</returns>
|
||||
/// <exception cref="XmlException">The bytes are not well-formed, or carry a DOCTYPE.</exception>
|
||||
public static XDocument LoadHardened(Stream stream)
|
||||
{
|
||||
using var reader = XmlReader.Create(stream, HardenedTreeSettings);
|
||||
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's <c>MTConnectError</c> document in <paramref name="body"/>, rendered as the
|
||||
/// refusal-marked <see cref="InvalidDataException"/> every caller already routes (see
|
||||
/// <see cref="IsAgentRefusal"/>), or <see langword="null"/> when the body is not one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why this exists (#804).</b> A modern Agent (cppagent 2.x, the fixture's
|
||||
/// <c>mtconnect/agent:2.7</c>) does not serve its error documents under HTTP 200 the way older
|
||||
/// ones did: it answers an out-of-range <c>/sample?from=</c> — and a <c>count</c> at or above its
|
||||
/// buffer — with <b>HTTP 400</b>, and an unknown device with <b>404</b>, each carrying a
|
||||
/// well-formed <c>MTConnectError</c> body. A status check alone turns those into an
|
||||
/// <see cref="HttpRequestException"/>, which the pump treats as a transient transport loss and
|
||||
/// reconnects at the SAME evicted cursor, so the <c>OUT_OF_RANGE</c> re-baseline never ran
|
||||
/// against a real 2.x Agent. Lifting the document is what routes it.
|
||||
/// </remarks>
|
||||
/// <param name="body">The (already size-bounded) response body.</param>
|
||||
/// <param name="status">The HTTP status the body arrived under, for the message.</param>
|
||||
/// <param name="expectedRootName">The root the request expected.</param>
|
||||
/// <param name="subject">How to describe the document.</param>
|
||||
/// <returns>The refusal to throw, or <see langword="null"/>.</returns>
|
||||
public static InvalidDataException? AgentRefusalFromErrorBody(
|
||||
string? body, int status, string expectedRootName, string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
XElement? root;
|
||||
try
|
||||
{
|
||||
root = ParseHardened(body.Trim('', ' ', '\t', '\r', '\n')).Root;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (root is null || !IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ex = new InvalidDataException(
|
||||
$"{DescribeUnexpectedRoot(root, expectedRootName, subject)} (served under HTTP {status})");
|
||||
ex.Data[AgentRefusalDataKey] = true;
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
/// <summary>Does <paramref name="element"/> have the given local name, whatever its namespace?</summary>
|
||||
/// <param name="element">The element to test.</param>
|
||||
/// <param name="localName">The unqualified name to match.</param>
|
||||
|
||||
Reference in New Issue
Block a user