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..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);
+ }
+
}