fix(mtconnect): a throwing value formatter cannot escape the write batch

TryFormatValue's catch filter named only FormatException / InvalidCastException
/ OverflowException, and the call sits OUTSIDE WriteAsync's per-slot try. But
Convert.ToString falls back to the value's OWN ToString/IFormattable - arbitrary
caller code that may throw anything - so any other exception escaped WriteAsync
uncaught and failed the entire batch, breaking the documented never-throws
per-slot contract. Broadened to a catch-all; the slot gets BadTypeMismatch and
the exception is logged at Warning (TryFormatValue became an instance method to
reach _logger). One unformattable value now costs its own slot and nothing more.

Falsification checked, not assumed: restoring the narrow filter makes the new
test fail with a raw "System.InvalidOperationException : this formatter is
broken" propagating out of WriteAsync - i.e. it fails for the contract-breach
reason, not on an assertion.

Two tests added:
- a value whose ToString throws InvalidOperationException is refused
  BadTypeMismatch while the *following* good slot still writes (the load-bearing
  half - it proves the batch survived).
- mid-batch cancellation: cancellation injected from inside slot 1's own
  formatting, so slot 0 has provably already reached the Agent. It keeps its
  real Good outcome while slots 1-2 fill BadCommunicationError. Reporting a
  landed write as failed is worse than reporting a failure, and a fill that
  started at 0 instead of i would do exactly that.

Tests: 534 passed / 0 failed (was 532). Solution build: 0 errors, no warnings
in any MTConnect project.
This commit is contained in:
Joseph Doherty
2026-08-05 06:48:49 -04:00
parent b29623cf99
commit b833dda673
2 changed files with 91 additions and 2 deletions
@@ -895,8 +895,16 @@ public sealed class MTConnectDriver
/// happily, making the corruption invisible until someone reads the observation back.
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
/// </para>
/// <para>
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
/// rendering runs the value's own <see cref="object.ToString"/> /
/// <see cref="IFormattable"/>, which is arbitrary code — and this runs OUTSIDE
/// <see cref="WriteAsync"/>'s per-slot <c>try</c>, so anything that escapes here escapes
/// the whole batch and breaks its never-throws contract. It costs its own slot a
/// <c>BadTypeMismatch</c> and is logged.
/// </para>
/// </remarks>
private static bool TryFormatValue(object? value, out string text)
private bool TryFormatValue(object? value, out string text)
{
text = string.Empty;
@@ -912,8 +920,20 @@ public sealed class MTConnectDriver
{
rendered = Convert.ToString(value, CultureInfo.InvariantCulture);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
catch (Exception ex)
{
// Catch-all, and deliberately so. Convert.ToString falls back to the value's OWN
// ToString/IFormattable, which is arbitrary caller code that may throw ANYTHING — and this
// call sits outside WriteAsync's per-slot try, so a narrower filter (FormatException /
// InvalidCastException / OverflowException) let every other exception escape WriteAsync
// uncaught and break the documented never-throws contract for the whole batch. One
// unformattable value must cost its own slot, nothing more.
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not render a write value of type {ValueType} to its invariant string form; the write is refused as a type mismatch.",
_driverInstanceId,
value.GetType());
return false;
}