fix(mtconnect): make a /sample stream end loud, and correct the gap contract (Task 7 review)
Code review of c46540ae approved the framing algorithm but moved the risk onto the contract around it. C1 (critical). SampleAsync returned normally on three different events: EOF, the closing --boundary--, and a response that was not multipart at all (where it yielded one parsed snapshot and finished). The seam documents the stream as yielding until ct is cancelled, so a Task 11 pump written to that contract would silently drop its subscription or hot-loop reconnecting; the non-multipart leg reported a configuration error as a healthy finished stream, which is the #485 quiet-successful-termination shape aimed at the very next task. Every non-cancellation end now throws: MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary - transient) or MTConnectStreamNotSupportedException (configuration - reconnecting reproduces it forever), sharing one catchable base. The non-multipart body is still parsed first so an Agent's own MTConnectError text wins, but the document is NOT yielded. I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first parameter is now expectedFrom. The old name and doc were wrong for every chunk after the first - the correct comparand is the PREVIOUS chunk's NextSequence, and comparing against the opening "from" argument reports a gap on every chunk once the ring buffer rolls, i.e. an endless /current re-baseline storm. Both doc blocks also now record that cppagent answers from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200, which surfaces as InvalidDataException and NOT as a gap - so Task 11 must treat that parse failure as a re-baseline trigger too. I2. Every multipart test served the whole body from one buffer, so every framing test completed in a single ReadAsync - the split-boundary path, the split-header path and the multi-fill loop were correct by inspection only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the fixtures through a split transport, plus a >16 KB document that forces a buffer resize. Falsifiability: removing either scan overlap, or collapsing the fill loop, fails ONLY the new chunked tests. I3. Disposal mid-enumeration surfaces as OperationCanceledException via an internal dispose-linked token, not a raw ObjectDisposedException that Task 9's re-init would inflict on an enumerating pump. I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs in construction-time positive-value validation. All three reach the query string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError that names no config key. I4/M2. The no-Content-length framing fallback logs a one-shot warning (the client takes an optional ILogger); a multipart response with no boundary parameter fails fast instead of degrading into a watchdog timeout. M5/M6. Shared element/attribute reading rules extracted to MTConnectXml; the probe parser documents why it alone does not trim BOM/whitespace. The literal U+FEFF in source became a '' escape. Also, from Task 8: MTConnectObservation gained IsStructured (default false), set from element.HasElements. A DATA_SET/TABLE observation's <Entry> children concatenate through element.Value to nonsense ("12" for two entries) that the index would publish as Good, and only the parser can tell that apart from a legitimate space-bearing Message. False for CONDITION observations - their value comes from the element name, so child content cannot corrupt it. 275/275 green in this suite's own files.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
@@ -30,12 +29,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||
/// <c><Components></c> is a component.
|
||||
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Elements are therefore
|
||||
/// matched on <see cref="XName.LocalName"/> and the namespace is ignored entirely — nothing
|
||||
/// version-specific is hard-coded, and a vendor-extension component in its own namespace
|
||||
/// (whose standard child elements still inherit the default MTConnect namespace) is not
|
||||
/// silently dropped. Attributes, by contrast, are read unqualified so a prefixed attribute
|
||||
/// (e.g. <c>xsi:type</c>) can never be mistaken for a DataItem's <c>type</c>.
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
|
||||
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
|
||||
/// (match on local name, read attributes unqualified) are shared verbatim with
|
||||
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
|
||||
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
|
||||
/// the Agent's document begins. A <c>/sample</c> chunk, by contrast, is lifted out of a
|
||||
/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
|
||||
/// the XML declaration, which <c>XmlReader</c> rejects — hence the trim there and not here.
|
||||
/// This asymmetry is intentional, not an oversight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
||||
@@ -54,6 +59,9 @@ internal static class MTConnectProbeParser
|
||||
/// </summary>
|
||||
private const int MaxComponentDepth = 64;
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "/probe response";
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
@@ -106,12 +114,12 @@ internal static class MTConnectProbeParser
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
|
||||
|
||||
if (!IsNamed(root, "MTConnectDevices"))
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
|
||||
{
|
||||
throw new InvalidDataException(DescribeUnexpectedRoot(root));
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
|
||||
}
|
||||
|
||||
var devicesContainer = ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
|
||||
|
||||
@@ -127,15 +135,15 @@ internal static class MTConnectProbeParser
|
||||
|
||||
private static MTConnectDevice ReadDevice(XElement element) =>
|
||||
new(
|
||||
RequiredAttribute(element, "id", "Device"),
|
||||
OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth: 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
|
||||
new(
|
||||
RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>"),
|
||||
OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth + 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
@@ -151,104 +159,31 @@ internal static class MTConnectProbeParser
|
||||
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
|
||||
}
|
||||
|
||||
return ChildrenNamed(container, "Components")
|
||||
return MTConnectXml.ChildrenNamed(container, "Components")
|
||||
.SelectMany(components => components.Elements())
|
||||
.Select(element => ReadComponent(element, depth))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
||||
ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => ChildrenNamed(dataItems, "DataItem"))
|
||||
MTConnectXml.ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
|
||||
.Select(ReadDataItem)
|
||||
.ToList();
|
||||
|
||||
private static MTConnectDataItem ReadDataItem(XElement element)
|
||||
{
|
||||
var id = RequiredAttribute(element, "id", "DataItem");
|
||||
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
|
||||
|
||||
return new MTConnectDataItem(
|
||||
id,
|
||||
OptionalAttribute(element, "name"),
|
||||
RequiredAttribute(element, "category", $"DataItem '{id}'"),
|
||||
RequiredAttribute(element, "type", $"DataItem '{id}'"),
|
||||
OptionalAttribute(element, "subType"),
|
||||
OptionalAttribute(element, "units"),
|
||||
OptionalAttribute(element, "representation"),
|
||||
OptionalIntAttribute(element, "sampleCount", id));
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "subType"),
|
||||
MTConnectXml.OptionalAttribute(element, "units"),
|
||||
MTConnectXml.OptionalAttribute(element, "representation"),
|
||||
MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
|
||||
}
|
||||
|
||||
private static bool IsNamed(XElement element, string localName) =>
|
||||
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
|
||||
|
||||
private static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
|
||||
parent.Elements().Where(child => IsNamed(child, localName));
|
||||
|
||||
private static string RequiredAttribute(XElement element, string name, string owner)
|
||||
{
|
||||
var value = OptionalAttribute(element, name);
|
||||
|
||||
return value ?? throw new InvalidDataException(
|
||||
$"MTConnect /probe response is malformed: {owner} has no '{name}' attribute.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// Only unqualified attributes are considered, so a namespace-prefixed attribute such as
|
||||
/// <c>xsi:type</c> can never be read as a DataItem's <c>type</c>.
|
||||
/// </summary>
|
||||
private static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static int? OptionalIntAttribute(XElement element, string name, string dataItemId)
|
||||
{
|
||||
var raw = OptionalAttribute(element, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /probe response is malformed: DataItem '{dataItemId}' has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message for a root element that is not <c>MTConnectDevices</c>, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document (which
|
||||
/// Agents return under HTTP 200 for, e.g., an unknown device name).
|
||||
/// </summary>
|
||||
private static string DescribeUnexpectedRoot(XElement root)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect /probe response root element is <{root.Name.LocalName}>, expected <MTConnectDevices>.";
|
||||
|
||||
if (!IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
{
|
||||
var code = OptionalAttribute(e, "errorCode");
|
||||
var text = e.Value.Trim();
|
||||
return code is null ? text : $"{code}: {text}";
|
||||
})
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user