fix(mtconnect): back off a /sample re-baseline that resolved nothing; scope Test Connect to the configured device

Driver.MTConnect-001 (Medium): StreamVerdict.Reopen reset the reconnect ladder
unconditionally. The InvalidDataException catch that routes an Agent OUT_OF_RANGE
to a /current re-baseline also catches an unparseable chunk and an over-sized
multipart part - neither of which a re-baseline can resolve - so the pump reopened
and failed with no delay at any point: two HTTP requests and one Warning per
iteration, forever. The Reopen arm now uses the same discriminator the transient
arm does (did THIS connection deliver a chunk?), so a buffer roll behind a working
stream still reopens instantly while a re-baseline that resolved nothing advances
BackoffFor.

Driver.MTConnect-002 (Medium): MTConnectDriverProbe requested {AgentUri}/probe
while MTConnectAgentClient requests {AgentUri}/{DeviceName}/probe, so a mistyped or
since-renamed deviceName showed GREEN on Test Connect and then Faulted the driver
at InitializeAsync (an unknown device path answers MTConnectError under HTTP 200).
The probe's minimal DTO now reads deviceName - the rule is 'which keys compose the
URL', not 'which keys are cheap to read'.

Driver.MTConnect-008 (Low): corrected two stale claims that a since-changed
RawBrowseCommitMapper invalidated (see Driver.MTConnect-003, which remains open and
belongs in AdminUI), and made DiscoverAsync log the options snapshot it
deliberately read once instead of the field.

