Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
51432b1
P0 Command: association-wide single-flight and quarantined end-to-end…
masarray Oct 16, 2026
e12ef49
P0 Command UI: reject second same-IED dispatch and show uncertain tim…
masarray Oct 16, 2026
6016f06
P0 Command: share atomic per-association admission and quarantine policy
masarray Oct 16, 2026
e491287
test: association-wide command concurrency, sticky quarantine and unk…
masarray Oct 16, 2026
2e77385
fix: keep UI busy hint on existing CommandSignals; runtime owns autho…
masarray Oct 16, 2026
1b38e9a
test: require atomic IEC control flight admission instead of incremen…
masarray Oct 16, 2026
2113f90
fix(p0): isolate control target claims per association and bound disc…
masarray Oct 17, 2026
65645c5
test(p0): guard session-scoped control claims and bounded disconnect …
masarray Oct 17, 2026
907f63f
fix(p0): quarantine uncertain ctlModel writes and stop monitor IO on …
masarray Oct 17, 2026
c767b1b
fix(p0): revoke old association control authority before teardown and…
masarray Oct 17, 2026
1fe4de8
fix(p0): suppress late native control metadata mutations after sessio…
masarray Oct 17, 2026
643e679
feat(p0): testable native wait boundary with sticky unknown-outcome q…
masarray Oct 17, 2026
17ea3fe
refactor(p0): share native timeout/quarantine boundary across Operate…
masarray Oct 17, 2026
1e32341
test(p0): deterministic native deadline/cancel/late-completion and te…
masarray Oct 17, 2026
85eceee
fix(p0): quarantine any canceled native flight without proven NotSent…
masarray Oct 17, 2026
cf09ef3
test(p0): native cancellation after possible dispatch must remain qua…
masarray Oct 17, 2026
9135fdf
fix(p0): make native-only cancellation explicit UNKNOWN instead of si…
masarray Oct 17, 2026
63e0246
fix(p0): cancel control inspection on association retirement
masarray Oct 17, 2026
ec1e985
fix(p0): guard late control inspection signal writes after cancellation
masarray Oct 17, 2026
35caadd
fix(p0): restore ctlModel diagnostic severity ternary in WPF build
masarray Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,28 @@ await Task.WhenAll(candidates.Select(async signal =>
SetStatus($"{device.Name}: refreshed {candidates.Length} control value(s).");
}

private bool RejectCommandWhenIedFlightUnavailable(
Iec61850MonitorDevice? device, SignalDefinition signal)
{
// The UI is advisory. The runtime's atomic association-wide gate is
// authoritative, including sticky uncertain-outcome quarantine.
// Reuse existing CommandSignals state instead of mirroring a new
// runtime busy flag into the WPF view model.
if (device is null ||
!device.CommandSignals.Any(active => active.ControlCommandBusy))
return false;
const string reason =
"another IED command is still active or its wire outcome is unresolved";
signal.ControlLastResult = "Command not sent: " + reason +
". Verify process status before recovery.";
AddLog("WARN", device.Name,
$"CONTROL_UI_SINGLE_FLIGHT: rejected {signal.ObjectReference}; " +
"same-IED control flight already active or quarantined; no Operate requested.");
SetStatus($"{device.Name}: {signal.Name} — command not sent; {reason}.");
ShowControlShout(Iec61850ControlShout.FromLocalRejection(reason));
return true;
}

private async void ControlStageAction_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal ||
Expand All @@ -1502,6 +1524,8 @@ private async void ControlStageAction_Click(object sender, RoutedEventArgs e)
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (device is null || !device.IsConnected || signal.ControlInspectionBusy || signal.ControlCommandBusy)
return;
if (RejectCommandWhenIedFlightUnavailable(device, signal))
return;
DismissControlShout();

// A live Direct/SBO model permits a preflight inspection, not an
Expand Down Expand Up @@ -1568,6 +1592,8 @@ private async void ControlConfirmAction_Click(object sender, RoutedEventArgs e)
return;

var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (RejectCommandWhenIedFlightUnavailable(device, signal))
return;
AddLog("INFO", device?.Name ?? "IED",
$"Confirm click received: {signal.ObjectReference}; pending={signal.ControlConfirmationPending}; action={signal.ControlPendingAction}; value={signal.ControlPendingValue}; commandBusy={signal.ControlCommandBusy}; inspectionBusy={signal.ControlInspectionBusy}.");

Expand Down Expand Up @@ -1615,6 +1641,8 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e)
}

