From 124bde1ad26251010a6a5d11660b6b50ac3e7224 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:45:01 +0700 Subject: [PATCH 1/7] fix(lifecycle): remove ICMP as authoritative MMS death signal (#446) --- MainWindow.P0AssociationLiveness.cs | 305 +--------------------------- 1 file changed, 10 insertions(+), 295 deletions(-) diff --git a/MainWindow.P0AssociationLiveness.cs b/MainWindow.P0AssociationLiveness.cs index a0a9b20f5..bf5cfbb9e 100644 --- a/MainWindow.P0AssociationLiveness.cs +++ b/MainWindow.P0AssociationLiveness.cs @@ -1,302 +1,17 @@ -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Windows; -using System.Windows.Threading; -using ArIED61850Tester.Models; - namespace ArIED61850Tester; /// -/// Supplements the report-only monitor path with endpoint liveness without turning a process -/// value into an MMS heartbeat. While an Engineering association is active we use ICMP only -/// after that endpoint has first proven it supports ICMP. Two consecutive proven endpoint -/// losses transition the shared Engineering device OFFLINE. Once offline, a bounded TCP/102 -/// reachability check detects the endpoint returning and the normal saved-model connection + -/// monitor pipeline is used to create the new authoritative MMS association. +/// Association liveness belongs to the per-IED MMS runtime, not to the WPF shell. +/// +/// The previous implementation used ICMP Ping as an authoritative death signal after an +/// endpoint had once answered ICMP. That can tear down a healthy IEC 61850 association when +/// ICMP is filtered, rate-limited, or transiently lost. It also duplicated the bounded +/// reconnect state machine already owned by . +/// +/// Keep this partial file as the explicit architectural boundary: UI code may render runtime +/// liveness/reconnect state, but it must not independently declare an MMS association dead or +/// start a second reconnect owner. /// public partial class MainWindow { - private static readonly bool AssociationLivenessClassHandlerRegistered = RegisterAssociationLivenessClassHandler(); - private readonly Dictionary _associationLivenessFailures = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _associationPingProven = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _associationReconnectWanted = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _associationResumeMonitoring = new(StringComparer.OrdinalIgnoreCase); - private DispatcherTimer? _associationLivenessTimer; - private CancellationTokenSource? _associationLivenessCancellation; - private bool _associationLivenessTickRunning; - - private static bool RegisterAssociationLivenessClassHandler() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(AssociationLiveness_Loaded)); - return true; - } - - private static void AssociationLiveness_Loaded(object sender, RoutedEventArgs e) - { - if (sender is MainWindow window) - window.EnsureAssociationLivenessWatchdog(); - } - - private void EnsureAssociationLivenessWatchdog() - { - if (_associationLivenessTimer != null) - return; - - _associationLivenessCancellation = new CancellationTokenSource(); - _associationLivenessTimer = new DispatcherTimer(DispatcherPriority.Background) - { - Interval = TimeSpan.FromSeconds(2) - }; - _associationLivenessTimer.Tick += AssociationLivenessTimer_Tick; - _associationLivenessTimer.Start(); - Closed += AssociationLiveness_Closed; - } - - private async void AssociationLivenessTimer_Tick(object? sender, EventArgs e) - { - if (_associationLivenessTickRunning || _associationLivenessCancellation?.IsCancellationRequested != false) - return; - - _associationLivenessTickRunning = true; - try - { - await RunAssociationLivenessPassAsync(_associationLivenessCancellation.Token).ConfigureAwait(true); - } - catch (OperationCanceledException) - { - } - finally - { - _associationLivenessTickRunning = false; - } - } - - private async Task RunAssociationLivenessPassAsync(CancellationToken cancellationToken) - { - foreach (var device in Devices.ToArray()) - { - cancellationToken.ThrowIfCancellationRequested(); - if (device.IsDemo || string.IsNullOrWhiteSpace(device.IpAddress) || device.IsBusy) - continue; - - if (_associationReconnectWanted.Contains(device.DeviceId)) - { - if (device.IsConnected) - { - CompleteAssociationReconnect(device); - continue; - } - - if (!await IsMmsEndpointReachableAsync(device, cancellationToken).ConfigureAwait(true)) - continue; - - await TryAssociationReconnectAsync(device, cancellationToken).ConfigureAwait(true); - continue; - } - - if (!device.IsConnected) - { - _associationLivenessFailures.Remove(device.DeviceId); - continue; - } - - var pingAlive = await TryPingEndpointAsync(device.IpAddress, cancellationToken).ConfigureAwait(true); - - // A user may intentionally disconnect while the asynchronous probe is in flight. - // Never convert that explicit Stop into an automatic reconnect request. - if (!device.IsConnected || _associationReconnectWanted.Contains(device.DeviceId)) - { - _associationLivenessFailures.Remove(device.DeviceId); - continue; - } - - if (pingAlive == true) - { - _associationPingProven.Add(device.DeviceId); - _associationLivenessFailures[device.DeviceId] = 0; - continue; - } - - // Before the first successful ping, null means ICMP may simply be blocked by the - // site and must never declare the IED dead. After ICMP has been proven for this - // endpoint, however, both an explicit non-success reply and a PingException/general - // transport failure are real liveness failures. Physical cable/power loss on Windows - // commonly surfaces as PingException rather than a TimedOut reply. - if (!_associationPingProven.Contains(device.DeviceId)) - continue; - - var failures = _associationLivenessFailures.TryGetValue(device.DeviceId, out var current) - ? current + 1 - : 1; - _associationLivenessFailures[device.DeviceId] = failures; - if (failures < 2) - continue; - - await MarkAssociationOfflineAsync(device).ConfigureAwait(true); - } - } - - private async Task MarkAssociationOfflineAsync(Iec61850MonitorDevice device) - { - var resumeMonitoring = device.IsMonitoring; - _associationReconnectWanted.Add(device.DeviceId); - if (resumeMonitoring) - _associationResumeMonitoring.Add(device.DeviceId); - - device.IsConnected = false; - device.IsMonitoring = false; - device.Status = "Offline"; - device.Detail = $"{device.EndpointText} stopped responding. Smart reconnect will resume automatically when the IEC 61850 endpoint returns."; - device.AcquisitionMode = "Connection lost • smart reconnect"; - device.RefreshComputed(); - AddLog("WARN", device.Name, - $"Endpoint liveness lost after two consecutive probes; {device.EndpointText} marked OFFLINE and automatic reconnect armed."); - - try - { - await StopDeviceConnectionAsync(device).ConfigureAwait(true); - } - catch (Exception ex) - { - AddLog("WARN", device.Name, - $"Offline cleanup reported {ex.GetType().Name}: {ex.Message}. Smart reconnect remains armed."); - } - - // StopDeviceConnectionAsync owns normal manual-disconnect text; restore the - // automatic-recovery state so Engineering and FAT cards expose the real condition. - device.IsConnected = false; - device.IsMonitoring = false; - device.Status = "Offline"; - device.Detail = $"{device.EndpointText} is offline. Waiting for the IEC 61850 endpoint to return…"; - device.AcquisitionMode = "Connection lost • smart reconnect"; - device.RefreshComputed(); - } - - private async Task TryAssociationReconnectAsync( - Iec61850MonitorDevice device, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - device.Status = "Reconnecting"; - device.Detail = $"{device.EndpointText} is reachable again. Opening a fresh IEC 61850 association…"; - device.RefreshComputed(); - - var connected = false; - try - { - connected = device.HasDiscoveryCache && device.Signals.Count > 0 - ? await ConnectUsingSavedModelAsync(device, selectDevice: false).ConfigureAwait(true) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false).ConfigureAwait(true); - - if (connected && - _associationResumeMonitoring.Contains(device.DeviceId) && - !device.IsMonitoring) - { - await StartDeviceMonitorAsync(device).ConfigureAwait(true); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - AddLog("WARN", device.Name, - $"Smart reconnect attempt failed: {ex.GetType().Name}: {ex.Message}"); - } - - if (connected && device.IsConnected) - { - CompleteAssociationReconnect(device); - return; - } - - device.Status = "Reconnect pending"; - device.Detail = $"{device.EndpointText} answered the reachability probe but an IEC 61850 association is not ready yet. ARSAS will retry automatically."; - device.RefreshComputed(); - } - - private void CompleteAssociationReconnect(Iec61850MonitorDevice device) - { - _associationReconnectWanted.Remove(device.DeviceId); - _associationResumeMonitoring.Remove(device.DeviceId); - _associationLivenessFailures[device.DeviceId] = 0; - AddLog("INFO", device.Name, - $"Smart reconnect completed for {device.EndpointText}; shared Engineering/FAT connection state is live again."); - } - - private static async Task TryPingEndpointAsync( - string host, - CancellationToken cancellationToken) - { - try - { - using var ping = new Ping(); - var reply = await ping.SendPingAsync(host, TimeSpan.FromMilliseconds(650), Array.Empty(), null, cancellationToken) - .ConfigureAwait(false); - return reply.Status == IPStatus.Success; - } - catch (OperationCanceledException) - { - throw; - } - catch (PingException) - { - // null is only "unknown" until this endpoint has first proven ICMP support. - // RunAssociationLivenessPassAsync turns it into a failure after that proof. - return null; - } - catch (InvalidOperationException) - { - return null; - } - } - - private static async Task IsMmsEndpointReachableAsync( - Iec61850MonitorDevice device, - CancellationToken cancellationToken) - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(TimeSpan.FromMilliseconds(850)); - try - { - using var client = new TcpClient(); - await client.ConnectAsync(device.IpAddress, device.Port, timeout.Token).ConfigureAwait(false); - return client.Connected; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return false; - } - catch (SocketException) - { - return false; - } - catch (IOException) - { - return false; - } - } - - private void AssociationLiveness_Closed(object? sender, EventArgs e) - { - Closed -= AssociationLiveness_Closed; - if (_associationLivenessTimer != null) - { - _associationLivenessTimer.Stop(); - _associationLivenessTimer.Tick -= AssociationLivenessTimer_Tick; - _associationLivenessTimer = null; - } - - _associationLivenessCancellation?.Cancel(); - _associationLivenessCancellation?.Dispose(); - _associationLivenessCancellation = null; - _associationLivenessFailures.Clear(); - _associationPingProven.Clear(); - _associationReconnectWanted.Clear(); - _associationResumeMonitoring.Clear(); - } } From ae91e7a73306abf4af17f94eec49e405bcc20955 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:45:23 +0700 Subject: [PATCH 2/7] fix(reporting): reset traffic proof on every association generation (#446) --- Services/Iec61850MonitorRuntime.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 29995cc2f..0db50314f 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1930,6 +1930,12 @@ private async Task DisposeClientForReconnectAsync( private static void ResetAssociationReportEvidence(DeviceSession session) { + // InformationReport proof is association-scoped. A report observed on the old + // socket must never make the replacement association look TrafficProven before + // the first routed report of the new generation arrives. + session.Device.HasReportStream = false; + session.Device.ReportPulseActive = false; + foreach (var state in session.States.Values) { state.ReportTrafficSeen = false; @@ -1974,6 +1980,8 @@ private void MarkSessionOffline(DeviceSession session, string detail) { var wasConnected = session.Device.IsConnected; session.Device.IsConnected = false; + session.Device.HasReportStream = false; + session.Device.ReportPulseActive = false; session.Device.Status = "Offline"; session.Device.Detail = detail; session.Device.AcquisitionMode = "Connection lost • reconnect pending"; From 683db9165da764fb6a3ae8dc17598602aaba0aa9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:46:24 +0700 Subject: [PATCH 3/7] perf(reporting): make static traffic proof event-driven and association-scoped (#446) --- MainWindow.IoTesting.AutoConnect.cs | 70 +++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index cc05bd11d..790ba33d7 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -496,28 +496,34 @@ private async Task ObserveSharedStaticReportEvidenceAsync( Iec61850MonitorDevice device, TimeSpan proofWindow) { + var startedUtc = DateTime.UtcNow; try { - await Task.Delay(proofWindow, _applicationCancellation.Token); + var trafficObserved = await WaitForReportStreamAsync( + device, + proofWindow, + _applicationCancellation.Token).ConfigureAwait(true); + if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device)) return; - if (device.HasReportStream) + if (trafficObserved && device.HasReportStream) { var reportPoints = device.Points.Count(point => IsReportSource(point.SourceMode) && !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase)); + var firstReportLatency = DateTime.UtcNow - startedUtc; AddLog( "INFO", device.Name, - $"Static DataSet report evidence: actual InformationReport traffic observed; report-backed runtime point(s)={reportPoints}. RCB/GI path is alive."); + $"Static DataSet report evidence: actual InformationReport traffic observed after {firstReportLatency.TotalMilliseconds:0} ms; report-backed runtime point(s)={reportPoints}. RCB/GI path is alive."); return; } AddLog( "WARN", device.Name, - $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. ARSAS will remain report-pending and will NOT switch process values to cyclic MMS polling. Check RptEna/ownership, GI support and whether the IED server actually emits reports for the configured DataSet."); + $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. Activation remains armed/report-pending; absence of traffic alone is not treated as RCB activation failure and ARSAS will NOT switch process values to cyclic MMS polling."); MarkDiagnosticAlert(); } catch (OperationCanceledException) @@ -526,6 +532,62 @@ private async Task ObserveSharedStaticReportEvidenceAsync( } } + private static async Task WaitForReportStreamAsync( + Iec61850MonitorDevice device, + TimeSpan timeout, + CancellationToken cancellationToken) + { + if (device.HasReportStream) + return true; + if (!device.IsMonitoring) + return false; + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + System.ComponentModel.PropertyChangedEventHandler? handler = null; + handler = (_, args) => + { + if (args.PropertyName == nameof(Iec61850MonitorDevice.HasReportStream) && + device.HasReportStream) + { + completion.TrySetResult(true); + return; + } + + if (args.PropertyName == nameof(Iec61850MonitorDevice.IsMonitoring) && + !device.IsMonitoring) + { + completion.TrySetResult(false); + } + }; + + device.PropertyChanged += handler; + try + { + // Close the subscribe/check race without polling. + if (device.HasReportStream) + return true; + if (!device.IsMonitoring) + return false; + + try + { + return await completion.Task + .WaitAsync(timeout, cancellationToken) + .ConfigureAwait(true); + } + catch (TimeoutException) + { + return false; + } + } + finally + { + device.PropertyChanged -= handler; + } + } + private bool AttachIoFatSclRuntimeAuthority( IoTestIedPlan ied, Iec61850MonitorDevice device) From b7bb28658feb64cd52e3384f2d3e966ce5cb476f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:46:58 +0700 Subject: [PATCH 4/7] fix(reconnect): keep static report-only recovery diagnostics semantically accurate (#446) --- Services/Iec61850MonitorRuntime.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0db50314f..b2e746c20 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1847,17 +1847,20 @@ await DisposeClientForReconnectAsync( session.NextHealthProbeUtc = session.RecoveryWarmupUntilUtc; session.Device.IsConnected = true; session.Device.Status = "Monitoring"; - session.Device.AcquisitionMode = "MMS recovered • static report re-arm pending"; - session.Device.Detail = - $"MMS reconnected in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. " + - $"{session.Points.Count} point(s) resumed with staggered MMS recovery; report re-arm continues in the background."; + session.Device.AcquisitionMode = session.StaticDataSetReportOnly + ? "MMS recovered • Static DataSet report re-arm pending" + : "MMS recovered • report re-arm pending"; + session.Device.Detail = session.StaticDataSetReportOnly + ? $"MMS reconnected in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. Static DataSet process polling remains disabled; configured RCB re-arm continues in the background." + : $"MMS reconnected in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. {session.Points.Count} point(s) resumed with staggered MMS recovery; report re-arm continues in the background."; session.Device.LastDiagnosticSnapshot = replacement.CaptureDiagnosticSnapshot( "Smart reconnect MMS associated; report re-arm deferred"); session.Device.RefreshComputed(); Log("INFO", session.Device.Name, - $"MMS reconnect successful on attempt #{attempt} in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. " + - $"Polling resumed immediately with a {SmartReconnectPolicy.RecoveryWarmupDuration.TotalSeconds:0} s recovery warm-up; static report re-arm is deferred to the background pipeline."); + session.StaticDataSetReportOnly + ? $"MMS reconnect successful on attempt #{attempt} in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. Static DataSet process polling remains disabled; configured RCB re-arm is deferred to the background pipeline." + : $"MMS reconnect successful on attempt #{attempt} in {reconnectStopwatch.Elapsed.TotalMilliseconds:0} ms. Polling resumed immediately with a {SmartReconnectPolicy.RecoveryWarmupDuration.TotalSeconds:0} s recovery warm-up; report re-arm is deferred to the background pipeline."); } private void ScheduleReconnectRetry( From b524adc4570d49fdce744e85e6ee37023a5345ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:47:18 +0700 Subject: [PATCH 5/7] test(lifecycle): lock MMS-owned reconnect and association-scoped traffic proof (#446) --- .../P72AssociationLifecycleRegressionTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs diff --git a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs new file mode 100644 index 000000000..caee3daa1 --- /dev/null +++ b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs @@ -0,0 +1,107 @@ +namespace ARSAS.Tests; + +public sealed class P72AssociationLifecycleRegressionTests +{ + [Fact] + public void UiShell_DoesNotOwnIcmpDeathOrSecondReconnectStateMachine() + { + var source = Read("MainWindow.P0AssociationLiveness.cs"); + + Assert.Contains( + "Association liveness belongs to the per-IED MMS runtime", + source, + StringComparison.Ordinal); + Assert.DoesNotContain("System.Net.NetworkInformation", source, StringComparison.Ordinal); + Assert.DoesNotContain("Ping", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkAssociationOfflineAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("_associationReconnectWanted", source, StringComparison.Ordinal); + Assert.DoesNotContain("StopDeviceConnectionAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_ClearsReportTrafficProofWhenAssociationIsLostOrReplaced() + { + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains( + "session.Device.HasReportStream = false;", + runtime, + StringComparison.Ordinal); + Assert.Contains( + "InformationReport proof is association-scoped", + runtime, + StringComparison.Ordinal); + Assert.Contains( + "private static void ResetAssociationReportEvidence", + runtime, + StringComparison.Ordinal); + Assert.Contains( + "private void MarkSessionOffline", + runtime, + StringComparison.Ordinal); + + Assert.True( + Count(runtime, "session.Device.HasReportStream = false;") >= 2, + "Traffic proof must be cleared both when the association goes offline and when a replacement association is installed."); + } + + [Fact] + public void StaticTrafficProof_WaitsForPropertyChangeInsteadOfFixedSleep() + { + var source = Read("MainWindow.IoTesting.AutoConnect.cs"); + + Assert.Contains("WaitForReportStreamAsync", source, StringComparison.Ordinal); + Assert.Contains("device.PropertyChanged += handler;", source, StringComparison.Ordinal); + Assert.Contains(".WaitAsync(timeout, cancellationToken)", source, StringComparison.Ordinal); + Assert.Contains("firstReportLatency.TotalMilliseconds", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "await Task.Delay(proofWindow", + source, + StringComparison.Ordinal); + } + + [Fact] + public void StaticReportOnlyReconnect_DoesNotClaimPollingResumed() + { + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains( + "Static DataSet process polling remains disabled; configured RCB re-arm", + runtime, + StringComparison.Ordinal); + Assert.Contains( + "session.StaticDataSetReportOnly", + runtime, + StringComparison.Ordinal); + } + + private static int Count(string text, string value) + { + var count = 0; + var offset = 0; + while ((offset = text.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + + return count; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From 675bfac3c07acc3f4bea15ad5d89816a085cd96e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:48:35 +0700 Subject: [PATCH 6/7] test(lifecycle): guard ICMP execution rather than documentation wording (#446) --- tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs index caee3daa1..54c59134c 100644 --- a/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs +++ b/tests/ARSAS.Tests/P72AssociationLifecycleRegressionTests.cs @@ -12,7 +12,9 @@ public void UiShell_DoesNotOwnIcmpDeathOrSecondReconnectStateMachine() source, StringComparison.Ordinal); Assert.DoesNotContain("System.Net.NetworkInformation", source, StringComparison.Ordinal); - Assert.DoesNotContain("Ping", source, StringComparison.Ordinal); + Assert.DoesNotContain("new Ping(", source, StringComparison.Ordinal); + Assert.DoesNotContain("SendPingAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("TryPingEndpointAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("MarkAssociationOfflineAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("_associationReconnectWanted", source, StringComparison.Ordinal); Assert.DoesNotContain("StopDeviceConnectionAsync", source, StringComparison.Ordinal); From ccf692101844aeeb780e6758882f78440bfc00a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:52:23 +0700 Subject: [PATCH 7/7] test(lifecycle): replace obsolete ICMP watchdog contract with MMS runtime ownership (#446) --- .../P0LatestFieldRegressionTests.cs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs index 34f93fb49..5b15dc9bc 100644 --- a/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LatestFieldRegressionTests.cs @@ -66,18 +66,26 @@ public void CommandDefaults_KeepInterlockAndSyncCheckedUntilCtlModelIsResolved() } [Fact] - public void AssociationWatchdog_DetectsOfflineWithoutCyclicMmsProcessReadsAndArmsReconnect() + public void AssociationLiveness_IsOwnedByMmsRuntimeWithoutIcmpOrCyclicProcessHeartbeat() { - var source = File.ReadAllText(FindRepoFile("MainWindow.P0AssociationLiveness.cs")); - - Assert.Contains("TryPingEndpointAsync", source, StringComparison.Ordinal); - Assert.Contains("failures < 2", source, StringComparison.Ordinal); - Assert.Contains("device.Status = \"Offline\"", source, StringComparison.Ordinal); - Assert.Contains("_associationReconnectWanted.Add", source, StringComparison.Ordinal); - Assert.Contains("IsMmsEndpointReachableAsync", source, StringComparison.Ordinal); - Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("ReadValueAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("IecSignalReadResolver", source, StringComparison.Ordinal); + var shell = File.ReadAllText(FindRepoFile("MainWindow.P0AssociationLiveness.cs")); + var runtime = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + Assert.Contains( + "Association liveness belongs to the per-IED MMS runtime", + shell, + StringComparison.Ordinal); + Assert.DoesNotContain("TryPingEndpointAsync", shell, StringComparison.Ordinal); + Assert.DoesNotContain("SendPingAsync", shell, StringComparison.Ordinal); + Assert.DoesNotContain("_associationReconnectWanted", shell, StringComparison.Ordinal); + Assert.DoesNotContain("StopDeviceConnectionAsync", shell, StringComparison.Ordinal); + Assert.DoesNotContain("ReadValueAsync", shell, StringComparison.Ordinal); + Assert.DoesNotContain("IecSignalReadResolver", shell, StringComparison.Ordinal); + + Assert.Contains("TryReconnectAsync(session", runtime, StringComparison.Ordinal); + Assert.Contains("ForceReconnectAsync(session", runtime, StringComparison.Ordinal); + Assert.Contains("SmartReconnectPolicy.ClientCleanupBudget", runtime, StringComparison.Ordinal); + Assert.Contains("session.Device.HasReportStream = false;", runtime, StringComparison.Ordinal); } [Fact]