Merge feat/804-mtconnect-parity

This commit is contained in:
Joseph Doherty
2026-11-10 23:24:27 -05:00
12 changed files with 1332 additions and 44 deletions
@@ -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>&lt;Components&gt;</c> or <c>&lt;Devices&gt;</c>, but the "every element child is a
/// component" rule is deliberately permissive, and without this list a stray <c>&lt;Description&gt;</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>