var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (RejectCommandWhenIedFlightUnavailable(device, signal))
return;
if (!signal.TryBeginDirectControlCommand(
requestedValue,
button.Content?.ToString()?.Trim() ?? requestedValue,
Expand Down Expand Up @@ -1710,8 +1738,33 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (OperationCanceledException)
{
signal.ControlLastResult = "Command cancelled.";
SetStatus($"{device.Name}: {signal.Name} command cancelled.");
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("control", StringComparison.OrdinalIgnoreCase) &&
(ex.Message.Contains("in progress", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("unresolved", StringComparison.OrdinalIgnoreCase)))
{
signal.ControlLastResult = "Command NOT SENT: " + ex.Message;
AddLog("WARN", device.Name,
$"CONTROL_UI_LOCAL_GATE: {signal.ObjectReference}; {ex.Message}");
SetStatus($"{device.Name}: {signal.Name} — command not sent; prior flight active or unresolved.");
MarkDiagnosticAlert();
ShowControlShout(Iec61850ControlShout.FromLocalRejection(
"another control flight is active or unresolved. Verify actual IED status before retrying"));
}
catch (TimeoutException ex)
{
signal.ControlLastResult =
"Control outcome UNKNOWN — MMS flight exceeded deadline. " +
"Verify IED process state and reconnect before another command.";
AddLog("ERROR", device.Name,
$"CONTROL_UI_TIMEOUT: {signal.ObjectReference}; {ex.Message}");
SetStatus($"{device.Name}: {signal.Name} control outcome UNKNOWN; check Diagnostics.");
MarkDiagnosticAlert();
ShowControlShout(Iec61850ControlShout.FromLocalRejection(
"Control timed out with UNKNOWN wire outcome. Verify the IED position and reconnect; do not blindly retry"));
}
catch (Exception ex)
{
Expand Down
62 changes: 62 additions & 0 deletions Services/Iec61850ControlFlightAdmission.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Threading;

namespace ArIED61850Tester.Services;

/// <summary>
/// Atomic association-wide admission for one IEC 61850 control flight.
/// Quarantine is sticky for this session after an uncertain wire outcome.
/// This is ownership only: no value ledger, MMS protocol or second transport.
/// </summary>
public static class Iec61850ControlFlightAdmission
{
public static bool IsUnavailable(ref int active, ref int quarantine)
=> Volatile.Read(ref active) != 0 || Volatile.Read(ref quarantine) != 0;

public static bool TryEnter(ref int active, ref int quarantine)
{
if (Volatile.Read(ref quarantine) != 0 ||
Interlocked.CompareExchange(ref active, 1, 0) != 0)
return false;
if (Volatile.Read(ref quarantine) == 0) return true;
Interlocked.Exchange(ref active, 0);
return false;
}

/// <summary>
/// Bounds only the caller's wait, never the actual native lifetime.
/// An unresolved native operation remains owned by its session until its
/// own Task finishes; the caller must retain admission until then.
/// </summary>
public static async Task<T> WaitForNativeAsync<T>(
Task<T> nativeFlight, TimeSpan deadline, CancellationToken waitCancellation,
Action quarantine, Action cancelNative)
{
ArgumentNullException.ThrowIfNull(nativeFlight);
ArgumentNullException.ThrowIfNull(quarantine);
ArgumentNullException.ThrowIfNull(cancelNative);
try
{
return await nativeFlight.WaitAsync(deadline, waitCancellation).ConfigureAwait(false);
}
catch (TimeoutException)
{
quarantine();
cancelNative();
throw;
}
catch (OperationCanceledException)
{
// Native cancellation may occur *after* a Write/Operate reached
// the wire. A completed canceled Task is not proof of NotSent.
quarantine();
cancelNative();
throw;
}
}

public static void Quarantine(ref int quarantine)
=> Interlocked.Exchange(ref quarantine, 1);

public static void Release(ref int active)
=> Interlocked.Exchange(ref active, 0);
}
Loading
Loading