Tests: 538 pass (534 base + 4 new, each red before its fix); AdminUI.Tests 1466 pass.
This commit is contained in:
Joseph Doherty
2026-08-10 06:51:39 -04:00
parent b990adae55
commit 6c63bf8fab
7 changed files with 239 additions and 33 deletions
+15 -3
View File
@@ -167,9 +167,11 @@ universal browser commits and the key the driver resolves reads/subscribes
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
three spellings in order — `fullName` → `dataItemId` → `address` — and takes three spellings in order — `fullName` → `dataItemId` → `address` — and takes
the first non-blank one, normalising onto `fullName` on save. `address` is the first non-blank one, normalising onto `fullName` on save. `address` is
accepted because that's the field name `RawBrowseCommitMapper` writes for a accepted because that is the field name `RawBrowseCommitMapper` writes for a
driver with no typed editor path, so a browse-committed tag stays readable driver with no typed-editor branch of its own — MTConnect has had such a
even before the editor round-trips it. branch since Gitea #536 and now commits under `fullName`, but blobs committed
before it, and hand-authored/CSV/API blobs, still carry `address` and must
stay readable.
### Coercion-type precedence ### Coercion-type precedence
@@ -192,6 +194,16 @@ into) is resolved in this order, first non-null wins:
(category/type/units/representation → `DriverDataType`). (category/type/units/representation → `DriverDataType`).
4. `DriverDataType.String` — the coercion that cannot fail. 4. `DriverDataType.String` — the coercion that cannot fail.
> ⚠️ **Step 3 does not currently rescue a browse-committed tag.** `RawBrowseCommitMapper`'s
> MTConnect branch emits a whole `MTConnectTagConfigModel`, whose `ToJson()` always writes
> `dataType` from a field defaulting to `String` — a non-blank authored type, so step 1 wins
> and step 3 is never consulted. The raw tag's OPC UA `DataType` meanwhile *is* taken from the
> browsed leaf's inferred type (`RawBrowseCommitMapper.MapLeaf` → `MapDataType`), so a browsed
> `SAMPLE` lands as a `Double` node whose driver coerces the observation to text. Fix belongs in
> the commit mapper (forward the leaf's `DriverDataType` into the model, as the AbCip / TwinCAT /
> FOCAS branches already do). Recorded as `Driver.MTConnect-003` in
> [`code-reviews/Driver.MTConnect/findings.md`](../../code-reviews/Driver.MTConnect/findings.md).
### Quality mapping ### Quality mapping
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to `UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
@@ -29,11 +29,21 @@ public readonly record struct MTConnectInferredType(DriverDataType DataType, boo
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a /// This lives in <c>.Contracts</c> because every call site must agree byte-for-byte, or a
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c> /// tag's OPC UA type stops matching its authored config. At HEAD the callers are both inside
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the /// the driver: <c>ITagDiscovery.DiscoverAsync</c> (stamps
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the /// <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf) and
/// AdminUI typed tag editor (shows the inferred type and offers an override). /// <c>MTConnectDriver.InferDefinition</c> (the runtime coercion fallback for a raw tag whose
/// blob declares no type).
/// </para>
/// <para>
/// ⚠️ <b>Two call sites this remark used to name do not exist.</b> The universal browser's
/// commit path (<c>RawBrowseCommitMapper</c>) never calls this — it re-maps the browsed leaf's
/// already-inferred <c>DriverDataType</c> onto the raw tag's OPC UA type but writes a
/// <c>TagConfig</c> whose <c>dataType</c> is the model default — and the AdminUI typed editor
/// (<c>MTConnectTagConfigModel</c>) deliberately does not recompute the inference at all. The
/// first of those is a live defect, not merely a stale comment: see
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).
/// </para> /// </para>
/// <para> /// <para>
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard /// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
@@ -86,7 +86,11 @@ public sealed class MTConnectDriverOptions
/// type comes from the blob's <c>driverDataType</c>; failing that, from a /// type comes from the blob's <c>driverDataType</c>; failing that, from a
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's /// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing /// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item> /// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.
/// ⚠️ The <c>/probe</c> step is <b>not</b> reached by a browse-committed tag at HEAD:
/// the AdminUI's commit mapper writes a full typed blob whose <c>dataType</c> defaults
/// to <c>String</c>, which the first step then honours. See
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).</item>
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c> /// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes /// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
/// its coercion type to the index.</item> /// its coercion type to the index.</item>
@@ -1224,7 +1224,7 @@ public sealed class MTConnectDriver
// downstream). Its data items are unreachable either way. // downstream). Its data items are unreachable either way.
_logger.LogWarning( _logger.LogWarning(
"MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.", "MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.",
_driverInstanceId, _options.AgentUri); _driverInstanceId, options.AgentUri);
continue; continue;
} }
@@ -1242,7 +1242,7 @@ public sealed class MTConnectDriver
{ {
_logger.LogWarning( _logger.LogWarning(
"MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.", "MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.",
_driverInstanceId, deviceScope, _options.AgentUri, model.Devices.Count); _driverInstanceId, deviceScope, options.AgentUri, model.Devices.Count);
return; return;
} }
@@ -1252,7 +1252,7 @@ public sealed class MTConnectDriver
_driverInstanceId, _driverInstanceId,
devices, devices,
variables, variables,
_options.AgentUri, options.AgentUri,
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')"); deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
} }
@@ -1854,10 +1854,13 @@ public sealed class MTConnectDriver
/// "just re-initialize" would be a silent permanent deadlock. /// "just re-initialize" would be a silent permanent deadlock.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Backoff applies to failures only.</b> A re-baseline is a protocol event, not a /// <b>Backoff applies to failures only — and to a re-baseline that resolved nothing.</b>
/// fault, so it reopens the stream immediately and resets the attempt count; only a /// A re-baseline behind a stream that delivered is a protocol event, not a fault, so it
/// genuine failure (dropped connection, blown heartbeat, a re-baseline that itself /// reopens immediately and resets the attempt count. One on a connection that delivered
/// failed) advances <see cref="BackoffFor"/>. /// nothing advances <see cref="BackoffFor"/> like any other failure, because the
/// <see cref="InvalidDataException"/> path that reaches it also carries conditions a
/// <c>/current</c> cannot resolve (an unparseable chunk, an over-sized part) and would
/// otherwise reopen-and-fail as fast as the Agent can answer.
/// </para> /// </para>
/// </remarks> /// </remarks>
private async Task RunSampleStreamAsync( private async Task RunSampleStreamAsync(
@@ -1927,8 +1930,21 @@ public sealed class MTConnectDriver
return; return;
case StreamVerdict.Reopen: case StreamVerdict.Reopen:
attempt = 0; // A re-baseline behind a stream that DELIVERED is the protocol event this
_reconnectAttempts = 0; // verdict was written for — a sequence gap or an OUT_OF_RANGE rejection, both
// of which the /current genuinely resolves — so it reopens immediately on a
// clean ladder.
//
// One that delivered NOTHING is not necessarily resolved by anything. The
// InvalidDataException catch that routes OUT_OF_RANGE here also catches an
// unparseable chunk and an over-sized part, and a /current cannot fix either:
// resetting the ladder there reopens instantly, fails identically, and spends
// one /sample AND one /current per iteration forever — the unbounded hot loop
// every other arm of this pump (the Unsupported latch, the backoff floor, the
// non-positive cap) exists to prevent. The discriminator is the same one the
// transient arm below uses, for the same reason.
attempt = outcome.DeliveredAny ? 0 : attempt + 1;
_reconnectAttempts = attempt;
break; break;
@@ -3208,10 +3224,22 @@ public sealed class MTConnectDriver
/// <b>Coercion type precedence</b> (see <see cref="MTConnectDriverOptions.RawTags"/>): /// <b>Coercion type precedence</b> (see <see cref="MTConnectDriverOptions.RawTags"/>):
/// the blob's own <c>driverDataType</c>, else a <see cref="MTConnectDriverOptions.Tags"/> /// the blob's own <c>driverDataType</c>, else a <see cref="MTConnectDriverOptions.Tags"/>
/// entry naming the same DataItem id, else the Agent's <c>/probe</c> declaration for that /// entry naming the same DataItem id, else the Agent's <c>/probe</c> declaration for that
/// data item, else <see cref="DriverDataType.String"/>. The third step is what makes a /// data item, else <see cref="DriverDataType.String"/>.
/// browse-committed tag work: <c>RawBrowseCommitMapper</c> writes only the address into /// </para>
/// the blob, so without it every value from the address picker would publish as text /// <para>
/// under a numerically-typed OPC UA node. /// ⚠️ <b>The third step does NOT currently rescue a browse-committed tag, and the
/// AdminUI is where that has to be fixed.</b> It was written on the premise that
/// <c>RawBrowseCommitMapper</c> writes only the address into the blob — true when this
/// was authored, false at HEAD: the MTConnect branch of
/// <c>RawBrowseCommitMapper.BuildTagConfig</c> now emits a whole
/// <c>MTConnectTagConfigModel</c>, whose <c>ToJson</c> unconditionally writes
/// <c>"dataType"</c> from a field defaulting to <see cref="DriverDataType.String"/>. That
/// is a non-blank authored type, so step 1 wins and this step is never reached — every
/// browse-committed tag coerces to text while its raw-tag node was typed <c>Double</c> /
/// <c>Int64</c> from the SAME probe inference (<c>RawBrowseCommitMapper.MapLeaf</c>).
/// The step is kept because it is still the correct rule and still serves a blob authored
/// by hand, by CSV or by API with no type; see
/// <c>code-reviews/Driver.MTConnect/findings.md</c> (Driver.MTConnect-003).
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="options">The options document to derive from.</param> /// <param name="options">The options document to derive from.</param>
@@ -16,19 +16,29 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That /// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
/// method builds the full driver options document, including every authored /// method builds the full driver options document, including every authored
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has /// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses /// nothing to do with reachability). A probe needs only the fields that compose the request
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can /// URL, so this type parses its own minimal DTO — a config that is invalid for the driver (bad
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for /// tag, bad data type) can still be reachability-tested, matching the AdminUI's "does the
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape /// endpoint answer" intent for Test Connect. It also avoids a compile-time dependency on the
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15). /// factory DTO shape (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately
/// (Task 15).
/// </para> /// </para>
/// <para> /// <para>
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a /// <b>The minimal DTO is "everything in the URL", not "agentUri".</b> It reads
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive /// <c>deviceName</c> too, because <see cref="MTConnectAgentClient"/> narrows every read to
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject. /// <c>{AgentUri}/{DeviceName}/probe</c>: probing the agent-wide path instead would answer a
/// This is the direct consequence of the independent-parsing decision above, judged /// different question from the one the operator asked, and a mistyped or since-renamed device
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact /// would show GREEN here and then Fault the driver at <c>InitializeAsync</c> (the Agent
/// config deploy". /// answers an unknown device path with an <c>MTConnectError</c> under HTTP 200). The
/// distinction is not "which keys are cheap to read" but "which keys change the endpoint".
/// </para>
/// <para>
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only the URL-composing fields are
/// read, a config can show green on Test Connect and still fail to deploy — e.g. a
/// non-positive <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse
/// would reject. This is the direct consequence of the independent-parsing decision above,
/// judged acceptable because Test Connect's job is "is the endpoint there", not "will this
/// exact config deploy".
/// </para> /// </para>
/// <para> /// <para>
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and /// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
@@ -117,6 +127,12 @@ public sealed class MTConnectDriverProbe : IDriverProbe
private sealed class MTConnectProbeConfigDto private sealed class MTConnectProbeConfigDto
{ {
public string? AgentUri { get; set; } public string? AgentUri { get; set; }
/// <summary>
/// The optional device scope. Read because it is part of the <b>URL</b>, not part of the
/// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type.
/// </summary>
public string? DeviceName { get; set; }
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -169,6 +185,11 @@ public sealed class MTConnectDriverProbe : IDriverProbe
var options = new MTConnectDriverOptions var options = new MTConnectDriverOptions
{ {
AgentUri = agentUri, AgentUri = agentUri,
// Part of the request URL, so it must be carried: MTConnectAgentClient narrows every read
// to {AgentUri}/{DeviceName}/probe, and a probe of the agent-wide path answers a different
// question from the one the operator asked. See the type remarks.
DeviceName = string.IsNullOrWhiteSpace(dto.DeviceName) ? null : dto.DeviceName.Trim(),
RequestTimeoutMs = requestTimeoutMs, RequestTimeoutMs = requestTimeoutMs,
}; };
@@ -301,6 +301,65 @@ public sealed class MTConnectDriverProbeTests
r.Latency.ShouldBeNull(); r.Latency.ShouldBeNull();
} }
// ── review follow-up: the probe must request the URL the DRIVER will request ──
/// <summary>
/// <b>Test Connect has to probe the driver's own request path, device scope included.</b>
/// <c>MTConnectAgentClient.BuildRequestUri</c> narrows every read to
/// <c>{AgentUri}/{DeviceName}/probe</c>, so a probe that requested the agent-wide
/// <c>{AgentUri}/probe</c> answers a different question from the one the operator asked. A
/// mistyped or since-renamed <c>deviceName</c> then shows GREEN on Test Connect and Faults the
/// driver at <c>InitializeAsync</c> — the Agent answers that path with an <c>MTConnectError</c>
/// under HTTP 200, which the parser surfaces as <see cref="InvalidDataException"/>.
/// <para>
/// This is a different class from the accepted "reachable ≠ deployable" gap the type
/// documents (a bad tag entry, a non-positive timeout): those do not change the URL, and
/// the whole point of a reachability probe is which endpoint it reaches.
/// </para>
/// </summary>
[Fact]
public async Task Configured_deviceName_scopes_the_probe_request_the_way_the_driver_does()
{
Uri? requested = null;
var probe = ProbeWithHandler((request, _) =>
{
requested = request.RequestUri;
return Task.FromResult(XmlResponse(ValidProbeXml));
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\",\"deviceName\":\"VMC-1\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
requested.ShouldNotBeNull();
requested.AbsolutePath.ShouldBe("/VMC-1/probe");
}
/// <summary>
/// …and with no <c>deviceName</c> the probe stays agent-wide, exactly as the driver does. The
/// falsification control for the test above: unconditionally inserting a segment would pass it
/// while breaking every unscoped config.
/// </summary>
[Fact]
public async Task An_absent_deviceName_leaves_the_probe_agent_wide()
{
Uri? requested = null;
var probe = ProbeWithHandler((request, _) =>
{
requested = request.RequestUri;
return Task.FromResult(XmlResponse(ValidProbeXml));
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
requested.ShouldNotBeNull();
requested.AbsolutePath.ShouldBe("/probe");
}
// ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ── // ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
[Fact] [Fact]
@@ -990,6 +990,78 @@ public sealed class MTConnectSubscribeTests
} }
} }
/// <summary>
/// <b>A re-baseline that never resolves anything must not reopen without a backoff.</b>
/// <c>StreamVerdict.Reopen</c> is "a protocol event, not a fault", so it reopens immediately
/// and resets the ladder — correct for the two cases it was written for (a sequence gap and an
/// <c>OUT_OF_RANGE</c> rejection), both of which the <c>/current</c> re-baseline genuinely
/// fixes, so they cannot recur.
/// <para>
/// But the <see cref="InvalidDataException"/> catch that routes <c>OUT_OF_RANGE</c> to the
/// re-baseline also catches an <b>unparseable chunk</b> and an oversized part — conditions a
/// <c>/current</c> cannot fix. Reopening those with the ladder reset is an unbounded hot
/// loop against the Agent (one <c>/sample</c> AND one <c>/current</c> per iteration, as fast
/// as they complete, one Warning each), which is exactly what the reconnect backoff exists
/// to prevent everywhere else in this pump.
/// </para>
/// <para>
/// The discriminator is the one the transient path already uses: did THIS connection deliver
/// a chunk? A re-baseline behind a working stream still reopens instantly; one on a
/// connection that has never delivered advances the ladder. Asserted as monotonic growth,
/// never as a duration.
/// </para>
/// </summary>
[Fact]
public async Task A_rebaseline_that_delivers_nothing_still_advances_the_reconnect_backoff()
{
// A 1 ms cap keeps the failing loop from actually sleeping; nothing here asserts a duration.
var options = Opts(new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 1 });
var (driver, client) = await InitializedDriverAsync(options: options);
// Sticky, and NOT a transient stream fault: a chunk this driver can never parse. /current keeps
// answering, so every re-baseline "succeeds" and the pump reopens — forever.
client.SampleFailure = new InvalidDataException("chunk is not a well-formed MTConnectStreams document");
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
try
{
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
var early = driver.ReconnectAttempts;
await client.WaitForSampleCallsAsync(6).WaitAsync(Watchdog, Ct);
early.ShouldBeGreaterThanOrEqualTo(2);
driver.ReconnectAttempts.ShouldBeGreaterThan(early);
}
finally
{
await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct);
}
}
/// <summary>
/// …and the falsification control for the test above: a re-baseline BEHIND a stream that was
/// delivering keeps its immediate reopen. Without this, "advance the ladder on every re-baseline"
/// would pass the test above while making a healthy Agent's ordinary buffer roll cost a backoff.
/// </summary>
[Fact]
public async Task A_rebaseline_after_a_delivered_chunk_still_reopens_immediately()
{
var (driver, client) = await InitializedDriverAsync();
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
// One good chunk, so this connection has provably delivered…
await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct);
// …then the Agent's buffer rolls past the cursor. The re-baseline is a protocol event behind a
// working stream: no backoff, no ladder.
client.CurrentAnswers.Enqueue(Chunk(5000, 5005, ("dev1_pos", "2.5")));
await client.PumpAsync(Chunk(5000, 5005, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct);
driver.ReconnectAttempts.ShouldBe(0);
}
/// <summary> /// <summary>
/// <b>I3 — a non-positive cap is an operator-authorable hot loop.</b> Clamping to it (the /// <b>I3 — a non-positive cap is an operator-authorable hot loop.</b> Clamping to it (the
/// obvious <c>Math.Max(0, …)</c>) makes EVERY delay zero, so the pump reconnect-spins as fast /// obvious <c>Math.Max(0, …)</c>) makes EVERY delay zero, so the pump reconnect-spins as fast