From 25cdc1764beaf47a174e8cd0b2244cbd58af2fe5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 18:02:05 +0700 Subject: [PATCH 1/3] safety(control): detach late native request and fence GUI publication per association --- MainWindow.xaml.cs | 44 +++++++++++- Services/Iec61850ControlRequestSnapshot.cs | 55 +++++++++++++++ Services/Iec61850MonitorRuntime.cs | 27 +++++++- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 12 +++- .../M217P0ControlSingleFlightRecoveryTests.cs | 69 +++++++++++++++++++ 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 Services/Iec61850ControlRequestSnapshot.cs 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/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/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 6132ed923..de94ef056 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -242,4 +242,73 @@ 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"); + 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("Open [01]", 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 63434a1f9ea4502771fc2f471b9ea7859450a48f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 18:06:27 +0700 Subject: [PATCH 2/3] test(control): import command request model for deterministic late-flight regression --- tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index de94ef056..75d54345e 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; From 2aab054b6cf9eb5500b1dce5ef5957eb8ba0232d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 18:10:41 +0700 Subject: [PATCH 3/3] test(control): compare actual preflight GUI value across late native mutation --- tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs index 75d54345e..5906e531e 100644 --- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs +++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs @@ -255,6 +255,10 @@ public async Task DetachedLateFlightCannotMutateGuiOwnedSignalAfterCancellation( 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, @@ -284,7 +288,7 @@ public async Task DetachedLateFlightCannotMutateGuiOwnedSignalAfterCancellation( detached.Signal.ControlCurrentValue = "Closed [10]"; detached.Signal.ControlSemanticVerified = false; nativeFinished.TrySetResult(1); - Assert.Equal("Open [01]", gui.ControlCurrentValue); + Assert.Equal(originalGuiValue, gui.ControlCurrentValue); Assert.True(gui.ControlSemanticVerified); Assert.Equal(1, Volatile.Read(ref quarantine)); }