fix(modbus): snapshot probe/reprobe CancellationToken value before scheduling (#729)

The probe and re-probe worker delegates closed over the CTS and read cts.Token
when they ran. Task.Run's scheduling token only suppresses a still-queued task;
a worker the pool had committed to but not yet invoked could outlive the bounded
join in StopBackgroundLoopsAsync, after which the CTS was disposed and the
delayed cts.Token read threw ObjectDisposedException (faulting the loop task).

Snapshot the token value before Task.Run and close over it; a token value stays
valid after its source is disposed and still reports cancellation, so a late
worker exits cleanly on its own generation's cancelled token. Document the
bounded-join / transport-lifetime limits on StopBackgroundLoopsAsync.

Regression tests park the worker deterministically in TPL's synchronous
TaskStarted event (after the start check, before the delegate runs) via an
EventListener correlated to the one TaskScheduled on the test's armed flow:
shutdown (probe + reprobe) and replacement-generation identity. All three fail
with ObjectDisposedException without the fix.
This commit is contained in:
Joseph Doherty
2026-11-09 12:04:53 -05:00
parent dc0a24f224
commit 66867ca8f6
2 changed files with 306 additions and 2 deletions
@@ -562,19 +562,31 @@ public sealed class ModbusDriver
// Running transition before any register round-trip has happened. Started inside the
// generation-checked publish so a teardown that bypassed the lifecycle gate can never
// run between the publish and the loop start and strand a loop.
//
// #729: each loop's CancellationToken VALUE is snapshotted here, before scheduling, and the
// worker delegate closes over that value — never over the CTS (or the mutable _probeCts /
// _reprobeCts fields). The scheduling token only suppresses a still-queued task; once the
// pool has committed to running the delegate, teardown can complete underneath it (the
// bounded join in StopBackgroundLoopsAsync gives up and disposes the CTS). A delegate that
// then read cts.Token would throw ObjectDisposedException; one that read the field would
// see null or adopt the NEXT initialization's token. A token value stays valid after its
// source is disposed and still reports the cancellation, so the late worker exits cleanly
// on its own generation's cancelled token.
if (_options.Probe.Enabled)
{
var cts = new CancellationTokenSource();
var probeToken = cts.Token;
var probeTransport = transport;
_probeCts = cts;
_probeTask = Task.Run(() => ProbeLoopAsync(generation, probeTransport, cts.Token), cts.Token);
_probeTask = Task.Run(() => ProbeLoopAsync(generation, probeTransport, probeToken), probeToken);
}
if (_options.AutoProhibitReprobeInterval is not null)
{
var cts = new CancellationTokenSource();
var reprobeToken = cts.Token;
_reprobeCts = cts;
_reprobeTask = Task.Run(() => ReprobeLoopAsync(cts.Token), cts.Token);
_reprobeTask = Task.Run(() => ReprobeLoopAsync(reprobeToken), reprobeToken);
}
}
transport = null; // published — owned by the driver now
@@ -592,6 +604,16 @@ public sealed class ModbusDriver
}
/// <summary>Cancels, bounded-joins and disposes the probe and re-probe loops. Idempotent.</summary>
/// <remarks>
/// Limits (#729): the join is bounded by <see cref="BackgroundLoopJoinTimeout"/>, so this does NOT
/// guarantee a loop has finished when it returns — a loop stuck in a transport call that ignores its
/// token, or a worker the pool had committed to but not yet invoked, is abandoned, not awaited. Such
/// a worker holds its own already-cancelled token value (never the CTS or the fields), so it exits on
/// its first token check without faulting. Transport lifetime is NOT bound to the loops: the caller
/// disposes the transport after this returns, so a probe send that ignores its token and outlives the
/// join completes (or faults) against a disposed transport; the loop then sees its cancelled token and
/// returns without a health transition.
/// </remarks>
private async Task StopBackgroundLoopsAsync()
{
var probeCts = _probeCts;