3edff3304b
- 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
55 lines
2.3 KiB
C#
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));
|
|
}
|