using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Task 13 — 's two optional capabilities: /// (a periodic reachability tick that flips one host /// Running ↔ Stopped) and (the Agent instanceId watch that /// is the ONLY thing making safe). /// /// /// /// Nothing here waits on wall-clock time, and the probe is a timer. The driver takes /// its inter-tick delay through an injected scheduler seam, and this suite supplies /// : the loop parks in the seam, the test releases exactly one /// tick, and the ticker's next park is the proof that the probe which followed has already /// completed. No test sleeps, no test polls a counter, and /// stays timer-free. /// /// /// The load-bearing tests are the anti-inertness ones. Both capabilities have a /// plausible-looking implementation that compiles, ships, and does nothing: /// a connectivity probe routed through the driver's cached /probe accessor never /// touches the network once the cache is warm (so it reports Running forever, whatever the /// Agent is doing), and a rediscovery signal raised without dropping that same cache makes /// Core re-run discovery and receive the identical stale device tree. Both are pinned here /// by asserting the Agent was actually called. /// /// public sealed class MTConnectHostAndRediscoverTests { private const string AgentUri = "http://fixture-agent:5000"; /// The instanceId every canned fixture shares. private const long FixtureInstanceId = 1655000000L; /// A DIFFERENT instanceId — the Agent came back as a new process. private const long RestartedInstanceId = 1655999999L; /// A third instanceId — the Agent restarted twice. private const long SecondRestartInstanceId = 1656111111L; /// The cursor Fixtures/current.xml primes the pump with. private const long PrimedNextSequence = 108L; /// /// Failure guard for the awaits a defect could turn into a permanent hang (a probe loop that /// never parks, a teardown that deadlocks on the lifecycle semaphore). It exists to make a /// hang red, not to assert a duration — no assertion is ever made about elapsed time. /// private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10); private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc); /// The interval the probe tests author, asserted to reach the scheduler verbatim. private static readonly TimeSpan AuthoredInterval = TimeSpan.FromSeconds(7); private static CancellationToken Ct => TestContext.Current.CancellationToken; // ---- fixtures ---- /// /// Options with the background probe disabled — the default for the rediscovery half /// of this suite, so no probe loop can add calls behind a ProbeCallCount assertion. /// private static MTConnectDriverOptions Opts(MTConnectProbeOptions? probe = null) => new() { AgentUri = AgentUri, RequestTimeoutMs = 5000, Probe = probe ?? new MTConnectProbeOptions { Enabled = false }, Tags = [ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), new MTConnectTagDefinition("dev1_execution", DriverDataType.String), ], }; private static MTConnectProbeOptions Probing(bool enabled = true) => new() { Enabled = enabled, Interval = AuthoredInterval, Timeout = TimeSpan.FromSeconds(2) }; private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync( CannedAgentClient? client = null, MTConnectDriverOptions? options = null, ManualTicker? ticker = null) { var agent = client ?? CannedAgentClient.FromFixtures(); var driver = new MTConnectDriver( options ?? Opts(), "mt1", _ => agent, probeScheduler: ticker is null ? null : ticker.WaitAsync); await driver.InitializeAsync("{}", Ct); return (driver, agent); } /// /// Brings a driver up with the probe loop wired to a manual ticker, and returns once the loop /// is demonstrably parked in its first inter-tick wait (i.e. it has run zero probes). /// private static async Task<(MTConnectDriver Driver, CannedAgentClient Client, ManualTicker Ticker)> ProbingDriverAsync(CannedAgentClient? client = null) { var ticker = new ManualTicker(); var (driver, agent) = await InitializedDriverAsync(client, Opts(Probing()), ticker); await ticker.ParkedAsync().WaitAsync(Watchdog, Ct); return (driver, agent, ticker); } /// /// Releases exactly one probe tick and returns once the probe it triggered has completed — /// proven by the loop parking again, not by a delay. /// private static async Task TickAsync(ManualTicker ticker) { ticker.Tick(); await ticker.ParkedAsync().WaitAsync(Watchdog, Ct); } /// /// Teardown symmetry with the /sample pump (remediation I1). The probe loop is /// the second long-lived background task in this driver, and it is stopped the same way and /// from the same place: while the lifecycle semaphore is held, on a path /// DriverInstanceActor.PostStop blocks an Akka dispatcher thread on. So its wait is /// bounded and honours the caller's token too — a blocking /// handler must not be able to wedge /// shutdown, any more than a blocking data-change handler can. /// /// /// Its blast radius is genuinely smaller than the pump's (each iteration is bounded by the /// validated-positive Probe.Timeout, where the pump's is an unbounded long poll), but /// leaving the two divergent would make the un-bounded one the pattern a future reader /// copies — the defect class would be closed in one place and re-opened in the other, in the /// same file. /// [Fact] public async Task Shutdown_honours_the_callers_deadline_when_a_host_status_subscriber_blocks() { var (driver, _, ticker) = await ProbingDriverAsync(); var blocking = new ManualResetEventSlim(false); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, _) => { entered.TrySetResult(); blocking.Wait(); }; try { // The first successful probe transitions Unknown -> Running and raises the event, which // now blocks inside the loop. ticker.Tick(); await entered.Task.WaitAsync(Watchdog, Ct); using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); await driver.ShutdownAsync(deadline.Token).WaitAsync(Watchdog, Ct); driver.ProbeLoopTask.ShouldBeNull(); } finally { blocking.Set(); } } private static ConcurrentQueue RecordRediscovery(MTConnectDriver driver) { var seen = new ConcurrentQueue(); ((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => seen.Enqueue(e); return seen; } private static ConcurrentQueue RecordHostStatus(MTConnectDriver driver) { var seen = new ConcurrentQueue(); ((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, e) => seen.Enqueue(e); return seen; } private static MTConnectStreamsResult ChunkFrom( long instanceId, long firstSequence, long nextSequence, params (string Id, string Value)[] observations) => new( instanceId, nextSequence, firstSequence, [.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]); /// A device model that is recognisably NOT the canned fixture's. private static MTConnectProbeModel OtherModel() => new([ new MTConnectDevice( "other-device", "other-device", [], [new MTConnectDataItem("other_item", "other_item", "EVENT", "AVAILABILITY", null, null, null, null)]), ]); // ---- IRediscoverable: the instanceId watch ---- /// /// The plan's TDD case. is /// , so the runtime runs exactly one discovery /// pass per connect — an Agent that restarts and renumbers (or adds) data items would leave a /// stale address space behind a Healthy driver. This event is the only thing that makes that /// policy safe. /// [Fact] public async Task InstanceId_change_raises_rediscovery() { var (driver, client) = await InitializedDriverAsync(); var seen = RecordRediscovery(driver); await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5"))); await client .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5"))) .WaitAsync(Watchdog, Ct); var raised = seen.ShouldHaveSingleItem(); raised.Reason.ShouldBe("MTConnect agent instanceId changed"); raised.ScopeHint.ShouldBeNull(); } /// /// Once per change, not once per chunk. The chunks that follow a restart all carry the /// new instanceId; a driver that compared each chunk against the id it was /// initialized with — or that simply re-raised on every chunk — would ask Core to /// rebuild the whole address space on every heartbeat, forever. /// [Fact] public async Task InstanceId_change_raises_rediscovery_once_per_change_not_once_per_chunk() { var (driver, client) = await InitializedDriverAsync(); var seen = RecordRediscovery(driver); await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5"))); await client .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5"))) .WaitAsync(Watchdog, Ct); seen.Count.ShouldBe(1); // Two more chunks from the SAME restarted Agent, contiguous with the re-baseline. await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "10.5"))).WaitAsync(Watchdog, Ct); await client.PumpAsync(ChunkFrom(RestartedInstanceId, 45, 48, ("dev1_pos", "11.5"))).WaitAsync(Watchdog, Ct); seen.Count.ShouldBe(1); // …but a SECOND restart is a second change, and must be announced. client.CurrentAnswers.Enqueue(ChunkFrom(SecondRestartInstanceId, 10, 12, ("dev1_pos", "1.5"))); await client .PumpAsync(ChunkFrom(SecondRestartInstanceId, 6000, 6005, ("dev1_pos", "2.5"))) .WaitAsync(Watchdog, Ct); seen.Count.ShouldBe(2); } /// /// An unchanged instanceId is the overwhelmingly common case — every ordinary chunk, /// every heartbeat, and every ring-buffer re-baseline of a still-running Agent. None of them /// may cost an address-space rebuild. /// [Fact] public async Task Unchanged_instanceId_raises_no_rediscovery() { var (driver, client) = await InitializedDriverAsync(); var seen = RecordRediscovery(driver); await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); await client .PumpAsync(ChunkFrom(FixtureInstanceId, PrimedNextSequence, 113, ("dev1_pos", "1.5"))) .WaitAsync(Watchdog, Ct); // A heartbeat… await client.PumpAsync(ChunkFrom(FixtureInstanceId, 1, 120)).WaitAsync(Watchdog, Ct); // …and a ring-buffer gap, which re-baselines but is NOT a restart. client.CurrentAnswers.Enqueue(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5"))); await client .PumpAsync(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5"))) .WaitAsync(Watchdog, Ct); seen.ShouldBeEmpty(); } /// /// The anti-inertness pin for . A restart invalidates the /// cached /probe device model — it describes a process that no longer exists. Raising /// the event while keeping that cache would have Core dutifully re-run /// and receive the identical stale tree /// from GetOrFetchProbeModelAsync's warm cache: a feature that fires, logs, and /// changes nothing. /// [Fact] public async Task InstanceId_change_drops_the_cached_probe_model_so_rediscovery_sees_the_new_one() { var (driver, client) = await InitializedDriverAsync(); await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); driver.CachedProbeModel.ShouldNotBeNull(); // The restarted Agent declares a different device model. client.Probe = OtherModel(); client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5"))); await client .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5"))) .WaitAsync(Watchdog, Ct); driver.CachedProbeModel.ShouldBeNull(); var probeCalls = client.ProbeCallCount; var builder = new CapturingBuilder(); await driver.DiscoverAsync(builder, Ct); // Discovery went back to the Agent, and streamed the NEW model rather than the old one. client.ProbeCallCount.ShouldBe(probeCalls + 1); builder.Variables.Select(v => v.Attr.FullName).ShouldBe(["other_item"]); } /// /// A rediscovery handler is arbitrary caller code. One that throws is a bug in the consumer, /// not a reason to stop streaming data to every subscriber on this driver. /// [Fact] public async Task A_throwing_rediscovery_handler_does_not_kill_the_pump() { var (driver, client) = await InitializedDriverAsync(); var seen = new ConcurrentQueue(); driver.OnDataChange += (_, e) => seen.Enqueue(e); ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => throw new InvalidOperationException("rediscovery subscriber blew up"); await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5"))); await client .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5"))) .WaitAsync(Watchdog, Ct); // The pump survived the throw, re-baselined, and keeps delivering. await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "12.5"))).WaitAsync(Watchdog, Ct); driver.SampleStreamTask!.IsCompleted.ShouldBeFalse(); seen.Where(e => e.FullReference == "dev1_pos").Last().Snapshot.Value.ShouldBe(12.5d); } // ---- IHostConnectivityProbe ---- /// /// One host per Agent, named by the authored AgentUri — that is the identity the Admin /// dashboard shows and the key Core scopes its Bad-quality fan-out by. Before the first tick /// the honest answer is : the probe loop is the sole writer of /// this field, and it has not run yet. /// [Fact] public async Task Host_statuses_report_one_host_named_by_the_agent_uri() { var (driver, _, _) = await ProbingDriverAsync(); driver.HostName.ShouldBe(AgentUri); var status = ((IHostConnectivityProbe)driver).GetHostStatuses().ShouldHaveSingleItem(); status.HostName.ShouldBe(AgentUri); status.State.ShouldBe(HostState.Unknown); } /// /// The anti-inertness pin for . Every tick must /// reach the Agent. A probe routed through the driver's cached-model accessor /// (GetOrFetchProbeModelAsync) compiles, ships, and — with a cache warmed by /// initialize — never issues a single request, reporting Running forever regardless of what /// the Agent is doing. The call count is the only thing that can tell the two apart. /// [Fact] public async Task Probe_loop_calls_the_agent_on_every_tick() { var (_, client, ticker) = await ProbingDriverAsync(); // Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is // the only call so far. client.ProbeCallCount.ShouldBe(1); await TickAsync(ticker); client.ProbeCallCount.ShouldBe(2); await TickAsync(ticker); client.ProbeCallCount.ShouldBe(3); await TickAsync(ticker); client.ProbeCallCount.ShouldBe(4); // …and it waits the authored interval between them, rather than a hard-coded cadence. ticker.LastInterval.ShouldBe(AuthoredInterval); } /// /// Running ↔ Stopped, and the event fires on the transition only. A driver that raised /// on every tick would emit one event per interval per Agent forever; Core would re-fan Bad /// quality across the host's subtree each time, and an operator watching the feed could not /// tell a new outage from an ongoing one. /// [Fact] public async Task Probe_flips_the_host_on_transition_only() { var (driver, client, ticker) = await ProbingDriverAsync(); var seen = RecordHostStatus(driver); client.ProbeFailure = new HttpRequestException("agent unreachable"); await TickAsync(ticker); await TickAsync(ticker); var down = seen.ShouldHaveSingleItem(); down.HostName.ShouldBe(AgentUri); down.OldState.ShouldBe(HostState.Unknown); down.NewState.ShouldBe(HostState.Stopped); ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); client.ProbeFailure = null; await TickAsync(ticker); await TickAsync(ticker); seen.Count.ShouldBe(2); var up = seen.Last(); up.OldState.ShouldBe(HostState.Stopped); up.NewState.ShouldBe(HostState.Running); ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running); } /// /// The probe is a liveness check, never a cache writer. Publishing its answer into /// AgentSession.ProbeModel would put a second writer on the field the lifecycle owns /// under a compare-and-swap, and would silently re-warm a cache that /// FlushOptionalCachesAsync (or an Agent restart) deliberately dropped. /// [Fact] public async Task Probe_ticks_never_publish_into_the_session_probe_cache() { var (driver, client, ticker) = await ProbingDriverAsync(); var original = driver.CachedProbeModel.ShouldNotBeNull(); // The Agent now answers /probe with a different model. Nothing the connectivity probe does // may let that model reach the driver's cache. client.Probe = OtherModel(); await TickAsync(ticker); await TickAsync(ticker); driver.CachedProbeModel.ShouldBeSameAs(original); driver.GetMemoryFootprint().ShouldBeGreaterThan(0); // …and a flushed cache stays flushed until a real consumer asks for it. await driver.FlushOptionalCachesAsync(Ct); await TickAsync(ticker); driver.CachedProbeModel.ShouldBeNull(); } /// /// Host connectivity is deliberately NOT an input to DriverHealth. The two real /// data paths (/current reads, the /sample pump) own health and will report the /// same outage with the failure that actually mattered. The probe's deadline /// (Probe.Timeout, 2 s) is far tighter than RequestTimeoutMs and its request is /// the largest document the Agent serves, so letting it degrade the driver would flap /// a perfectly working instance on a slow-but-alive Agent. /// [Fact] public async Task An_unreachable_host_does_not_degrade_driver_health() { var (driver, client, ticker) = await ProbingDriverAsync(); client.ProbeFailure = new HttpRequestException("agent unreachable"); await TickAsync(ticker); ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); driver.GetHealth().State.ShouldBe(DriverState.Healthy); // …and the converse: a reachable host does not clear a genuinely failing read path either. client.ProbeFailure = null; client.CurrentFailure = new HttpRequestException("connection refused"); await driver.ReadAsync(["dev1_pos"], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); await TickAsync(ticker); ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running); driver.GetHealth().State.ShouldBe(DriverState.Degraded); } /// /// Probe.Enabled = false means no loop, no scheduler wait, and not one extra request. /// An operator who turned probing off must not still be paying for it. /// [Fact] public async Task Probe_disabled_starts_no_loop_and_makes_no_calls() { var ticker = new ManualTicker(); var (driver, client) = await InitializedDriverAsync(options: Opts(Probing(enabled: false)), ticker: ticker); driver.ProbeLoopTask.ShouldBeNull(); ticker.Waits.ShouldBe(0); client.ProbeCallCount.ShouldBe(1); // the initialize prime, and nothing else // The capability is still implemented — it just has nothing to report. ((IHostConnectivityProbe)driver).GetHostStatuses() .ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown); } /// /// Teardown stops the timer and waits for it. A loop left running would keep dialling an /// Agent through a client its owner has already disposed — the "torn down but still /// reporting" shape the rest of this driver's lifecycle is written to prevent. /// [Fact] public async Task Shutdown_stops_the_probe_loop_and_makes_no_further_calls() { var (driver, client, ticker) = await ProbingDriverAsync(); await TickAsync(ticker); var loop = driver.ProbeLoopTask.ShouldNotBeNull(); await driver.ShutdownAsync(Ct).WaitAsync(Watchdog, Ct); loop.IsCompleted.ShouldBeTrue(); loop.IsFaulted.ShouldBeFalse(); driver.ProbeLoopTask.ShouldBeNull(); client.IsDisposed.ShouldBeTrue(); // The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach // nobody — no probe against a disposed client, and no further waits on the scheduler. var calls = client.ProbeCallCount; var waits = ticker.Waits; ticker.Tick(); client.ProbeCallCount.ShouldBe(calls); ticker.Waits.ShouldBe(waits); } /// /// A re-initialize replaces the client, so it must replace the loop that dials it — the old /// one holds a reference to a client the re-initialize may be about to dispose. /// [Fact] public async Task Reinitialize_replaces_the_probe_loop() { var (driver, _, ticker) = await ProbingDriverAsync(); var first = driver.ProbeLoopTask.ShouldNotBeNull(); await driver.ReinitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct); first.IsCompleted.ShouldBeTrue(); var second = driver.ProbeLoopTask.ShouldNotBeNull(); second.ShouldNotBeSameAs(first); // The replacement loop is live: it parks, ticks, and probes like the first. await ticker.ParkedAsync().WaitAsync(Watchdog, Ct); await TickAsync(ticker); } // ---- operator-authorable zeroes (arch-review 01/S-6) ---- /// /// arch-review 01/S-6, applied to the two knobs Task 13 is the first consumer of. A /// 0 interval turns the "periodic" probe into an unthrottled hot loop against the /// Agent; a 0 timeout cancels every probe before it can be answered, pinning the host /// to Stopped forever and reporting an outage that does not exist. Both are refused with the /// same RequirePositive posture as the four timing knobs Task 9 guarded. /// [Theory] [InlineData(0, 2000)] [InlineData(-1, 2000)] [InlineData(5000, 0)] [InlineData(5000, -1)] public async Task A_non_positive_probe_interval_or_timeout_is_refused(int intervalMs, int timeoutMs) { var options = Opts(new MTConnectProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(intervalMs), Timeout = TimeSpan.FromMilliseconds(timeoutMs), }); var driver = new MTConnectDriver(options, "mt1", _ => CannedAgentClient.FromFixtures()); await Should.ThrowAsync(() => driver.InitializeAsync("{}", Ct)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } /// /// …but only when probing is on. Faulting a deployment over a field the driver never reads /// would be a new way for a deploy to fail with nothing gained; flipping /// Enabled back on is a config edit that re-runs the same validation. /// [Fact] public async Task A_non_positive_probe_interval_is_ignored_when_probing_is_disabled() { var options = Opts(new MTConnectProbeOptions { Enabled = false, Interval = TimeSpan.Zero, Timeout = TimeSpan.Zero, }); var (driver, _) = await InitializedDriverAsync(options: options); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.ProbeLoopTask.ShouldBeNull(); } /// /// A hand-driven replacement for : the /// probe loop parks here, and a test releases exactly one tick at a time. The park signal is /// what makes the suite deterministic — when the loop is parked again, the probe that /// followed the previous release has demonstrably finished. /// /// /// installs the next park signal before releasing the /// current wait, so a loop that races ahead and parks again immediately cannot lose the /// signal a test is about to await. /// private sealed class ManualTicker { private readonly Lock _gate = new(); private TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously); private TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _waits; /// How many times the loop has parked in this scheduler. public int Waits => Volatile.Read(ref _waits); /// The interval the loop most recently asked to wait for. public TimeSpan LastInterval { get; private set; } /// The scheduler seam handed to the driver. public async Task WaitAsync(TimeSpan interval, CancellationToken ct) { TaskCompletionSource release; lock (_gate) { LastInterval = interval; Interlocked.Increment(ref _waits); release = _release; _parked.TrySetResult(); } await release.Task.WaitAsync(ct).ConfigureAwait(false); } /// Completes once the loop is parked in the current wait. public Task ParkedAsync() { lock (_gate) { return _parked.Task; } } /// Releases the current wait so exactly one probe runs. public void Tick() { lock (_gate) { var release = _release; _release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _parked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); release.TrySetResult(); } } } }