Files
T
Joseph Doherty 75be6fe12b fix(runtime): derive the write cap from the configured write timeout (lmxopcua#774)
DriverInstanceActor.RunWriteAsync cancelled every write with a hard-coded,
undilated 5 s CancellationTokenSource, so writeTimeout / DefaultWriteTimeout
(then 10 s) only ever served as the pessimistic abandon bound: a configured
bound above 5 s never took effect.

The write's token is now cancelled at the configured write timeout; a write
that ignores its token is abandoned WriteAbandonGrace later (half the
timeout, at most 2 s) so a token-honouring driver still gets to answer the
cancellation itself. Both bounds run on an injectable TimeProvider
(Props writeClock, System by default).

DefaultWriteTimeout is now 5 s (cancel 5 s, abandon 7 s), not 10 s: the
host's write Ask is 8 s, and a write allowed to run past it would already
have been reported as a timeout and its node reverted while the device could
still take the value - the exact failure the off-mailbox write work fixed.
Production behaviour is therefore unchanged for the cancel (5 s) and tighter
for a token-ignoring driver (abandoned at 7 s instead of 10 s, i.e. inside
the Ask). DriverHostActor's Ask comment updated to match.

Tests (DriverInstanceActorConcurrentWriteTests, manual clock copied from
Ab.Transport.Tests into Runtime.Tests/Harness/ManualTimeProvider):
- a write held past 6 s under a 10 s write timeout succeeds;
- positive control: at 10 s the token is cancelled ("write timeout");
- a token-ignoring write is still pending at 10 s and abandoned (BadTimeout)
  after the grace.
Mutation checks: a fixed 5 s token on the same clock fails the first test;
dropping the grace fails the third (and the second, where the abandon then
races the cancellation).

342a825d re-check: its held-write tests were written around the undilated
5 s cap. Connect() now defaults the write timeout to the DILATED presence
budget, so a held, explicitly-released write no longer races any undilated
deadline; tests about the bound pass their own. AwaitWritesEntered keeps its
20 ms interval (doc updated).

Runtime.Tests: 946 passed / 3 skipped / 0 failed at AKKA_TEST_TIMEFACTOR 3
and 1. Solution Release build: 0 warnings.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:29:33 -05:00

107 lines
3.9 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// <summary>
/// Deterministic <see cref="TimeProvider"/>: time moves only on <see cref="Advance"/>, which fires every
/// timer falling due on the way, in due order, synchronously on the calling thread.
/// (Microsoft.Extensions.TimeProvider.Testing is not pinned in this repo; a copy of the Ab.Transport.Tests fake.)
/// </summary>
internal sealed class ManualTimeProvider : TimeProvider
{
private readonly object _lock = new();
private readonly List<ManualTimer> _timers = [];
private DateTimeOffset _now;
public ManualTimeProvider() => Start = _now = new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero);
public DateTimeOffset Start { get; }
/// <summary>Steps the wall clock (<see cref="GetUtcNow"/>) without moving monotonic time
/// (<see cref="GetTimestamp"/>) or any timer, as an NTP or operator clock change does.</summary>
public TimeSpan WallClockOffset { get; set; }
public override DateTimeOffset GetUtcNow() { lock (_lock) return _now + WallClockOffset; }
public override long GetTimestamp() { lock (_lock) return _now.UtcTicks; }
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var t = new ManualTimer(this, callback, state);
lock (_lock) _timers.Add(t);
t.Change(dueTime, period);
return t;
}
/// <summary>Armed timers (not disposed, with a due time).</summary>
public int ArmedTimers { get { lock (_lock) return _timers.Count(t => t.Due is not null); } }
public bool HasTimerDueAt(DateTimeOffset at) { lock (_lock) return _timers.Any(t => t.Due == at); }
/// <summary>Waits (in real time) until some code under test has armed a timer due at <paramref name="at"/>,
/// i.e. is genuinely parked on the fake clock rather than merely not yet scheduled.</summary>
public async Task WaitForTimerDueAtAsync(DateTimeOffset at, TimeSpan timeout)
{
var giveUp = DateTime.UtcNow + timeout;
while (!HasTimerDueAt(at))
{
if (DateTime.UtcNow > giveUp) throw new TimeoutException($"No timer armed for {at:O} (now {GetUtcNow():O}).");
await Task.Delay(5);
}
}
public void Advance(TimeSpan by)
{
DateTimeOffset target;
lock (_lock) target = _now + by;
while (true)
{
ManualTimer? next;
lock (_lock)
{
next = _timers.Where(t => t.Due <= target).MinBy(t => t.Due);
if (next is null) { _now = target; return; }
_now = next.Due!.Value;
next.Due = next.Period is { } p ? _now + p : null;
}
next.Fire();
}
}
private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer
{
public DateTimeOffset? Due; // guarded by owner._lock
public TimeSpan? Period; // guarded by owner._lock
private bool _disposed;
public void Fire() => callback(state);
public bool Change(TimeSpan dueTime, TimeSpan period)
{
lock (owner._lock)
{
if (_disposed) return false;
Due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime;
Period = period == Timeout.InfiniteTimeSpan || period == TimeSpan.Zero ? null : period;
return true;
}
}
public void Dispose()
{
lock (owner._lock)
{
_disposed = true;
Due = null;
owner._timers.Remove(this);
}
}
public ValueTask DisposeAsync()
{
Dispose();
return ValueTask.CompletedTask;
}
}
}