Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/UnobservedTaskProbe.cs
T
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00

55 lines
2.3 KiB
C#

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
/// <summary>
/// Collects <see cref="TaskScheduler.UnobservedTaskException"/>s raised after some code abandons work, with a
/// positive control (Gitea #583): after the abandon, a task is deliberately faulted and dropped, and garbage is
/// collected until THAT task's exception has been reported. Only then is the collection returned — so "nothing
/// of ours was reported" means the finalizer pass that would have reported it really ran, not merely that a
/// fixed number of GC cycles went by before the runtime got round to it.
/// </summary>
internal static class UnobservedTaskProbe
{
public static async Task<IReadOnlyList<Exception>> CollectAsync(Func<Task> abandon, TimeSpan timeout)
{
var marker = $"unobserved-task-probe-control-{Guid.NewGuid():N}";
var seen = new ConcurrentQueue<Exception>();
var controlReported = 0;
EventHandler<UnobservedTaskExceptionEventArgs> handler = (_, e) =>
{
foreach (var ex in e.Exception.Flatten().InnerExceptions)
{
if (ex.Message == marker) Volatile.Write(ref controlReported, 1);
else seen.Enqueue(ex);
}
};
TaskScheduler.UnobservedTaskException += handler;
try
{
await abandon();
AbandonControl(marker);
var giveUp = DateTime.UtcNow + timeout;
while (Volatile.Read(ref controlReported) == 0)
{
if (DateTime.UtcNow > giveUp)
throw new TimeoutException("The positive control's unobserved exception was never reported.");
GC.Collect();
GC.WaitForPendingFinalizers();
await Task.Delay(10);
}
// One more full pass, so anything that became garbage in the same cycle as the control is reported too.
GC.Collect();
GC.WaitForPendingFinalizers();
}
finally
{
TaskScheduler.UnobservedTaskException -= handler;
}
return [.. seen];
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void AbandonControl(string marker) => _ = Task.FromException(new InvalidOperationException(marker));
}