fix(mtconnect): close the silent-refusal + teardown-wedge defects in the pump (Task 11 review)

C1 (critical): a latched "this endpoint cannot stream" verdict could end with a
GREEN driver holding a permanently silent subscription. DriverInstanceActor
handles a tag-set change as Unsubscribe-then-Subscribe with no re-initialize;
the unsubscribe cleared the stream-degraded flag, the resubscribe was refused by
the latch without a word, and one successful read then reported Healthy.
StartSampleStreamCore now re-asserts the degradation and logs a Warning naming
the remedy, and teardown no longer clears the flag while the latch is set.

NOT promoted to Faulted: DriverHealthReport maps any Faulted driver to a /readyz
503, so that would de-ready the whole node over one misconfigured Agent whose
reads are perfectly healthy. Faulted stays for the case that earns it.

I1: teardown waits are bounded (5 s) and honour the caller's token. They were
unbounded and ignored it, so DriverInstanceActor's 5 s budget was inert and
PostStop — which blocks a dispatcher thread on ShutdownAsync while holding the
lifecycle semaphore — could be wedged forever by one blocking subscriber. The
cancellation source is disposed only when the loop is provably gone. Applied to
StopProbeLoopAsync too: Task 13 reproduced the identical shape, and closing the
class in one method while leaving the other as the pattern to copy is worse than
not closing it.

I2: the reconnect ladder was monotonic for the process lifetime, so ~9 unrelated
drops pinned the driver at MaxBackoffMs forever. A stream that delivered before
dropping now starts a fresh ladder (at attempt 1, not 0, so MinBackoffMs is
still honoured).

I3: MaxBackoffMs <= 0 clamped every delay to zero — an operator-authorable
reconnect spin. Treated as unset, like a multiplier that cannot grow.

I4: the session published instanceId without NextSequence, so after a restart it
held the new agent's id beside the dead one's cursor, and a gap/OUT_OF_RANGE
re-baseline (id unchanged) never published the cursor at all. Both move in one
CAS, and the pump publishes its cursor as it exits.

I5: OnDataChange was a multicast Invoke — the first throwing handler aborted the
rest of the list, starving every later subscriber. Now walked by hand with the
catch inside the loop. The test that claimed to cover this registered the
recorder BEFORE the thrower, so it passed regardless; swapped, it was red.
Faults are latched to one Warning per stream generation (Debug thereafter) so a
consistently-throwing consumer cannot flood the log.

Also: the pump restarts after an unexpected fault (IsCompleted, not null); a
device-scope that matches nothing is a Warning, not a Debug tally; a device or
component with neither name nor id is skipped rather than emitting a blank path
segment; DiscoverAsync's remark no longer claims DriverInstanceActor retries
(it does not for a Once driver, and injection is dormant in v3); and two flake
seeds in the fake are gone (Dispose re-read its own counter; _disposeCts was
disposed under a racing SampleAsync).

439/439. Every changed behaviour falsified by mutation.
This commit is contained in:
Joseph Doherty
2026-07-24 17:16:13 -04:00
parent a92584c45e
commit a3a0cfe9bb
6 changed files with 926 additions and 78 deletions
@@ -58,7 +58,9 @@ public sealed class MTConnectDiscoverTests
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
MTConnectDriverOptions? options = null,
MTConnectProbeModel? probe = null,
RecordingDriverLogger? logger = null)
{
var client = CannedAgentClient.FromFixtures();
if (probe is not null)
@@ -66,13 +68,15 @@ public sealed class MTConnectDiscoverTests
client.Probe = probe;
}
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client, logger), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
MTConnectDriverOptions? options = null,
MTConnectProbeModel? probe = null,
RecordingDriverLogger? logger = null)
{
var (driver, client) = NewDriver(options, probe);
var (driver, client) = NewDriver(options, probe, logger);
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (driver, client);
@@ -476,6 +480,93 @@ public sealed class MTConnectDiscoverTests
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
}
/// <summary>
/// A configured <c>DeviceName</c> that matches nothing is an authoring error whose only
/// symptom is an empty picker. It must be reported at <b>Warning</b> — at Debug the operator
/// sees a browse that "worked" and a machine that apparently declares nothing, with no
/// evidence pointing at their typo.
/// </summary>
[Fact]
public async Task Discover_warns_when_the_configured_device_scope_matches_nothing()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "no-such-device"), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("no-such-device", StringComparison.Ordinal));
}
/// <summary>
/// …and an agent-wide browse that finds nothing is NOT that warning: it is a different
/// statement (the Agent declares no devices) and must not cry scope-typo.
/// </summary>
[Fact]
public async Task Discover_does_not_warn_about_scope_when_no_scope_is_configured()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(probe: new MTConnectProbeModel([]), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot().ShouldBeEmpty();
}
/// <summary>
/// A component declaring neither a <c>name</c> nor an <c>id</c> has no browse path at all,
/// and folding it in would open a folder called <c>""</c> — a blank segment in the path of
/// everything beneath it. It is skipped; its siblings are unaffected. (A component with a
/// blank id but a real name is fine — the id is only the fallback.)
/// </summary>
[Fact]
public async Task Discover_skips_a_component_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9",
"VMC",
[
new MTConnectComponent(" ", " ", [], [item]),
new MTConnectComponent(" ", "Axes", [], [item]),
new MTConnectComponent("dev9_ctrl", null, [], [item]),
],
[]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC", "VMC/Axes", "VMC/dev9_ctrl"], ignoreOrder: true);
cap.Folders.ShouldAllBe(f => !f.Path.Contains("//", StringComparison.Ordinal));
}
/// <summary>The same rule one level up: a device with no browse path is skipped, and said so.</summary>
[Fact]
public async Task Discover_skips_a_device_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var logger = new RecordingDriverLogger();
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(" ", " ", [], [item]),
new MTConnectDevice("dev9", "VMC", [], [item]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe, logger: logger);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC"]);
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("neither a name nor an id", StringComparison.Ordinal));
}
// ---- the probe-cache contract (anti-wedge) ----
/// <summary>
@@ -556,7 +647,10 @@ public sealed class MTConnectDiscoverTests
/// no data items", which is the #485 shape (failure wearing success's clothes) and would read
/// to an operator in the browse picker as a working connection to an empty machine. Both
/// production callers handle the throw: the universal browser surfaces it as a failed browse,
/// and <c>DriverInstanceActor</c> logs it and retries.
/// and <c>DriverInstanceActor</c> logs it, publishes an empty node set, and does NOT retry
/// (retrying is an <c>UntilStable</c> behaviour; this driver is <c>Once</c>) — which today
/// reaches nothing anyway, since <c>DriverHostActor</c>'s discovered-node injection is
/// dormant in v3. The browse path is the consumer this posture is chosen for.
/// </remarks>
[Fact]
public async Task Discover_before_initialize_throws_and_streams_nothing()