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:
@@ -79,6 +79,12 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private const string ConditionContainer = "Condition";
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "streams response";
|
||||
|
||||
/// <summary>How those messages name the header element.</summary>
|
||||
private const string HeaderOwner = "the <Header>";
|
||||
|
||||
/// <summary>
|
||||
/// The three elements whose element children are observations. A ComponentStream may carry
|
||||
/// any subset of them, including none.
|
||||
@@ -105,7 +111,7 @@ internal static class MTConnectStreamsParser
|
||||
{
|
||||
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
|
||||
// trailing line break; XmlReader rejects either ahead of the XML declaration.
|
||||
document = XDocument.Parse(xml.Trim('', ' ', '\t', '\r', '\n'));
|
||||
document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
@@ -140,19 +146,19 @@ internal static class MTConnectStreamsParser
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect streams response has no root element.");
|
||||
|
||||
if (!IsNamed(root, "MTConnectStreams"))
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
|
||||
{
|
||||
throw new InvalidDataException(DescribeUnexpectedRoot(root));
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
|
||||
}
|
||||
|
||||
var header = ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
||||
|
||||
return new MTConnectStreamsResult(
|
||||
RequiredLongAttribute(header, "instanceId"),
|
||||
RequiredLongAttribute(header, "nextSequence"),
|
||||
RequiredLongAttribute(header, "firstSequence"),
|
||||
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
|
||||
ReadObservations(root));
|
||||
}
|
||||
|
||||
@@ -168,7 +174,7 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
foreach (var container in root.Descendants().Where(IsObservationContainer))
|
||||
{
|
||||
var isCondition = IsNamed(container, ConditionContainer);
|
||||
var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
|
||||
foreach (var element in container.Elements())
|
||||
{
|
||||
observations.Add(ReadObservation(element, isCondition));
|
||||
@@ -180,12 +186,20 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
|
||||
{
|
||||
var dataItemId = RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>");
|
||||
var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
|
||||
|
||||
return new MTConnectObservation(
|
||||
dataItemId,
|
||||
isCondition ? ConditionState(element) : element.Value.Trim(),
|
||||
ReadTimestampUtc(element, dataItemId));
|
||||
ReadTimestampUtc(element, dataItemId),
|
||||
|
||||
// A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
|
||||
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
|
||||
// because this is the only layer that can still see the distinction — see
|
||||
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
|
||||
// the element NAME, so child content cannot corrupt it, and flagging one would throw
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && element.HasElements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,7 +217,7 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
|
||||
{
|
||||
var raw = RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'");
|
||||
var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
raw,
|
||||
@@ -221,76 +235,6 @@ internal static class MTConnectStreamsParser
|
||||
}
|
||||
|
||||
private static bool IsObservationContainer(XElement element) =>
|
||||
ObservationContainers.Any(name => IsNamed(element, name));
|
||||
ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
|
||||
|
||||
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 streams 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 vendor attribute can
|
||||
/// never be mistaken for an observation's identity or timestamp.
|
||||
/// </summary>
|
||||
private static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static long RequiredLongAttribute(XElement header, string name)
|
||||
{
|
||||
var raw = RequiredAttribute(header, name, "The <Header>");
|
||||
|
||||
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect streams response is malformed: the <Header> has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message for a root element that is not <c>MTConnectStreams</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., a <c>from</c> outside the buffer).
|
||||
/// </summary>
|
||||
private static string DescribeUnexpectedRoot(XElement root)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect streams response root element is <{root.Name.LocalName}>, expected <MTConnectStreams>.";
|
||||
|
||||
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