diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 43afe57f..8e19daed 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// handles GC-collected tags.
///
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
- IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable
+ IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable, IDisposable
{
/// Authored tags this driver could not build into its poll table.
private readonly SkippedTagLedger _skippedTags = new();
@@ -2268,8 +2268,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Asynchronously releases all resources used by the driver — the same complete teardown as
/// . Idempotent, and safe CONCURRENTLY with a still-running
/// (the runtime's final stop abandons a shutdown that overruns its budget and
- /// disposes anyway): each teardown retires a disjoint device table. There is deliberately no synchronous
- /// Dispose() — it could only block on this teardown (sync-over-async).
+ /// disposes anyway): each teardown retires a disjoint device table. This is the dispose the runtime calls
+ /// (it prefers ).
///
/// A value task representing the asynchronous disposal.
public async ValueTask DisposeAsync()
@@ -2277,6 +2277,22 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
}
+ ///
+ /// Starts the same teardown as WITHOUT blocking the calling thread on it (review
+ /// 07 F10: the old DisposeAsync().AsTask().GetAwaiter().GetResult() parked the caller — a dispatcher
+ /// thread — for up to the probe join, with the usual sync-over-async deadlock exposure). The teardown runs to
+ /// completion in the background and any fault is logged. Callers that need completion use
+ /// ; kept only for synchronous using / Dispose() call sites.
+ ///
+ public void Dispose()
+ {
+ var teardown = DisposeAsync().AsTask();
+ if (teardown.IsCompleted) { teardown.GetAwaiter().GetResult(); return; }
+ _ = teardown.ContinueWith(
+ t => _logger.LogWarning(t.Exception, "AbCip driver {DriverInstanceId}: background dispose failed", _driverInstanceId),
+ CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
+ }
+
///
/// Per-device runtime state. Holds the parsed host address, family profile, and the
/// live libplctag.NET instances keyed by tag name.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs
index eea409d2..2b009a7f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs
@@ -175,12 +175,10 @@ public sealed class AbCipLifecycleConcurrencyTests
created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown");
}
- /// F10: the driver exposes only an async dispose; a sync Dispose() that blocked on the async
- /// teardown (sync-over-async) is gone, and the runtime takes the path.
+ /// F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).
[Fact]
- public async Task Driver_is_async_disposable_only_and_DisposeAsync_stops_the_probe()
+ public async Task DisposeAsync_is_a_complete_idempotent_teardown()
{
- typeof(IDisposable).IsAssignableFrom(typeof(AbCipDriver)).ShouldBeFalse();
var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
@@ -192,4 +190,27 @@ public sealed class AbCipLifecycleConcurrencyTests
loop.IsCompleted.ShouldBeTrue();
factory.Tags["ProbeTag"].Disposed.ShouldBeTrue();
}
+
+ ///
+ /// F10: the sync Dispose() no longer blocks the caller's thread on the async teardown (sync-over-async —
+ /// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still
+ /// completes.
+ ///
+ [Fact]
+ public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
+ {
+ var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(500));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
+ var loop = drv.GetDeviceState(Host)!.ProbeTask!;
+ await Task.Delay(20); // the probe is inside its slow, cancellation-deaf read
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ ((IDisposable)drv).Dispose();
+ sw.Stop();
+
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250)); // did not wait out the 500 ms probe read
+ await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed");
+ drv.GetDeviceState(Host).ShouldBeNull();
+ }
}