using Shouldly; using ZB.MOM.WW.OtOpcUa.Tests; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Task 9 — 's lifecycle over the /// seam: construction, initialize, reinitialize, shutdown, /// health, footprint, and cache flush. Every test runs against /// ; no socket is opened anywhere in this file. /// /// /// The load-bearing assertions here are the ones about what must NOT happen: a /// constructor that dials, an initialize that faults but still reports Healthy, a re-point that /// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a /// defect the happy-path assertions alone would pass straight over. /// public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output) { private const string AgentUri = "http://fixture-agent:5000"; /// Every dataItemId the canned probe declares — the browse-cache footprint basis. private const int FixtureDataItemCount = 7; private static MTConnectDriverOptions Opts( string agentUri = AgentUri, string? deviceName = null, int requestTimeoutMs = 5000, int heartbeatMs = 10000, int sampleIntervalMs = 1000, int sampleCount = 1000) => new() { AgentUri = agentUri, DeviceName = deviceName, RequestTimeoutMs = requestTimeoutMs, HeartbeatMs = heartbeatMs, SampleIntervalMs = sampleIntervalMs, SampleCount = sampleCount, Tags = [ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), new MTConnectTagDefinition("dev1_execution", DriverDataType.String), ], }; /// A driver wired to one canned client, plus that client, for the common case. private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( MTConnectDriverOptions? options = null) { var client = CannedAgentClient.FromFixtures(); return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client); } private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync() { var (driver, client) = NewDriver(); await driver.InitializeAsync("{}", CancellationToken.None); return (driver, client); } // ---- connection-free construction ---- [Fact] public void Constructor_dials_nothing_and_does_not_even_build_a_client() { var factoryCalls = 0; var client = CannedAgentClient.FromFixtures(); var driver = new MTConnectDriver(Opts(), "mt1", _ => { factoryCalls++; return client; }); // The universal discovery browser constructs a throwaway instance purely to ask CanBrowse. // If the ctor built (or worse, dialled) a client, every browse probe would open a connection // pool against a possibly-unreachable Agent. factoryCalls.ShouldBe(0); client.ProbeCallCount.ShouldBe(0); client.CurrentCallCount.ShouldBe(0); client.SampleCallCount.ShouldBe(0); driver.GetHealth().State.ShouldBe(DriverState.Unknown); } [Fact] public void Identity_is_the_constructor_arguments() { var (driver, _) = NewDriver(); driver.DriverInstanceId.ShouldBe("mt1"); driver.DriverType.ShouldBe("MTConnect"); } // ---- initialize ---- [Fact] public async Task Initialize_primes_index_and_reports_healthy() { var (driver, client) = NewDriver(); await driver.InitializeAsync("{}", CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); client.ProbeCallCount.ShouldBe(1); client.CurrentCallCount.ShouldBe(1); // The /current snapshot is what makes the driver able to answer a read at all. driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u); } [Fact] public async Task Initialize_records_the_agent_instance_id_and_the_probe_model() { var (driver, client) = NewDriver(); await driver.InitializeAsync("{}", CancellationToken.None); driver.AgentInstanceId.ShouldBe(client.Current.InstanceId); driver.CachedProbeModel.ShouldNotBeNull(); driver.CachedProbeModel!.Devices.Count.ShouldBe(1); } [Fact] public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries() { var (driver, _) = NewDriver(); var before = DateTime.UtcNow; await driver.InitializeAsync("{}", CancellationToken.None); var after = DateTime.UtcNow; var lastRead = driver.GetHealth().LastSuccessfulRead; lastRead.ShouldNotBeNull(); lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before); lastRead.Value.ShouldBeLessThanOrEqualTo(after); } [Fact] public async Task Initialize_faults_and_rethrows_when_probe_fails() { var (driver, client) = NewDriver(); client.ProbeFailure = new HttpRequestException("agent unreachable"); var thrown = await Should.ThrowAsync( () => driver.InitializeAsync("{}", CancellationToken.None)); thrown.Message.ShouldBe("agent unreachable"); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Faulted); health.LastError.ShouldNotBeNull(); health.LastError!.ShouldContain("agent unreachable"); // No half-initialized object: the client it built must not survive the failure. client.IsDisposed.ShouldBeTrue(); } /// /// Gitea #517 — the failed state must be published BEFORE the client is disposed, not after. /// Every other test in this file asserts the state once InitializeAsync has returned, /// by which time the window has closed. That is why the defect survived: the terminal value was /// always right, and only the ordering was wrong. This samples GetHealth() from inside the /// client's Dispose, which is the moment the driver stops being able to serve anything. /// The window is reachable in production, which is what makes this worth pinning rather than /// a theoretical tidy-up: DriverInstanceActor runs InitializeAsync on a /// fire-and-forget Task.Run, so its health-poll tick keeps calling GetHealth() on the /// actor thread for the whole duration of the teardown. /// Falsifiable: remove the pre-teardown WriteHealth and the observed state reverts to /// — the stale value written on entry. /// [Fact] public async Task Initialize_publishes_the_failed_state_before_disposing_the_client() { var (driver, client) = NewDriver(); client.ProbeFailure = new HttpRequestException("agent unreachable"); DriverState? stateAtDisposal = null; client.OnFirstDispose = () => stateAtDisposal = driver.GetHealth().State; await Should.ThrowAsync( () => driver.InitializeAsync("{}", CancellationToken.None)); client.IsDisposed.ShouldBeTrue("the test is vacuous unless the disposal actually happened"); stateAtDisposal.ShouldNotBeNull(); stateAtDisposal.ShouldBe(DriverState.Faulted, "health must already report the failure when the client goes away — a caller polling " + "GetHealth() during the teardown must never see a state that outlives the transport"); // And the terminal write still lands, so the resting state is right too. driver.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails() { // Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose // priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy // there would render every tag BadWaitingForInitialData forever while the dashboard shows a // green driver — the #485 "a failure that looks like success" shape. var (driver, client) = NewDriver(); client.CurrentFailure = new InvalidDataException("current unparseable"); await Should.ThrowAsync( () => driver.InitializeAsync("{}", CancellationToken.None)); client.ProbeCallCount.ShouldBe(1); driver.GetHealth().State.ShouldBe(DriverState.Faulted); driver.GetHealth().State.ShouldNotBe(DriverState.Healthy); driver.CachedProbeModel.ShouldBeNull(); driver.ObservationIndex.Count.ShouldBe(0); client.IsDisposed.ShouldBeTrue(); } /// /// arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait /// forever" (or "ask the Agent for nothing"). All four knobs share one /// RequirePositive path, so all four are asserted — a guard that covered only the /// one knob with a test is exactly how three of them would quietly lose the check. /// /// /// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than /// leaning on the production client's own constructor guard. /// [Theory] [InlineData("RequestTimeoutMs")] [InlineData("HeartbeatMs")] [InlineData("SampleIntervalMs")] [InlineData("SampleCount")] public async Task Initialize_rejects_a_non_positive_timing_knob_before_dialling(string knob) { var options = knob switch { "RequestTimeoutMs" => Opts(requestTimeoutMs: 0), "HeartbeatMs" => Opts(heartbeatMs: 0), "SampleIntervalMs" => Opts(sampleIntervalMs: 0), "SampleCount" => Opts(sampleCount: 0), _ => throw new ArgumentOutOfRangeException(nameof(knob), knob, "unhandled knob"), }; var factoryCalls = 0; var driver = new MTConnectDriver(options, "mt1", _ => { factoryCalls++; return CannedAgentClient.FromFixtures(); }); var thrown = await Should.ThrowAsync( () => driver.InitializeAsync("{}", CancellationToken.None)); // The message must name the offending key, or an operator cannot act on it. thrown.Message.ShouldContain(knob); factoryCalls.ShouldBe(0); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } // ---- the driverConfigJson argument ---- [Fact] public async Task Initialize_honours_the_config_json_over_the_constructor_options() { // The IDriver contract: the driver serves the config document it is handed, not only its // constructor options — ignoring it would turn an in-place config change into a silent no-op. MTConnectDriverOptions? seen = null; var driver = new MTConnectDriver(Opts(), "mt1", o => { seen = o; return CannedAgentClient.FromFixtures(); }); await driver.InitializeAsync( """{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""", CancellationToken.None); seen.ShouldNotBeNull(); seen!.AgentUri.ShouldBe("http://other-agent:5001"); seen.DeviceName.ShouldBe("VMC-3Axis"); } /// /// "Semantically empty" is decided by parsing, not by matching the literal two-character /// spellings. A pretty-printer, a formatter, or a hand edit turns {} into { } /// or {\n} without changing its meaning — and under literal matching each of those /// reached ParseOptions, failed the required-AgentUri check, and turned an empty document /// into a cold-start fault or a rejected deployment. /// [Theory] [InlineData("{}")] [InlineData("{ }")] [InlineData("{\n}")] [InlineData(" {\r\n\t} ")] [InlineData("[]")] [InlineData("[ ]")] [InlineData("null")] [InlineData("")] [InlineData(" ")] public async Task Initialize_with_a_semantically_empty_config_body_keeps_the_constructor_options(string json) { MTConnectDriverOptions? seen = null; var driver = new MTConnectDriver(Opts(), "mt1", o => { seen = o; return CannedAgentClient.FromFixtures(); }); await driver.InitializeAsync(json, CancellationToken.None); seen.ShouldNotBeNull(); seen!.AgentUri.ShouldBe(AgentUri); seen.Tags.Count.ShouldBe(2); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Initialize_treats_a_malformed_config_body_as_a_parse_error_not_as_empty() { // The other half of the emptiness rule: unreadable text must NOT fall through to "no config // supplied" and silently start the driver on stale options. var (driver, _) = NewDriver(); var thrown = await Should.ThrowAsync( () => driver.InitializeAsync("""{"agentUri": """, CancellationToken.None)); thrown.Message.ShouldContain("not valid JSON"); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] public async Task Initialize_faults_on_a_config_body_with_no_agent_uri() { var (driver, _) = NewDriver(); await Should.ThrowAsync( () => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] public async Task Initialize_on_a_live_instance_with_an_unreadable_config_keeps_it_serving() { // One rule for both entry points: an unreadable document never destroys working state; it // faults only a driver that had none. InitializeAsync IS reachable on a live instance // (DriverInstanceActor re-initialises during Connecting/Reconnecting), so parsing must // happen BEFORE the teardown — otherwise a bad edit kills a healthy client and the driver // ends up in exactly the state ReinitializeAsync is written to avoid. var (driver, client) = await InitializedDriverAsync(); await Should.ThrowAsync( () => driver.InitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri); client.IsDisposed.ShouldBeFalse(); driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); } [Fact] public async Task Initialize_parses_authored_tags_out_of_the_config_json() { MTConnectDriverOptions? seen = null; var driver = new MTConnectDriver(Opts(), "mt1", o => { seen = o; return CannedAgentClient.FromFixtures(); }); await driver.InitializeAsync( """ {"agentUri":"http://a:5000", "tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]} """, CancellationToken.None); seen.ShouldNotBeNull(); seen!.Tags.Count.ShouldBe(1); seen.Tags[0].FullName.ShouldBe("dev1_partcount"); // Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES. seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32); } // ---- reinitialize ---- [Fact] public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client() { var built = new List(); var driver = new MTConnectDriver(Opts(), "mt1", _ => { var c = CannedAgentClient.FromFixtures(); built.Add(c); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); await driver.ReinitializeAsync( $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""", CancellationToken.None); // Config-only: the live connection pool survives, so no client churn... built.Count.ShouldBe(1); built[0].IsDisposed.ShouldBeFalse(); // ...but everything DERIVED from config is genuinely rebuilt against the new document. built[0].ProbeCallCount.ShouldBe(2); built[0].CurrentCallCount.ShouldBe(2); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client() { var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); var driver = new MTConnectDriver(Opts(), "mt1", o => { var c = CannedAgentClient.FromFixtures(); built.Add((o, c)); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); await driver.ReinitializeAsync( """{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None); // A re-pointed driver that kept the old client would keep reading the OLD machine while the // deployment reports success. built.Count.ShouldBe(2); built[0].Client.IsDisposed.ShouldBeTrue(); built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000"); built[1].Client.IsDisposed.ShouldBeFalse(); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client() { var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); var driver = new MTConnectDriver(Opts(), "mt1", o => { var c = CannedAgentClient.FromFixtures(); built.Add((o, c)); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); await driver.ReinitializeAsync( $$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None); // DeviceName is baked into every request URI at client construction. built.Count.ShouldBe(2); built[0].Client.IsDisposed.ShouldBeTrue(); built[1].Options.DeviceName.ShouldBe("VMC-3Axis"); } [Fact] public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client() { // RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client // CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client // because the URI happened not to change would silently discard the operator's edit. var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); var driver = new MTConnectDriver(Opts(), "mt1", o => { var c = CannedAgentClient.FromFixtures(); built.Add((o, c)); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); await driver.ReinitializeAsync( $$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None); built.Count.ShouldBe(2); built[1].Options.HeartbeatMs.ShouldBe(2500); built[0].Client.IsDisposed.ShouldBeTrue(); } [Fact] public async Task Reinitialize_before_initialize_behaves_as_initialize() { var (driver, client) = NewDriver(); await driver.ReinitializeAsync("{}", CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); client.ProbeCallCount.ShouldBe(1); } [Fact] public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy() { var built = new List(); var driver = new MTConnectDriver(Opts(), "mt1", _ => { var c = CannedAgentClient.FromFixtures(); built.Add(c); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); built[0].CurrentFailure = new HttpRequestException("agent went away"); // Config-only shape (only the tag list changes), so the failing leg is the re-prime against // the client that is already live — the path a rebuild would otherwise mask. await Should.ThrowAsync( () => driver.ReinitializeAsync( $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None)); built.Count.ShouldBe(1); driver.GetHealth().State.ShouldBe(DriverState.Faulted); driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away"); // A failed refresh must not leave a live client nobody owns. built[0].IsDisposed.ShouldBeTrue(); } [Fact] public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving() { // Blast radius: an unparseable in-place config edit fails the reinitialize call; it must // not down a driver that is happily serving its previous, valid configuration. var (driver, client) = await InitializedDriverAsync(); await Should.ThrowAsync( () => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri); client.IsDisposed.ShouldBeFalse(); driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); } // ---- shutdown ---- [Fact] public async Task Shutdown_disposes_the_client_and_drops_the_served_state() { var (driver, client) = await InitializedDriverAsync(); var lastRead = driver.GetHealth().LastSuccessfulRead; await driver.ShutdownAsync(CancellationToken.None); client.DisposeCount.ShouldBe(1); driver.GetHealth().State.ShouldBe(DriverState.Unknown); // Retained: it is diagnostic ("when did we last hear from the Agent"), not served data. driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead); // Dropped: values from a torn-down connection must never keep reading Good. driver.ObservationIndex.Count.ShouldBe(0); driver.CachedProbeModel.ShouldBeNull(); } [Fact] public async Task Shutdown_is_idempotent() { var (driver, client) = await InitializedDriverAsync(); await driver.ShutdownAsync(CancellationToken.None); await driver.ShutdownAsync(CancellationToken.None); client.DisposeCount.ShouldBe(1); driver.GetHealth().State.ShouldBe(DriverState.Unknown); } [Fact] public async Task Shutdown_before_initialize_is_a_no_op() { var (driver, client) = NewDriver(); await driver.ShutdownAsync(CancellationToken.None); client.DisposeCount.ShouldBe(0); driver.GetHealth().State.ShouldBe(DriverState.Unknown); } // ---- dropping the probe cache ---- // // These used to drive FlushOptionalCachesAsync. Gitea #525 deleted it as an unconsumed IDriver // member, along with GetMemoryFootprint and the footprint test that only existed to assert on // it. What survives is InvalidateProbeModel — the Agent-restart drop, which is the only thing // that empties this cache in production, and which has the same retired-session guard and the // same "browse must not be left permanently empty" obligation. [Fact] public async Task Dropping_the_probe_cache_keeps_the_observation_index() { var (driver, _) = NewDriver(); await FixtureCleanup.RunAsync(async () => { await driver.InitializeAsync("{}", CancellationToken.None); driver.InvalidateProbeModel(); driver.CachedProbeModel.ShouldBeNull(); // The index is required for correctness (it IS the read surface) — never flushable. driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u); driver.GetHealth().State.ShouldBe(DriverState.Healthy); }, () => driver.ShutdownAsync(CancellationToken.None), failure => output.WriteLine($"MTConnect cache drop cleanup failed: {failure}")); } [Fact] public async Task Body_failure_after_probe_scheduler_enters_stops_probe_and_disposes_client() { var client = CannedAgentClient.FromFixtures(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var driver = new MTConnectDriver(Opts(), "mt1", _ => client, probeScheduler: async (_, ct) => { entered.TrySetResult(); try { await released.Task.WaitAsync(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { cancelled.TrySetResult(); throw; } }); Task? probe = null; var sentinel = new InvalidOperationException("body sentinel"); try { var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () => { await driver.InitializeAsync("{}", CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); probe = driver.ProbeLoopTask; _ = probe.ShouldNotBeNull(); probe!.IsCompleted.ShouldBeFalse(); client.IsDisposed.ShouldBeFalse(); throw sentinel; }, () => driver.ShutdownAsync(CancellationToken.None), failure => output.WriteLine($"MTConnect probe fixture cleanup failed: {failure}"))); actual.ShouldBeSameAs(sentinel); await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); probe!.IsCompleted.ShouldBeTrue(); client.DisposeCount.ShouldBe(1); } finally { released.TrySetResult(); } } [Fact] public async Task Initialization_failure_disposes_the_client_inside_fixture_boundary() { var client = CannedAgentClient.FromFixtures(); var failure = new HttpRequestException("current unavailable"); client.CurrentFailure = failure; var driver = new MTConnectDriver(Opts(), "mt1", _ => client); var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync( () => driver.InitializeAsync("{}", CancellationToken.None), () => driver.ShutdownAsync(CancellationToken.None), secondary => output.WriteLine($"MTConnect init fixture cleanup failed: {secondary}"))); actual.ShouldBeSameAs(failure); client.DisposeCount.ShouldBe(1); } [Fact] public async Task Dropping_the_cache_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand() { // Task 12's DiscoverAsync reads the probe model. If dropping it left no way back, an Agent // restart would permanently disable browse on that driver instance. var (driver, client) = await InitializedDriverAsync(); driver.InvalidateProbeModel(); client.ProbeCallCount.ShouldBe(1); var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None); model.Devices.Count.ShouldBe(1); client.ProbeCallCount.ShouldBe(2); driver.CachedProbeModel.ShouldNotBeNull(); } [Fact] public async Task Probe_model_is_served_from_cache_while_it_is_warm() { var (driver, client) = await InitializedDriverAsync(); _ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None); client.ProbeCallCount.ShouldBe(1); } [Fact] public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty() { var (driver, _) = NewDriver(); await Should.ThrowAsync( () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); } [Fact] public async Task Fetching_the_probe_model_after_shutdown_is_rejected() { var (driver, _) = await InitializedDriverAsync(); await driver.ShutdownAsync(CancellationToken.None); await Should.ThrowAsync( () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); } // ---- lock-free session snapshot: the probe fetch races the lifecycle ---- [Fact] public async Task A_shutdown_landing_mid_fetch_surfaces_as_not_connected_not_as_object_disposed() { // Browse is deliberately lock-free (it awaits the network; holding the lifecycle lock would // let it stall a shutdown for a whole RequestTimeoutMs), so a fetch CAN outlive the client // it captured. What must not happen is a raw ObjectDisposedException escaping into browse — // the caller already handles "not connected" and should have exactly one failure mode. var (driver, client) = await InitializedDriverAsync(); driver.InvalidateProbeModel(); // Held locally: the gate is one-shot, so the client has already cleared its own reference by // the time the request is parked on it. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.ProbeGate = gate; var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None); await driver.ShutdownAsync(CancellationToken.None); client.IsDisposed.ShouldBeTrue(); gate.TrySetResult(); var thrown = await Should.ThrowAsync(() => inFlight); thrown.InnerException.ShouldBeOfType(); } [Fact] public async Task A_fetch_that_completes_after_a_reinitialize_does_not_overwrite_the_newer_cache() { // The probe cache is re-published only onto the session it was fetched against. Without // that check, a slow browse would install a device model belonging to a superseded // configuration over the one the re-initialize just established — stale browse output that // nothing would ever correct. var built = new List(); var driver = new MTConnectDriver(Opts(), "mt1", _ => { var c = CannedAgentClient.FromFixtures(); built.Add(c); return c; }); await driver.InitializeAsync("{}", CancellationToken.None); driver.InvalidateProbeModel(); var client = built[0]; var staleModel = client.Probe; // Park a fetch on the OLD (about to be superseded) session; it captures staleModel now. // Held locally — the gate is one-shot and clears the client's own reference when it parks. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.ProbeGate = gate; var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None); // A config-only re-initialize: same client (so nothing is disposed), brand-new session. var freshModel = new MTConnectProbeModel(staleModel.Devices); client.Probe = freshModel; await driver.ReinitializeAsync( $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""", CancellationToken.None); built.Count.ShouldBe(1); driver.CachedProbeModel.ShouldBeSameAs(freshModel); // Now let the stale fetch finish. It still returns what the Agent gave it... gate.TrySetResult(); (await inFlight).ShouldBeSameAs(staleModel); // ...but it must not have published that over the newer session's cache. driver.CachedProbeModel.ShouldBeSameAs(freshModel); } [Fact] public async Task Dropping_the_cache_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session() { // The /sample pump can see an Agent restart at any moment, so a drop arriving after the // driver was torn down is ordinary, not exotic. It must observe the retired session and do // nothing — publishing a "dropped" copy of it would reinstate a session holding an // already-disposed client for every later caller. // // NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. The drop // has no await point, so nothing can be interleaved between its read and its publish from a // single-threaded test; the CAS is defence-in-depth against a genuinely concurrent // lifecycle call and is deliberately claimed as such rather than as tested behaviour. The // fetch-side CAS, which does have an await point, is covered by the test above. var (driver, client) = await InitializedDriverAsync(); await driver.ShutdownAsync(CancellationToken.None); driver.InvalidateProbeModel(); driver.CachedProbeModel.ShouldBeNull(); driver.GetHealth().State.ShouldBe(DriverState.Unknown); client.DisposeCount.ShouldBe(1); await Should.ThrowAsync( () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); } }