diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 1e9764827..598d4e56f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1552,6 +1552,24 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool if (!device.IsConnected) return; + // A normal Command Dock refresh also owns a specific live model epoch. + // The MMS socket can remain the same while Rescan/rebinding replaces + // device.Signals. Neither native success nor a late failure may publish + // into that replacement model. Reuse the post-Write admission predicate; + // no second authority cache or additional MMS request is needed. + var expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId); + if (expectedMmsAssociation == Guid.Empty) + return; + var expectedModelAssociation = device.LiveValues.Association; + bool IsCurrentRefreshDevice() => + Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + expectedMmsAssociation, + token => _runtime.IsControlAssociationCurrent(device.DeviceId, token), + expectedModelAssociation, device.LiveValues.Association, + device.IsConnected, true); + bool IsCurrentRefreshTarget(SignalDefinition candidate) => + IsCurrentRefreshDevice() && device.Signals.Contains(candidate); + // The native runtime serializes inspection and Operate on one // _controlCommandGate. Enqueuing three parallel UI inspections cannot // improve MMS throughput, but places extra reads ahead of user control. @@ -1581,19 +1599,27 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool // read. Never place another inspection behind an active // operator command on this association. await System.Windows.Threading.Dispatcher.Yield(DispatcherPriority.Background); - if (!device.IsConnected || _applicationCancellation.IsCancellationRequested || + if (!IsCurrentRefreshDevice() || _applicationCancellation.IsCancellationRequested || device.CommandSignals.Any(active => active.ControlCommandBusy)) break; + if (!IsCurrentRefreshTarget(signal)) + break; if (!Iec61850ControlIdentity.NeedsInspection(signal, force)) continue; signal.ControlInspectionBusy = true; try { + // Facade evaluates the model predicate before dispatch, + // before typed GUI publication, and before exception revoke. + // A check only after await here would be too late. var capabilities = await _runtime.InspectControlAsync( device.DeviceId, signal, - _applicationCancellation.Token); + _applicationCancellation.Token, + () => IsCurrentRefreshTarget(signal)); + if (!IsCurrentRefreshTarget(signal)) + break; if (!signal.ControlCommandBusy) { signal.ControlCurrentValue = capabilities.CurrentValue; @@ -1615,6 +1641,10 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool } catch (Exception ex) { + // Retired inspection failures must not replace the current + // Command Dock's status/evidence with stale error text. + if (!IsCurrentRefreshTarget(signal)) + break; if (!signal.ControlCommandBusy) { signal.ControlInspectionEvidence = @@ -1628,6 +1658,8 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool } } + if (!IsCurrentRefreshDevice()) + return; device.RefreshCommandSignalProjection(); RebuildControlFeedbackIndex(device); if (ReferenceEquals(SelectedDevice, device)) diff --git a/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs b/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs new file mode 100644 index 000000000..8e0fb8795 --- /dev/null +++ b/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs @@ -0,0 +1,110 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Routine Command Dock refresh must obey the same MMS association and model +/// epoch ownership as the dedicated post-Write reinspection. These tests use +/// deterministic state changes and verify the actual WPF/native dispatch seam, +/// without simulating a real IEC 61850 Operate. +/// +public sealed class M217P0RefreshControlModelEpochTests +{ + [Fact] + public void RefreshTargetFenceRejectsSameSocketReplacedModelAndRetiredRows() + { + var expectedMms = Guid.NewGuid(); + var currentMms = expectedMms; + const long expectedModel = 17; + long liveModel = expectedModel; + var deviceConnected = true; + var original = new SignalDefinition + { + Name = "CSWI1 Pos", + ObjectReference = "LD0/CSWI1.Pos", + FunctionalConstraint = "CO", + IsControlSignal = true + }; + var replacement = new SignalDefinition + { + Name = "CSWI1 Pos (rebound)", + ObjectReference = original.ObjectReference, + FunctionalConstraint = "CO", + IsControlSignal = true + }; + var live = new List { original }; + bool IsCurrent(SignalDefinition candidate) => + Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + expectedMms, id => id == currentMms, + expectedModel, liveModel, deviceConnected, live.Contains(candidate)); + + Assert.True(IsCurrent(original)); + Assert.False(IsCurrent(replacement)); + + // The same MMS GUID does not make a rescan's replacement data model + // an authorized recipient of the previous asynchronous inspection. + live.Clear(); + live.Add(replacement); + Assert.False(IsCurrent(original)); + Assert.False(IsCurrent(original)); // duplicate-root names never grant ownership + liveModel++; + Assert.False(IsCurrent(replacement)); + + liveModel = expectedModel; + live.Clear(); + live.Add(original); + currentMms = Guid.NewGuid(); + Assert.False(IsCurrent(original)); + currentMms = expectedMms; + deviceConnected = false; + Assert.False(IsCurrent(original)); + } + + [Fact] + public void RoutineRefreshPassesModelFenceToFacadeBeforeGuiMutationAndFailure() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists( + Path.Combine(root.FullName, "MainWindow.xaml.cs"))) + root = root.Parent; + Assert.NotNull(root); + var src = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs")); + var begin = src.IndexOf("private async Task RefreshControlValuesAsync(", + StringComparison.Ordinal); + var end = src.IndexOf("private bool RejectCommandWhenIedFlightUnavailable(", + begin, StringComparison.Ordinal); + Assert.True(begin >= 0 && end > begin); + var refresh = src[begin..end]; + + Assert.Contains("expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId)", + refresh); + Assert.Contains("expectedModelAssociation = device.LiveValues.Association", refresh); + Assert.Contains("Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(", + refresh); + Assert.Contains("device.Signals.Contains(candidate)", refresh); + Assert.Contains("() => IsCurrentRefreshTarget(signal));", refresh); + Assert.Contains("if (!IsCurrentRefreshTarget(signal))", refresh); + Assert.Contains("if (!IsCurrentRefreshDevice())", refresh); + Assert.Equal(1, refresh.Split("_runtime.InspectControlAsync(").Length - 1); + Assert.DoesNotContain("session.Client.InspectControlAsync(", refresh); + + var awaitEnd = refresh.IndexOf("() => IsCurrentRefreshTarget(signal));", + StringComparison.Ordinal); + var mutation = refresh.IndexOf("signal.ControlCurrentValue = capabilities.CurrentValue", + StringComparison.Ordinal); + Assert.True(awaitEnd >= 0 && mutation > awaitEnd); + var genericCatch = refresh.IndexOf("catch (Exception ex)", StringComparison.Ordinal); + var faultGuard = refresh.IndexOf("if (!IsCurrentRefreshTarget(signal))", + genericCatch, StringComparison.Ordinal); + var faultMutation = refresh.IndexOf("signal.ControlInspectionEvidence =", + genericCatch, StringComparison.Ordinal); + Assert.True(genericCatch >= 0 && faultGuard > genericCatch && + faultMutation > faultGuard); + var projection = refresh.IndexOf("device.RefreshCommandSignalProjection()", + StringComparison.Ordinal); + var projectGuard = refresh.LastIndexOf("if (!IsCurrentRefreshDevice())", + projection, StringComparison.Ordinal); + Assert.True(projectGuard >= 0 && projection > projectGuard); + } +}