From a0b96118d1ba4f04e108ff1fb9321f81fd653146 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 18:50:54 +0700 Subject: [PATCH 1/5] test(integration): assemble exact #753 GOOSE and #754 command safety candidates on frozen RC --- MainWindow.IedBrowser.cs | 12 +- MainWindow.xaml.cs | 44 ++++++- Models/IedBrowserGooseInspection.cs | 36 +++++- Models/IedBrowserReadPlan.cs | 9 ++ Services/Iec61850ControlRequestSnapshot.cs | 55 ++++++++ Services/Iec61850MonitorRuntime.cs | 27 +++- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 12 +- .../IedBrowserControlStateEvidenceTests.cs | 45 +++++++ .../IedBrowserGooseInspectionTests.cs | 64 ++++++++++ ...M217DCurrentTreeEvidenceContinuityTests.cs | 118 ++++++++++++++++++ .../M217P0ControlSingleFlightRecoveryTests.cs | 74 +++++++++++ 11 files changed, 485 insertions(+), 11 deletions(-) create mode 100644 Services/Iec61850ControlRequestSnapshot.cs create mode 100644 tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 21a055141..5a03abc5c 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1707,7 +1707,13 @@ void Put(string name, string value, string authority) var wireSlots = _iedBrowserDetails.Rows .Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0) .ToArray(); - var limit = Math.Min(frameView.Leaves.Count, 128); + // Unbound allData is evidence only if the ENTIRE received PDU + // has complete, contiguous wire slots. Do not project a partial + // decoder/model mismatch as real GOOSE values or as FCDA identity. + var unbound = ordered is null + ? IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView) + : null; + var limit = Math.Min(unbound?.Count ?? 0, 128); if (ordered is not null) { foreach (var slot in wireSlots) @@ -1751,9 +1757,9 @@ _iedBrowserGooseDirectory is { } directory && { if (row.GooseSlot < 0) continue; if (row.GooseReference.Length == 0 && - ordered is null && row.GooseSlot < frameView.Leaves.Count) + ordered is null && unbound is not null && row.GooseSlot < unbound.Count) { - row.Value = frameView.Leaves[row.GooseSlot].Value; + row.Value = unbound[row.GooseSlot].Value; row.ObservationSource = "GOOSE raw allData · unbound"; row.ObservationTooltip = $"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 44e26f5ff..f6447310a 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1822,6 +1822,14 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo DismissControlShout(); var clickStopwatch = Stopwatch.StartNew(); + Guid dispatchedAssociation = Guid.Empty; + bool IsRetiredAssociation() => + dispatchedAssociation != Guid.Empty && + !_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation); + void LogStaleResult() => AddLog("WARN", device.Name, + $"CONTROL_STALE_RESULT_IGNORED: {signal.ObjectReference}; " + + $"sequence={claim.Sequence}; association={dispatchedAssociation:N}; " + + "no old control result published into the current GUI model."); signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…"; SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…"); AddLog("INFO", device.Name, @@ -1849,6 +1857,10 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo } } + dispatchedAssociation = _runtime.GetControlAssociationId(device.DeviceId); + if (dispatchedAssociation == Guid.Empty) + throw new InvalidOperationException( + "Control NOT SENT: no current MMS association after connection."); AddLog("INFO", device.Name, $"IEC 61850 control execution started: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; wire send is not assumed until native evidence is returned."); var result = await _runtime.ExecuteControlAsync( @@ -1865,8 +1877,13 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo CommandTerminationTimeoutMs = 10000, OriginCategory = "StationControl" }, - _applicationCancellation.Token); + _applicationCancellation.Token, dispatchedAssociation); + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } if (!string.IsNullOrWhiteSpace(result.ControlModelText)) signal.ControlModelText = result.ControlModelText; if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") @@ -1889,12 +1906,22 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo } catch (OperationCanceledException) { + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } signal.ControlLastResult = "Command cancelled; if Operate was already dispatched, verify actual IED position."; SetStatus($"{device.Name}: {signal.Name} command cancelled; verify the final process state."); } catch (InvalidOperationException ex) when ( ex.Message.Contains("command NOT SENT", StringComparison.OrdinalIgnoreCase)) { + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } // A rejected local admission is NOT an MMS failure and not an // UNKNOWN process outcome. Surface the precise busy/stop reason // instead of the generic "Command failed" control popup. @@ -1911,6 +1938,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo (ex.Message.Contains("in progress", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("unresolved", StringComparison.OrdinalIgnoreCase))) { + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } signal.ControlLastResult = "Command NOT SENT: " + ex.Message; AddLog("WARN", device.Name, $"CONTROL_UI_LOCAL_GATE: {signal.ObjectReference}; {ex.Message}"); @@ -1921,6 +1953,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo } catch (TimeoutException ex) { + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } signal.ControlLastResult = "Control outcome UNKNOWN — MMS flight exceeded deadline. " + "Verify IED process state and reconnect before another command."; @@ -1933,6 +1970,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo } catch (Exception ex) { + if (IsRetiredAssociation()) + { + LogStaleResult(); + return; + } signal.ControlLastResult = $"Command failed: {ex.Message}"; AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); diff --git a/Models/IedBrowserGooseInspection.cs b/Models/IedBrowserGooseInspection.cs index 246677872..d3fe77ffa 100644 --- a/Models/IedBrowserGooseInspection.cs +++ b/Models/IedBrowserGooseInspection.cs @@ -90,22 +90,52 @@ public static bool IsFrameCurrent( return matches.Length == 1 ? matches[0] : null; } + /// + /// Admit only a complete and contiguous received allData vector when no + /// exact FCDA directory can be verified. The returned entries remain + /// explicitly unbound: their position never proves an IEC reference or FC. + /// Validate the WHOLE PDU before showing even the first bounded UI rows. + /// + public static IReadOnlyList? OrderedUnboundWireEntries( + GooseStreamSnapshot frame) + { + ArgumentNullException.ThrowIfNull(frame); + var count = frame.WireDataSetEntryCount; + if (count <= 0 || count != frame.Leaves.Count) + return null; + for (var index = 0; index < count; index++) + { + var leaf = frame.Leaves[index]; + if (leaf.DataSetIndex != index || leaf.Order != index + 1 || + string.IsNullOrWhiteSpace(leaf.Value) || + leaf.Value == "") + return null; + } + return frame.Leaves; + } + public static IReadOnlyList? VerifiedMembers( GooseStreamSnapshot frame, LiveIedDataSetModel directory) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(directory); var members = directory.Members.OrderBy(x => x.Index).ToArray(); + // A cached directory is not evidence for a different received datSet. + // Bind its complete reference before interpreting any FCDA leaf. if (members.Length == 0 || frame.WireDataSetEntryCount != members.Length || frame.Leaves.Count != members.Length || - members.Select(x => x.Index).Distinct().Count() != members.Length) + string.IsNullOrWhiteSpace(directory.Reference) || + !string.Equals(Canonical(frame.DataSetReference), + Canonical(directory.Reference), StringComparison.Ordinal)) return null; - // Validate entire ordered vector before exposing any member value. + // Validate the full zero-based directory, one-based presentation order, + // and each exact wire reference/FC before publishing any member value. for (var slot = 0; slot < members.Length; slot++) { var declared = members[slot]; var wire = frame.Leaves[slot]; - if (wire.DataSetIndex != slot || + if (declared.Index != slot || wire.DataSetIndex != slot || + wire.Order != slot + 1 || Canonical(wire.SignalReference) != Canonical(declared.Reference) || !string.Equals(wire.FunctionalConstraint, declared.FunctionalConstraint, StringComparison.OrdinalIgnoreCase) || diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 69bb7157e..0cc15916c 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -440,7 +440,15 @@ public static IReadOnlyList BuildInitialPriority( // must be admitted through the same bounded read plan, not skipped // because the generic attribute walker cannot see the control block. // Only read-only NumOfSG/ActSG/LActTm; never EditSG/CnfEdit/SE. + // A syntactically valid SGCB/GoCB from an unrelated MMS domain is + // not an authority for this live association. Reuse one exact domain + // index for both bounded read-only control-state inventories. + var liveDomains = verifiedLiveModel.LogicalDevices + .Select(ld => ld.MmsDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .ToHashSet(StringComparer.Ordinal); var settings = verifiedLiveModel.SettingGroupControls + .Where(block => liveDomains.Contains(block.Domain)) .SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB")) .GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, @@ -460,6 +468,7 @@ public static IReadOnlyList BuildInitialPriority( // Limit background status reads for dense GoCB catalogs; unvisited // objects retain honest Not read evidence. var goose = verifiedLiveModel.GooseControlBlocks + .Where(block => liveDomains.Contains(block.Domain)) .SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB")) .GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, diff --git a/Services/Iec61850ControlRequestSnapshot.cs b/Services/Iec61850ControlRequestSnapshot.cs new file mode 100644 index 000000000..96277fceb --- /dev/null +++ b/Services/Iec61850ControlRequestSnapshot.cs @@ -0,0 +1,55 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Makes one disposable, command-only copy of the operator's selected signal. +/// ARIEC may update the request's SignalDefinition after an asynchronous MMS +/// reply; a retired association must never mutate a WPF-owned signal model. +/// The copy carries no process-value authority or extra observation cache. +/// +public static class Iec61850ControlRequestSnapshot +{ + public static Iec61850ControlCommandRequest Create(Iec61850ControlCommandRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return new Iec61850ControlCommandRequest + { + Signal = CopySignal(request.Signal), + ValueText = request.ValueText, + InterlockCheck = request.InterlockCheck, + SynchroCheck = request.SynchroCheck, + TestMode = request.TestMode, + Originator = request.Originator, + OriginCategory = request.OriginCategory, + FeedbackTimeoutMs = request.FeedbackTimeoutMs, + CommandTerminationTimeoutMs = request.CommandTerminationTimeoutMs + }; + } + + private static SignalDefinition CopySignal(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + var copy = new SignalDefinition + { + Name = signal.Name, + ObjectReference = signal.ObjectReference, + FunctionalConstraint = signal.FunctionalConstraint, + DataType = signal.DataType, + Category = signal.Category, + IsControlSignal = signal.IsControlSignal, + ControlCdc = signal.ControlCdc, + ControlStatusReference = signal.ControlStatusReference, + ControlValueType = signal.ControlValueType, + ControlModelReference = signal.ControlModelReference, + ControlModelText = signal.ControlModelText, + ControlCurrentValue = signal.ControlCurrentValue, + ControlSemanticVerified = signal.ControlSemanticVerified + }; + // Preserve only the authority already verified by the current MMS + // association. Never promote DataSet/SCL advisory CDC to live proof. + if (!string.IsNullOrWhiteSpace(signal.VerifiedLiveControlCdc)) + copy.AcceptVerifiedLiveControlCdc(signal.VerifiedLiveControlCdc); + return copy; + } +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0f783af40..110fd3a11 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1311,16 +1311,34 @@ public bool IsControlFlightUnavailable(string deviceId) (Iec61850ControlFlightAdmission.IsUnavailable( ref session.ControlCommandActive, ref session.ControlFlightQuarantined)); + // Each connection owns a non-reusable identity. No device ID or control + // reference alone may authorize UI results after Stop/Reconnect. + public Guid GetControlAssociationId(string deviceId) + => _sessions.TryGetValue(deviceId, out var session) && + session.Client.IsConnected && + !session.ControlLifetimeCancellation.IsCancellationRequested + ? session.ControlSessionIdentity : Guid.Empty; + + public bool IsControlAssociationCurrent(string deviceId, Guid associationId) + => associationId != Guid.Empty && + GetControlAssociationId(deviceId) == associationId; + public async Task ExecuteControlAsync( string deviceId, Iec61850ControlCommandRequest request, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid expectedAssociation = default) { ArgumentException.ThrowIfNullOrWhiteSpace(deviceId); ArgumentNullException.ThrowIfNull(request); if (!_sessions.TryGetValue(deviceId, out var session) || !session.Client.IsConnected) throw new InvalidOperationException("The IED must be connected before a command can be sent."); + if (expectedAssociation != Guid.Empty && + (session.ControlSessionIdentity != expectedAssociation || + session.ControlLifetimeCancellation.IsCancellationRequested)) + throw new InvalidOperationException( + "Control NOT SENT: MMS association changed since operator confirmation."); // One physical control target per device, regardless of DataSet aliases. // Failed concurrent admission is local: no additional MMS Operate. @@ -1354,7 +1372,12 @@ public async Task ExecuteControlAsync( { Log("INFO", session.Device.Name, $"Control execution requested: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}; IED acceptance is determined only from native MMS wire evidence."); - nativeFlight = session.Client.ExecuteControlAsync(request, nativeCancellation.Token); + // The native client writes decoded ctlModel/CDC/feedback after awaits. + // It must NEVER hold the WPF-owned mutable SignalDefinition alias: + // an old association can complete after the caller's deadline. + var detachedRequest = Iec61850ControlRequestSnapshot.Create(request); + nativeFlight = session.Client.ExecuteControlAsync( + detachedRequest, nativeCancellation.Token); result = await Iec61850ControlFlightAdmission.WaitForNativeAsync( nativeFlight, ControlFlightDeadline, cancellationToken, () => Iec61850ControlFlightAdmission.Quarantine(ref session.ControlFlightQuarantined), diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 525477f2f..f1ecb9f97 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -205,10 +205,17 @@ public Task InspectControlAsync( cancellationToken, token => _inner.ConfigureControlModelAsync(deviceId, signal, expected, requested, token)); + public Guid GetControlAssociationId(string deviceId) + => _inner.GetControlAssociationId(deviceId); + + public bool IsControlAssociationCurrent(string deviceId, Guid associationId) + => _inner.IsControlAssociationCurrent(deviceId, associationId); + public Task ExecuteControlAsync( string deviceId, Iec61850ControlCommandRequest request, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid expectedAssociation = default) // An IEC command cannot silently queue behind a bulk discovery, // browser Read All or lifecycle operation. If the single association // is busy, fail locally before a wire write; NEVER bypass its IO gate. @@ -223,7 +230,8 @@ public Task ExecuteControlAsync( request.ValueText); try { - return await _inner.ExecuteControlAsync(deviceId, request, token).ConfigureAwait(false); + return await _inner.ExecuteControlAsync( + deviceId, request, token, expectedAssociation).ConfigureAwait(false); } finally { diff --git a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs index 81873c29b..1218bd76f 100644 --- a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs +++ b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs @@ -50,6 +50,51 @@ public void UnverifiedOrConflictingSgcbNeverTriggersProbe( Assert.Empty(IedBrowserControlStateEvidence.ReadTargets(block, "SGCB")); } + [Fact] + public void InitialControlStateHydrationRejectsForeignMmsDomain() + { + var valid = Block(fields: ["ActSG"]); + var foreign = new LiveIedControlBlockModel + { + Name = "SGCB", Kind = "SettingGroupControl", + Domain = "FOREIGN", LogicalNode = "LLN0", + Reference = "FOREIGN/LLN0.SP.SGCB", + FunctionalConstraint = "SP", DiscoveryStatus = "AttributeInventoryOnly", + Attributes = ["ActSG"] + }; + var localGoose = Block("GSEControl", "GO", "BCU", + fields: ["GoEna"]); + var foreignGoose = new LiveIedControlBlockModel + { + Name = "BCU", Kind = "GSEControl", + Domain = "FOREIGN", LogicalNode = "LLN0", + Reference = "FOREIGN/LLN0.GO.BCU", + FunctionalConstraint = "GO", DiscoveryStatus = "AttributeInventoryOnly", + Attributes = ["GoEna"] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [foreign, valid], + GooseControlBlocks = [foreignGoose, localGoose] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var admitted = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Contains(admitted, target => target.Reference == "LD/LLN0.SGCB.ActSG"); + Assert.Contains(admitted, target => target.Reference == "LD/LLN0.BCU.GoEna"); + Assert.DoesNotContain(admitted, target => + target.Reference.StartsWith("FOREIGN/", StringComparison.Ordinal)); + + model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [foreign], + GooseControlBlocks = [foreignGoose] + }; + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32)); + } + [Fact] public void GocbGoEnaAndNdsComAreMmsReads_NotPassiveFrameAssumptions() { diff --git a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs index 70148c3f8..f734c2590 100644 --- a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs @@ -33,6 +33,49 @@ public void ExactCanonicalControlIdentityAndOnePublisherRequired() Assert.Null(IedBrowserGooseInspection.Select(Expected(), [right, Frame()])); } + [Fact] + public void UnboundAllDataRejectsIncompleteDuplicateOrMissingRealWireSlots() + { + var first = new GooseLeafValueSnapshot(1, 0, "wire[1]", "", "", "", + "", "Closed [10]", "", false, "Unbound"); + var second = new GooseLeafValueSnapshot(2, 1, "wire[2]", "", "", "", + "", "Good", "", false, "Unbound"); + var frame = Frame(leaves: [first, second]); + var slots = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame); + Assert.NotNull(slots); + Assert.Equal(2, slots.Count); + Assert.Same(first, slots[0]); + Assert.All(slots, slot => Assert.Empty(slot.SignalReference)); + Assert.All(slots, slot => Assert.Empty(slot.FunctionalConstraint)); + + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { WireDataSetEntryCount = 3 })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [second, first] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, first] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Order = 7 }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Value = "" }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Value = "" }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [] })); + + // The UI displays at most 128 entries; an invalid *129th* wire + // slot must still invalidate the entire PDU, including the first 128. + var large = Enumerable.Range(0, 129) + .Select(i => new GooseLeafValueSnapshot(i + 1, i, + $"wire[{i + 1}]", "", "", "", "", "Good", "", false, "Unbound")) + .ToArray(); + Assert.Equal(129, IedBrowserGooseInspection.OrderedUnboundWireEntries( + Frame(leaves: large))?.Count); + large[128] = large[128] with { DataSetIndex = 127 }; + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + Frame(leaves: large))); + } + [Fact] public void ExactSclGoCbMetadata_ProjectsOnlyConfiguredVlanAndTimingFields() { @@ -78,8 +121,28 @@ public void WireDataSetValuesOnlyIfWholeOrderedMemberIdentityMatches() { Leaves = [b, a] }, set)); Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with { WireDataSetEntryCount = 1 }, set)); + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with + { DataSetReference = "LD/LLN0$DIFFERENT" }, set)); + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with + { Leaves = [a, b with { Order = 1 }] }, set)); Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with { Leaves = [a, b with { FunctionalConstraint = "MX" }] }, set)); + var nonContiguous = new LiveIedDataSetModel + { + Reference = set.Reference, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 1, Reference = first.Reference, FunctionalConstraint = "ST" + }, + new LiveIedDataSetMemberModel + { + Index = 2, Reference = second.Reference, FunctionalConstraint = "ST" + } + ] + }; + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, nonContiguous)); var ambiguous = new LiveIedDataSetModel { Reference = set.Reference, Members = [first, first] }; Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, ambiguous)); @@ -229,6 +292,7 @@ public void BrowserHasUnboundWireAllDataFallbackWithoutRewritingLedger() "MainWindow.IedBrowser.cs")); Assert.Contains("ResolveWireDataSet(", source); Assert.Contains("allData[{slot + 1}] · unbound", source); + Assert.Contains("IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)", source); Assert.Contains("GOOSE raw allData · unbound", source); Assert.Contains("Not current", source); Assert.Contains("Received GOOSE PDU datSet", source); diff --git a/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs new file mode 100644 index 000000000..b2db7c9a1 --- /dev/null +++ b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs @@ -0,0 +1,118 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Retains the unique live-wire and initial-SGCB admission contracts of +/// historical #606 against the CURRENT RC API. No obsolete UI navigation +/// assertions, no second GOOSE decoder, no MMS mutation and no device I/O. +/// +public sealed class M217DCurrentTreeEvidenceContinuityTests +{ + [Fact] + public void ExactWireGoCbCanBeReadUnboundWithoutAnMmsDataSetInventory() + { + var first = new GooseLeafValueSnapshot(1, 0, "raw-1", "", "", "", "", + "Open [01]", "", false, "Unbound"); + var second = new GooseLeafValueSnapshot(2, 1, "raw-2", "", "", "", "", + "Good", "", false, "Unbound"); + var frame = new GooseStreamSnapshot( + "received-wire", "0x0021", "LD/LLN0$GO$BCU", "BCUGE03", + "LD/LLN0$GOOSE1", "02:00:4C:4F:5B:50", + "01:0C:CD:01:00:33", "VID 0 / PCP 4", + "1", "42", "Healthy", "20000 ms", "1", + "", "Unbound", "", "12:00:00.000", 1, 0, + false, false, [first, second], + "0", "4", 2); + // There is no MMS/SCL DatSet inventory and publisher IED label is + // absent. Only the full received GoCB reference authorizes a match. + var expected = new IedBrowserGooseExpectedIdentity( + "BCUGE", "LD/LLN0.GO.BCU", "", "BCUGE03", + 33, 1, "", "", null); + Assert.Same(frame, IedBrowserGooseInspection.Select(expected, [frame])); + var raw = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame); + Assert.NotNull(raw); + Assert.Equal(2, raw.Count); + Assert.Equal("Good", raw[1].Value); + Assert.All(raw, slot => Assert.Empty(slot.SignalReference)); + Assert.All(raw, slot => Assert.Empty(slot.FunctionalConstraint)); + Assert.Null(IedBrowserGooseInspection.Select( + expected with { GoCbRef = "" }, [frame])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame with { GoCbRef = "LD/LLN0$GO$OTHER" }])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame, frame])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame with { AppIdText = "0x0022" }])); + } + + [Fact] + public void ExactLiveSgcbInitialReadIsBoundedReadOnlyAndSkipsObservedFields() + { + var block = new LiveIedControlBlockModel + { + Kind = "SettingGroupControl", + Domain = "LD", LogicalNode = "LLN0", Name = "SGCB", + FunctionalConstraint = "SP", + DiscoveryStatus = "AttributeInventoryOnly", + Reference = "LD/LLN0.SP.SGCB", + Attributes = ["NumOfSG", "ActSG", "LActTm", "EditSG", "CnfEdit"] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [block] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var original = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Equal(3, original.Count); + Assert.All(original, entry => Assert.Equal("SP", entry.FunctionalConstraint)); + Assert.Equal(new[] { "ActSG", "LActTm", "NumOfSG" }, + original.Select(target => target.Reference.Split('.')[^1]) + .OrderBy(name => name, StringComparer.Ordinal).ToArray()); + Assert.DoesNotContain(original, target => + target.Reference.Contains("EditSG", StringComparison.Ordinal) || + target.Reference.Contains("CnfEdit", StringComparison.Ordinal)); + + Assert.True(ledger.PublishForAssociation(association, + "LD/LLN0.SGCB.ActSG", "SP", "1", "", "", "MMS Read")); + var pending = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Equal(2, pending.Count); + Assert.DoesNotContain(pending, target => + target.Reference == "LD/LLN0.SGCB.ActSG"); + Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 1)); + ledger.MarkOffline(); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32)); + } + + [Fact] + public void UnverifiedSclOrWrongFcNeverBecomesAnInitialSgcbRead() + { + LiveIedModelDiscoveryDocument Create(string status, string fc) => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = + [ + new LiveIedControlBlockModel + { + Kind = "SettingGroupControl", Domain = "LD", + LogicalNode = "LLN0", Name = "SGCB", + FunctionalConstraint = fc, + DiscoveryStatus = status, + Reference = $"LD/LLN0.{fc}.SGCB", + Attributes = ["NumOfSG", "ActSG", "EditSG", "CnfEdit"] + } + ] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("SCL", "SP"), ledger, 32)); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("AttributeInventoryOnly", "CF"), ledger, 32)); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("AttributeInventoryOnly", "SP"), ledger, 0)); + } +} diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 6132ed923..5906e531e 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -1,4 +1,5 @@ using System.Threading; +using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ARSAS.Tests; @@ -242,4 +243,77 @@ public void BrowserMustRejectNewConfirmationBeforeClaimingPhysicalCommand() Assert.Contains("CONTROL_UI_TIMEOUT", code); Assert.Contains("Control outcome UNKNOWN", code); } + [Fact] + public async Task DetachedLateFlightCannotMutateGuiOwnedSignalAfterCancellation() + { + var gui = new ArIED61850Tester.Models.SignalDefinition + { + Name = "CSWI6 Pos", ObjectReference = "LD/CSWI6.Pos", + IsControlSignal = true, ControlCdc = "DPC", + ControlStatusReference = "LD/CSWI6.Pos.stVal", + ControlSemanticVerified = true, + ControlCurrentValue = "Open [01]" + }; + gui.AcceptVerifiedLiveControlCdc("DPC"); + // SignalDefinition normalizes operator labels when live CDC is + // accepted ("Open [01]" can become "Open"). Assert immutability + // against the GUI's actual pre-flight state, not a display spelling. + var originalGuiValue = gui.ControlCurrentValue; + var request = new Iec61850ControlCommandRequest + { + Signal = gui, ValueText = "Closed [10]", TestMode = true, + FeedbackTimeoutMs = 12000 + }; + var detached = Iec61850ControlRequestSnapshot.Create(request); + Assert.NotSame(gui, detached.Signal); + Assert.Equal("DPC", detached.Signal.VerifiedLiveControlCdc); + + var nativeFinished = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var userCancelled = new CancellationTokenSource(); + var quarantine = 0; + var cancelledNative = 0; + var waiter = Iec61850ControlFlightAdmission.WaitForNativeAsync( + nativeFinished.Task, TimeSpan.FromMinutes(1), + userCancelled.Token, + () => Interlocked.Exchange(ref quarantine, 1), + () => Interlocked.Exchange(ref cancelledNative, 1)); + userCancelled.Cancel(); + await Assert.ThrowsAnyAsync(async () => await waiter); + Assert.Equal(1, Volatile.Read(ref quarantine)); + Assert.Equal(1, Volatile.Read(ref cancelledNative)); + + // The real native task did not finish when its caller stopped waiting. + // Late completion may mutate only its private detached signal copy. + detached.Signal.ControlCurrentValue = "Closed [10]"; + detached.Signal.ControlSemanticVerified = false; + nativeFinished.TrySetResult(1); + Assert.Equal(originalGuiValue, gui.ControlCurrentValue); + Assert.True(gui.ControlSemanticVerified); + Assert.Equal(1, Volatile.Read(ref quarantine)); + } + + [Fact] + public void OperatorControlPublicationUsesNonreusableAssociationFence() + { + 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 ui = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs")); + var runtime = File.ReadAllText(Path.Combine(root.FullName, + "Services/Iec61850MonitorRuntime.cs")); + var facade = File.ReadAllText(Path.Combine(root.FullName, + "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs")); + Assert.Contains("Guid dispatchedAssociation = Guid.Empty;", ui); + Assert.Contains("IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)", ui); + Assert.Contains("CONTROL_STALE_RESULT_IGNORED", ui); + Assert.Contains("_applicationCancellation.Token, dispatchedAssociation", ui); + Assert.Contains("session.ControlSessionIdentity != expectedAssociation", runtime); + Assert.Contains("Iec61850ControlRequestSnapshot.Create(request)", runtime); + Assert.Contains("detachedRequest, nativeCancellation.Token", runtime); + Assert.Contains("_inner.IsControlAssociationCurrent(deviceId, associationId)", facade); + } + } From 99f68472cad22e8939a39c5fa6906e0eeffe8e6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 19:15:04 +0700 Subject: [PATCH 2/5] safety(control): detach read-only inspection and fence typed GUI authority at association boundary --- .../Iec61850ControlInspectionPublication.cs | 57 +++++++++++ Services/Iec61850ControlRequestSnapshot.cs | 9 +- Services/Iec61850MonitorRuntime.cs | 6 +- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 46 ++++++++- .../M217P0ControlSingleFlightRecoveryTests.cs | 99 +++++++++++++++++++ 5 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 Services/Iec61850ControlInspectionPublication.cs diff --git a/Services/Iec61850ControlInspectionPublication.cs b/Services/Iec61850ControlInspectionPublication.cs new file mode 100644 index 000000000..f7ff05d59 --- /dev/null +++ b/Services/Iec61850ControlInspectionPublication.cs @@ -0,0 +1,57 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Single synchronous publication point for inspection evidence. The native +/// client owns a detached signal; only an exact current association and control +/// object may update the operator's GUI model. Never infer process state from +/// inspection status, and never turn StatusOnly into command authority. +/// +public static class Iec61850ControlInspectionPublication +{ + public static bool TryApply( + SignalDefinition guiSignal, Iec61850ControlCapabilities capabilities, + Guid expectedAssociation, Func isCurrentAssociation) + { + ArgumentNullException.ThrowIfNull(guiSignal); + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(isCurrentAssociation); + if (expectedAssociation == Guid.Empty || + !isCurrentAssociation(expectedAssociation) || + guiSignal.ControlCommandBusy || + !string.Equals(guiSignal.ObjectReference, capabilities.ObjectReference, + StringComparison.Ordinal)) + return false; + + // Do not preserve an old typed CDC after a failed/changed descriptor. + // IsOperationallyReady alone is insufficient for a read-only model. + var verified = capabilities.SupportsOperate && + !string.IsNullOrWhiteSpace(capabilities.ControlCdc); + if (!verified || + (guiSignal.VerifiedLiveControlCdc.Length > 0 && + !string.Equals(guiSignal.VerifiedLiveControlCdc, + capabilities.ControlCdc, StringComparison.OrdinalIgnoreCase))) + guiSignal.ClearLiveControlAuthority(); + + // All mutations here are synchronous on the invoking UI context: + // a native late callback never owns an ObservableObject reference. + guiSignal.ClearControlConfirmation(); + if (verified) + guiSignal.AcceptVerifiedLiveControlCdc(capabilities.ControlCdc); + else + guiSignal.ControlCdc = capabilities.ControlCdc; + guiSignal.ControlValueType = capabilities.ControlValueType; + guiSignal.ControlStatusReference = capabilities.StatusReference; + guiSignal.ControlModelReference = capabilities.ControlModelReference; + guiSignal.ControlModelText = capabilities.ControlModelText; + guiSignal.ControlCurrentValue = capabilities.CurrentValue; + guiSignal.ControlInspectionEvidence = + $"{capabilities.ControlModelReference} | {capabilities.ControlModelText} | " + + $"{capabilities.EngineControlServiceStatus} | {capabilities.DiscoveryEvidence}"; + // Operability is published last, never before the typed evidence. + guiSignal.ControlSemanticVerified = verified; + guiSignal.ControlLastResult = capabilities.EngineControlServiceStatus; + return true; + } +} diff --git a/Services/Iec61850ControlRequestSnapshot.cs b/Services/Iec61850ControlRequestSnapshot.cs index 96277fceb..9460c9849 100644 --- a/Services/Iec61850ControlRequestSnapshot.cs +++ b/Services/Iec61850ControlRequestSnapshot.cs @@ -15,7 +15,7 @@ public static Iec61850ControlCommandRequest Create(Iec61850ControlCommandRequest ArgumentNullException.ThrowIfNull(request); return new Iec61850ControlCommandRequest { - Signal = CopySignal(request.Signal), + Signal = CreateSignal(request.Signal), ValueText = request.ValueText, InterlockCheck = request.InterlockCheck, SynchroCheck = request.SynchroCheck, @@ -27,7 +27,12 @@ public static Iec61850ControlCommandRequest Create(Iec61850ControlCommandRequest }; } - private static SignalDefinition CopySignal(SignalDefinition signal) + /// + /// Detached command/control inspection model created synchronously on the + /// caller's thread. This is not a live value cache or a new MMS authority. + /// No background native task may retain the GUI-owned SignalDefinition. + /// + public static SignalDefinition CreateSignal(SignalDefinition signal) { ArgumentNullException.ThrowIfNull(signal); var copy = new SignalDefinition diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 110fd3a11..7610cd52c 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1283,8 +1283,12 @@ public async Task InspectControlAsync( { using var inspectionCancellation = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, session.ControlLifetimeCancellation.Token); + // Native inspection changes CDC, ctlModel and feedback after awaits, + // including on failure. Use private state so retired sessions can + // never mutate a WPF-owned SignalDefinition behind the facade. + var detachedSignal = Iec61850ControlRequestSnapshot.CreateSignal(signal); var capabilities = await session.Client.InspectControlAsync( - signal, inspectionCancellation.Token).ConfigureAwait(false); + detachedSignal, inspectionCancellation.Token).ConfigureAwait(false); if (!_sessions.TryGetValue(deviceId, out var current) || !ReferenceEquals(current, session) || session.ControlLifetimeCancellation.IsCancellationRequested) diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index f1ecb9f97..66efae6c3 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -184,14 +184,50 @@ public Task> StartMonitoringAsync( cancellationToken, token => _inner.ProbeControlModelAsync(deviceId, signal, token)); - public Task InspectControlAsync( + public async Task InspectControlAsync( string deviceId, SignalDefinition signal, CancellationToken cancellationToken) - => RunDeviceOperationAsync( - deviceId, - cancellationToken, - token => _inner.InspectControlAsync(deviceId, signal, token)); + { + ArgumentNullException.ThrowIfNull(signal); + // Snapshot before the first await on the invoking GUI context. Runtime + // performs the I/O on its worker lane; native never receives this + // WPF-owned signal or a reference it can mutate after disconnect. + var association = _inner.GetControlAssociationId(deviceId); + if (association == Guid.Empty) + throw new InvalidOperationException( + "Control inspection NOT SENT: no current MMS association."); + var detached = Iec61850ControlRequestSnapshot.CreateSignal(signal); + try + { + // Deliberately capture the caller context for the final view-model + // publication; RunDeviceOperationAsync owns the background work. + var capabilities = await RunDeviceOperationAsync( + deviceId, cancellationToken, + token => _inner.InspectControlAsync(deviceId, detached, token)); + if (signal.ControlCommandBusy || + !Iec61850ControlInspectionPublication.TryApply( + signal, capabilities, association, + expected => _inner.IsControlAssociationCurrent(deviceId, expected))) + throw new OperationCanceledException( + "Control inspection result belongs to an old association, " + + "a different control object, or an active command."); + return capabilities; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Native inspection failure used to revoke stale live authority. + // Do it only for this same association, on the caller's GUI + // context; never let an old native task mutate a newer row. + if (!signal.ControlCommandBusy && + _inner.IsControlAssociationCurrent(deviceId, association)) + { + signal.ClearLiveControlAuthority(); + signal.ControlModelText = "Auto-detect"; + } + throw; + } + } // The UI and the inner MMS runtime intentionally have different types. // ctlModel editing must cross this facade, just like inspection/Operate; diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 5906e531e..8579546bb 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -316,4 +316,103 @@ public void OperatorControlPublicationUsesNonreusableAssociationFence() Assert.Contains("_inner.IsControlAssociationCurrent(deviceId, associationId)", facade); } + [Fact] + public void DetachedReadOnlyInspectionAndAssociationPublicationCannotReplayOldProof() + { + var gui = new SignalDefinition + { + Name = "CSWI6 Pos", ObjectReference = "LD/CSWI6.Pos", + IsControlSignal = true, FunctionalConstraint = "CO", + ControlCdc = "DPC", ControlModelText = "Auto-detect", + ControlCurrentValue = "Open" + }; + var detached = Iec61850ControlRequestSnapshot.CreateSignal(gui); + Assert.NotSame(gui, detached); + detached.ControlCurrentValue = "Closed"; + detached.ControlModelText = "Direct Operate (DO) • Normal security"; + detached.AcceptVerifiedLiveControlCdc("DPC"); + detached.ControlSemanticVerified = true; + Assert.NotEqual(detached.ControlCurrentValue, gui.ControlCurrentValue); + Assert.False(gui.ControlSemanticVerified); + Assert.Empty(gui.VerifiedLiveControlCdc); + + var old = Guid.NewGuid(); + var current = Guid.NewGuid(); + var ready = new Iec61850ControlCapabilities + { + ObjectReference = gui.ObjectReference, + StatusReference = "LD/CSWI6.Pos.stVal", + ControlModelReference = "LD/CSWI6.Pos.ctlModel", + ControlModel = Iec61850ControlModelKind.DirectNormal, + ControlModelText = "Direct Operate (DO) • Normal security", + ControlCdc = "DPC", ControlValueType = "Dbpos", + CurrentValue = "Closed", IsOperationallyReady = true, + EngineControlServiceAvailable = true, + EngineControlServiceStatus = "Live typed descriptor verified" + }; + Assert.False(Iec61850ControlInspectionPublication.TryApply( + gui, ready, old, id => id == current)); + Assert.Equal("Open", gui.ControlCurrentValue); + Assert.False(gui.ControlSemanticVerified); + var foreignObject = new Iec61850ControlCapabilities + { + ObjectReference = "LD/CSWI7.Pos", + ControlModel = Iec61850ControlModelKind.DirectNormal, + ControlCdc = "DPC", IsOperationallyReady = true, + EngineControlServiceAvailable = true + }; + Assert.False(Iec61850ControlInspectionPublication.TryApply( + gui, foreignObject, current, _ => true)); + Assert.True(Iec61850ControlInspectionPublication.TryApply( + gui, ready, current, id => id == current)); + Assert.Equal("Closed", gui.ControlCurrentValue); + Assert.Equal("DPC", gui.VerifiedLiveControlCdc); + Assert.True(gui.ControlSemanticVerified); + Assert.True(gui.ControlSupportsOperate); + + var statusOnly = new Iec61850ControlCapabilities + { + ObjectReference = gui.ObjectReference, + ControlModel = Iec61850ControlModelKind.StatusOnly, + ControlModelText = "Status only", + ControlCdc = "DPC", CurrentValue = "-", + EngineControlServiceAvailable = true, + IsOperationallyReady = false, + EngineControlServiceStatus = "Status-only MMS read" + }; + Assert.True(Iec61850ControlInspectionPublication.TryApply( + gui, statusOnly, current, id => id == current)); + Assert.False(gui.ControlSemanticVerified); + Assert.Empty(gui.VerifiedLiveControlCdc); + Assert.False(gui.ControlSupportsOperate); + Assert.Equal("Status only", gui.ControlModelText); + Assert.Equal("Status-only MMS read", gui.ControlLastResult); + } + + + [Fact] + public void ControlInspectionNativeUsesOnlyDetachedSignalAndUiPublicationFence() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && + !File.Exists(Path.Combine(root.FullName, + "Services/Iec61850MonitorRuntime.cs"))) + root = root.Parent; + Assert.NotNull(root); + var runtime = File.ReadAllText(Path.Combine(root!.FullName, + "Services/Iec61850MonitorRuntime.cs")); + var facade = File.ReadAllText(Path.Combine(root.FullName, + "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs")); + Assert.Contains( + "var detachedSignal = Iec61850ControlRequestSnapshot.CreateSignal(signal);", + runtime); + Assert.Contains("detachedSignal, inspectionCancellation.Token", runtime); + Assert.Contains("var detached = Iec61850ControlRequestSnapshot.CreateSignal(signal);", + facade); + Assert.Contains("Iec61850ControlInspectionPublication.TryApply(", facade); + Assert.Contains("_inner.IsControlAssociationCurrent(deviceId, expected)", facade); + Assert.Contains("signal.ControlCommandBusy", facade); + Assert.Contains("signal.ClearLiveControlAuthority();", facade); + } + } From b52d4824bbadb04dc0d897b476b38bcf507cda38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 19:19:31 +0700 Subject: [PATCH 3/5] test(control): assert normalized read-only gate instead of raw status prose --- tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 8579546bb..a0ae3acde 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -386,7 +386,8 @@ public void DetachedReadOnlyInspectionAndAssociationPublicationCannotReplayOldPr Assert.Empty(gui.VerifiedLiveControlCdc); Assert.False(gui.ControlSupportsOperate); Assert.Equal("Status only", gui.ControlModelText); - Assert.Equal("Status-only MMS read", gui.ControlLastResult); + Assert.Contains("Status only", gui.ControlLastResult); + Assert.Contains("read-only", gui.ControlLastResult, StringComparison.OrdinalIgnoreCase); } From 139f2f4d64eb4903630050ab0521db11937401b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 19:48:43 +0700 Subject: [PATCH 4/5] safety(control): reject retired post-write inspection result, cancel and fault before WPF publication --- MainWindow.xaml.cs | 45 ++++++++++++---- .../Iec61850ControlInspectionPublication.cs | 17 ++++++ .../M217P0ControlSingleFlightRecoveryTests.cs | 52 +++++++++++++++++++ 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index f6447310a..78cca2344 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -2112,19 +2112,33 @@ private async Task ReinspectConfiguredControlAsync( return; } - // A successful CF Write/readback proves only ctlModel. A fresh typed - // descriptor + CDC/ctlVal/status verification must gate Operate. + // Successful CF Write/readback proves only ctlModel, not Operate. + // Pin BOTH the native association and the model's epoch before await. + // A late inspection from a retired socket must not revoke, overwrite + // or report success for the replacement association's Command Dock. + var expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId); + if (expectedMmsAssociation == Guid.Empty) + { + SetStatus($"{device.Name}: ctlModel inspection requires a current MMS association."); + return; + } + bool IsCurrentPostWriteInspection() => + Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + expectedMmsAssociation, + token => _runtime.IsControlAssociationCurrent(device.DeviceId, token), + writeAssociation, device.LiveValues.Association, + device.IsConnected, device.Signals.Contains(signal)); + signal.ControlInspectionBusy = true; try { var capabilities = await _runtime.InspectControlAsync( device.DeviceId, signal, _applicationCancellation.Token); - if (!device.IsConnected || writeAssociation != device.LiveValues.Association || - !device.Signals.Contains(signal)) + if (!IsCurrentPostWriteInspection()) { - signal.ClearLiveControlAuthority(); - signal.ControlModelText = "Auto-detect"; - SetStatus($"{device.Name}: ctlModel inspection belongs to a replaced association."); + AddLog("WARN", device.Name, + $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; " + + "an obsolete MMS association cannot publish or revoke current control evidence."); return; } signal.ControlCurrentValue = capabilities.CurrentValue; @@ -2140,16 +2154,27 @@ private async Task ReinspectConfiguredControlAsync( } catch (OperationCanceledException) { + if (!IsCurrentPostWriteInspection()) + { + AddLog("WARN", device.Name, + $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; cancelled after association replacement."); + return; + } signal.ClearLiveControlAuthority(); signal.ControlModelText = "Auto-detect"; SetStatus($"{device.Name}: ctlModel reinspection cancelled; control remains disabled."); } catch (Exception ex) { + if (!IsCurrentPostWriteInspection()) + { + AddLog("WARN", device.Name, + $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; retired inspection fault {ex.GetType().Name}."); + return; + } // An incomplete read must never re-enable Operate. Do not // masquerade as a spinner forever after an actual failure. signal.ClearLiveControlAuthority(); - signal.ControlModelText = "Auto-detect"; signal.ControlModelText = "Verification required"; signal.ControlInspectionEvidence = $"Fresh typed ctlModel inspection failed: {ex.Message}"; signal.ControlLastResult = "Command disabled: verification required."; @@ -2159,8 +2184,10 @@ private async Task ReinspectConfiguredControlAsync( } finally { + // Release the original inspection's visual busy hint, but do not + // rebuild live control projections from a retired MMS association. signal.ControlInspectionBusy = false; - if (device.IsConnected) + if (IsCurrentPostWriteInspection()) { device.RefreshCommandSignalProjection(); RebuildControlFeedbackIndex(device); diff --git a/Services/Iec61850ControlInspectionPublication.cs b/Services/Iec61850ControlInspectionPublication.cs index f7ff05d59..ed1cfd4c2 100644 --- a/Services/Iec61850ControlInspectionPublication.cs +++ b/Services/Iec61850ControlInspectionPublication.cs @@ -10,6 +10,23 @@ namespace ArIED61850Tester.Services; /// public static class Iec61850ControlInspectionPublication { + /// + /// The post-Write typed inspection is tied to two independent epochs: + /// the non-reusable MMS session GUID and the live model association. + /// Neither a canceled old read nor a late fault may clear new GUI proof. + /// + public static bool IsCurrentPostWriteTarget( + Guid expectedMmsAssociation, Func isCurrentMmsAssociation, + long expectedModelAssociation, long currentModelAssociation, + bool connected, bool belongsToCurrentModel) + { + ArgumentNullException.ThrowIfNull(isCurrentMmsAssociation); + return expectedMmsAssociation != Guid.Empty && + connected && belongsToCurrentModel && + expectedModelAssociation == currentModelAssociation && + isCurrentMmsAssociation(expectedMmsAssociation); + } + public static bool TryApply( SignalDefinition guiSignal, Iec61850ControlCapabilities capabilities, Guid expectedAssociation, Func isCurrentAssociation) diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index a0ae3acde..2baf352d8 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -416,4 +416,56 @@ public void ControlInspectionNativeUsesOnlyDetachedSignalAndUiPublicationFence() Assert.Contains("signal.ClearLiveControlAuthority();", facade); } + [Fact] + public void PostWriteInspectionRejectsRetiredMmsAndLiveModelEpochs() + { + var oldMms = Guid.NewGuid(); + var currentMms = Guid.NewGuid(); + const long writeModelEpoch = 54; + bool Current(Guid association) => association == currentMms; + Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + Guid.Empty, Current, writeModelEpoch, writeModelEpoch, true, true)); + Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + oldMms, Current, writeModelEpoch, writeModelEpoch, true, true)); + Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + currentMms, Current, writeModelEpoch, writeModelEpoch + 1, true, true)); + Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + currentMms, Current, writeModelEpoch, writeModelEpoch, false, true)); + Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + currentMms, Current, writeModelEpoch, writeModelEpoch, true, false)); + Assert.True(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget( + currentMms, Current, writeModelEpoch, writeModelEpoch, true, true)); + } + + [Fact] + public void PostWriteLateCancellationAndFailureNeverRevokeCurrentControlProof() + { + 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 code = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs")); + var begin = code.IndexOf("private async Task ReinspectConfiguredControlAsync(", StringComparison.Ordinal); + var end = code.IndexOf("private void RemoveControlFeedbackIndex(", begin, StringComparison.Ordinal); + Assert.True(begin >= 0 && end > begin); + var postWrite = code[begin..end]; + Assert.Contains("expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId)", postWrite); + Assert.Contains("Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(", postWrite); + Assert.Contains("writeAssociation, device.LiveValues.Association", postWrite); + Assert.Contains("CTL_MODEL_POSTWRITE_STALE_IGNORED", postWrite); + var guardCount = postWrite.Split("if (!IsCurrentPostWriteInspection())").Length - 1; + Assert.Equal(3, guardCount); // success, cancellation, fault + var cancelled = postWrite.IndexOf("catch (OperationCanceledException)", StringComparison.Ordinal); + var failed = postWrite.IndexOf("catch (Exception ex)", StringComparison.Ordinal); + Assert.True(cancelled > 0 && failed > cancelled); + Assert.True(postWrite.IndexOf("if (!IsCurrentPostWriteInspection())", cancelled, + StringComparison.Ordinal) < postWrite.IndexOf("signal.ClearLiveControlAuthority();", cancelled, + StringComparison.Ordinal)); + Assert.True(postWrite.IndexOf("if (!IsCurrentPostWriteInspection())", failed, + StringComparison.Ordinal) < postWrite.IndexOf("signal.ClearLiveControlAuthority();", failed, + StringComparison.Ordinal)); + Assert.Contains("if (IsCurrentPostWriteInspection())", postWrite); + } + } From 7d235554225aa0341561b4e887a852fb6fd8e269 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 19:51:03 +0700 Subject: [PATCH 5/5] safety(control): enforce post-write model epoch at facade publication and fault boundary --- MainWindow.xaml.cs | 6 +++- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 13 ++++++- .../M217P0ControlSingleFlightRecoveryTests.cs | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 78cca2344..1e9764827 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -2132,8 +2132,12 @@ bool IsCurrentPostWriteInspection() => signal.ControlInspectionBusy = true; try { + // The predicate is evaluated by the facade on the WPF caller + // context BEFORE it mutates SignalDefinition or handles a fault. + // A post-await-only check here would be too late. var capabilities = await _runtime.InspectControlAsync( - device.DeviceId, signal, _applicationCancellation.Token); + device.DeviceId, signal, _applicationCancellation.Token, + IsCurrentPostWriteInspection); if (!IsCurrentPostWriteInspection()) { AddLog("WARN", device.Name, diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 66efae6c3..ef84f3939 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -187,9 +187,16 @@ public Task> StartMonitoringAsync( public async Task InspectControlAsync( string deviceId, SignalDefinition signal, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? stillCurrentPublicationTarget = null) { ArgumentNullException.ThrowIfNull(signal); + // Caller-owned model epoch is checked on the invoking UI context. + // The runtime's own nonreusable association GUID remains mandatory. + if (stillCurrentPublicationTarget is not null && + !stillCurrentPublicationTarget()) + throw new OperationCanceledException( + "Control inspection NOT SENT: the selected live model changed."); // Snapshot before the first await on the invoking GUI context. Runtime // performs the I/O on its worker lane; native never receives this // WPF-owned signal or a reference it can mutate after disconnect. @@ -206,6 +213,8 @@ public async Task InspectControlAsync( deviceId, cancellationToken, token => _inner.InspectControlAsync(deviceId, detached, token)); if (signal.ControlCommandBusy || + (stillCurrentPublicationTarget is not null && + !stillCurrentPublicationTarget()) || !Iec61850ControlInspectionPublication.TryApply( signal, capabilities, association, expected => _inner.IsControlAssociationCurrent(deviceId, expected))) @@ -220,6 +229,8 @@ public async Task InspectControlAsync( // Do it only for this same association, on the caller's GUI // context; never let an old native task mutate a newer row. if (!signal.ControlCommandBusy && + (stillCurrentPublicationTarget is null || + stillCurrentPublicationTarget()) && _inner.IsControlAssociationCurrent(deviceId, association)) { signal.ClearLiveControlAuthority(); diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 2baf352d8..94d503b51 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -454,6 +454,9 @@ public void PostWriteLateCancellationAndFailureNeverRevokeCurrentControlProof() Assert.Contains("Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(", postWrite); Assert.Contains("writeAssociation, device.LiveValues.Association", postWrite); Assert.Contains("CTL_MODEL_POSTWRITE_STALE_IGNORED", postWrite); + Assert.Contains("IsCurrentPostWriteInspection);", postWrite); + Assert.Contains("predicate is evaluated by the facade", postWrite, + StringComparison.OrdinalIgnoreCase); var guardCount = postWrite.Split("if (!IsCurrentPostWriteInspection())").Length - 1; Assert.Equal(3, guardCount); // success, cancellation, fault var cancelled = postWrite.IndexOf("catch (OperationCanceledException)", StringComparison.Ordinal); @@ -468,4 +471,37 @@ public void PostWriteLateCancellationAndFailureNeverRevokeCurrentControlProof() Assert.Contains("if (IsCurrentPostWriteInspection())", postWrite); } + [Fact] + public void FacadeChecksModelEpochBeforePublishingAndBeforeFailureRevocation() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && + !File.Exists(Path.Combine(root.FullName, + "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"))) + root = root.Parent; + Assert.NotNull(root); + var code = File.ReadAllText(Path.Combine(root!.FullName, + "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs")); + var begin = code.IndexOf( + "public async Task InspectControlAsync(", + StringComparison.Ordinal); + var end = code.IndexOf("public Task", + begin, StringComparison.Ordinal); + Assert.True(begin >= 0 && end > begin); + var inspect = code[begin..end]; + Assert.Contains("Func? stillCurrentPublicationTarget = null", inspect); + Assert.Contains("Control inspection NOT SENT: the selected live model changed", inspect); + Assert.Contains("!Iec61850ControlInspectionPublication.TryApply(", inspect); + var apply = inspect.IndexOf("!Iec61850ControlInspectionPublication.TryApply(", + StringComparison.Ordinal); + Assert.True(inspect.LastIndexOf("!stillCurrentPublicationTarget()", apply, + StringComparison.Ordinal) >= 0); + var catchBegin = inspect.IndexOf("catch (Exception ex)", StringComparison.Ordinal); + var revoke = inspect.IndexOf("signal.ClearLiveControlAuthority();", + catchBegin, StringComparison.Ordinal); + Assert.True(catchBegin >= 0 && revoke > catchBegin); + Assert.True(inspect.IndexOf("stillCurrentPublicationTarget()", catchBegin, + StringComparison.Ordinal) < revoke); + } + }