fix(mtconnect): volatile _options publication, IConvertible write guard, idempotent client dispose
Driver.MTConnect-005/-006/-007, each TDD'd red-first. - -005: _options was the one field at the StartCoreAsync commit point published by a plain write and read lock-free from ReadAsync/WriteAsync/DiscoverAsync/HostName/health. Now Volatile.Write/Volatile.Read like _session/_binding/_announcedInstanceId, pinned by a source scan (MTConnectOptionsPublicationTests) that fails on any unfenced touch. - -006: TryFormatValue guarded null and Array, but Convert.ToString renders ANY non-IConvertible value as its type name under a Good status. Guard is now the property the remark relies on; the accepted set (string/bool/numeric/DateTime) is unchanged. - -007: MTConnectAgentClient.Dispose was a check-then-set on a non-volatile bool, so two racing disposals both passed and the loser cancelled an already-disposed CTS. Now an Interlocked.Exchange claim over an int, with the request seams reading it volatile.
This commit is contained in:
@@ -191,6 +191,57 @@ public sealed class MTConnectWriteTests
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_convertible_composite_value_is_refused_as_a_type_mismatch()
|
||||
{
|
||||
// Driver.MTConnect-006. The array guard's stated REASON is "Convert.ToString falls back to
|
||||
// object.ToString for a non-IConvertible value", and an array is only one shape carrying that
|
||||
// property. A List<T> from a non-SDK caller (or an Opc.Ua.ExtensionObject, or an XmlElement)
|
||||
// renders as its TYPE NAME, which the Agent stores under a Good status — the identical
|
||||
// invisible corruption the array guard exists to prevent, refused nowhere.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PositionRawPath, new List<double> { 1.0, 2.0 })],
|
||||
Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)7, "7")]
|
||||
[InlineData('x', "x")]
|
||||
[InlineData("already text", "already text")]
|
||||
public async Task An_IConvertible_scalar_still_writes(object value, string expected)
|
||||
{
|
||||
// Falsification control for the guard above: the accepted set must be UNCHANGED. Every scalar
|
||||
// this driver can carry is IConvertible, so a refusal expressed as "not a primitive I
|
||||
// recognise" — rather than as the IConvertible property the remark relies on — would silently
|
||||
// start refusing real writes instead of composite ones.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, value)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_DateTime_still_writes_as_its_invariant_string()
|
||||
{
|
||||
// DateTime is IConvertible but is neither a primitive nor a string — the shape most likely to
|
||||
// be swept up by a hand-rolled "is this a scalar?" guard.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PositionRawPath, new DateTime(2026, 8, 10, 13, 45, 0, DateTimeKind.Utc))],
|
||||
Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("08/10/2026 13:45:00");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
|
||||
{
|
||||
@@ -358,16 +409,19 @@ public sealed class MTConnectWriteTests
|
||||
// The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against
|
||||
// the Agent before the token was cancelled, and reporting it BadCommunicationError would tell
|
||||
// an operator a write failed that actually landed — the one thing worse than reporting a
|
||||
// failure. Cancellation is injected from inside slot 1's own formatting, so the ordering is a
|
||||
// fact of the run rather than a timing hope.
|
||||
// failure. Cancellation is injected from inside slot 0's own Agent call, so the ordering is a
|
||||
// fact of the run rather than a timing hope. (It used to be injected from a value's ToString;
|
||||
// such a value is not IConvertible and is now refused before it is ever rendered — see
|
||||
// Driver.MTConnect-006 — so the injection point moved to the seam it is asserting around.)
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
using var cts = new CancellationTokenSource();
|
||||
client.AfterWrite = () => cts.Cancel();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest(PositionRawPath, 42.5),
|
||||
new WriteRequest(PositionRawPath, new CancellingFormatter(cts)),
|
||||
new WriteRequest(PositionRawPath, 7.5),
|
||||
new WriteRequest(PositionRawPath, 9.5),
|
||||
],
|
||||
cts.Token);
|
||||
|
||||
@@ -380,17 +434,6 @@ public sealed class MTConnectWriteTests
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
|
||||
}
|
||||
|
||||
/// <summary>Cancels the batch from inside its own rendering, then renders normally.</summary>
|
||||
private sealed class CancellingFormatter(CancellationTokenSource cts)
|
||||
{
|
||||
public override string ToString()
|
||||
{
|
||||
cts.Cancel();
|
||||
|
||||
return "99";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user