Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions FaultRecordWindow.FastWorkflow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Windows.Threading;

namespace ArIED61850Tester;

public partial class FaultRecordWindow
{
private bool _initialFastWorkflowObserved;

/// <summary>
/// The existing Loaded handler starts discovery automatically. This post-render
/// guard keeps the window responsive, waits for that first scan, and performs one
/// bounded reconnect/rescan when the initial automatic discovery fails.
/// </summary>
protected override async void OnContentRendered(EventArgs e)
{
base.OnContentRendered(e);
if (_initialFastWorkflowObserved)
return;

_initialFastWorkflowObserved = true;
await Dispatcher.Yield(DispatcherPriority.ContextIdle);

while (IsBusy && IsVisible)
await Task.Delay(50).ConfigureAwait(true);

if (!IsVisible || Records.Count > 0 ||
!StatusText.StartsWith("Fault-record scan failed", StringComparison.OrdinalIgnoreCase))
{
return;
}

StatusText = "Automatic file discovery is reconnecting and retrying once…";
await Task.Delay(250).ConfigureAwait(true);
if (IsVisible && !IsBusy)
await ScanAsync().ConfigureAwait(true);
}
}
173 changes: 115 additions & 58 deletions Services/FaultRecordTransferClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,39 +31,7 @@ public async Task ConnectAsync(
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sameEndpoint =
_host.Equals(normalizedHost, StringComparison.OrdinalIgnoreCase) &&
_port == normalizedPort;
if (sameEndpoint && IsSessionHealthy())
return;

// The connection operation token must not own the lifetime of a reusable MMS
// receive pump. A completed scan token is replaced before download; without this
// rebind the old cancellation would stop confirmed-service response routing while
// the association still appeared to be MmsInitiated.
if (_session.IsTransportConnected ||
_session.IsMmsInitiated ||
_session.IsReceivePumpRunning)
{
await _session.DisposeAsync().ConfigureAwait(false);
}

await _session.ConnectAsync(
normalizedHost,
normalizedPort,
TimeSpan.FromSeconds(8),
cancellationToken).ConfigureAwait(false);
await _session.RebindReceivePumpToSessionLifetimeAsync(cancellationToken).ConfigureAwait(false);

if (!IsSessionHealthy())
{
throw new InvalidOperationException(
$"The dedicated fault-record association is not operational after connect. {ConnectionState}.");
}

_host = normalizedHost;
_port = normalizedPort;
_service = new Iec61850FaultRecordService(_session);
await ConnectCoreAsync(normalizedHost, normalizedPort, cancellationToken).ConfigureAwait(false);
}
finally
{
Expand Down Expand Up @@ -110,37 +78,46 @@ public async Task<Iec61850FaultRecordDownloadResult> DownloadAsync(
try
{
EnsureReady();
var result = await Iec61850FaultRecordInteroperableDownloader.DownloadAsync(
_session,
var first = await DownloadCoreAsync(
record,
destinationRoot,
new Iec61850FaultRecordDownloadOptions
{
MaximumTotalBytes = 1024L * 1024L * 1024L,
MaximumFileBytes = 512L * 1024L * 1024L,
MaximumReadOperationsPerFile = 100_000,
// Completeness describes COMTRADE companion coverage; it must not block
// MMS FileOpen/FileRead of files that the IED actually exposes.
RequireCompleteRecord = false,
RequireDeclaredSizeMatch = false
},
progress,
cancellationToken).ConfigureAwait(false);

if (result.IsSuccess)
return result;
if (first.IsSuccess)
return first;

return new Iec61850FaultRecordDownloadResult
// A transport or receive-pump fault invalidates the MMS association. The
// downloader cleans its temporary directory, so one complete reconnect and
// bounded retry is safe and avoids turning a transient connection loss into
// an immediate user-visible failure.
if (!IsSessionHealthy() &&
!cancellationToken.IsCancellationRequested &&
!string.IsNullOrWhiteSpace(_host))
{
IsSuccess = false,
RecordId = result.RecordId,
DestinationDirectory = result.DestinationDirectory,
Files = result.Files,
BytesTransferred = result.BytesTransferred,
Message =
$"{result.Message} Dedicated session: {ConnectionState}. " +
$"Receive routing: {ValueOrDash(_session.LastReceiveRoutingSummary)}"
};
var firstFailure = first.Message;
await ConnectCoreAsync(_host, _port, cancellationToken).ConfigureAwait(false);
var recovered = await DownloadCoreAsync(
record,
destinationRoot,
progress,
cancellationToken).ConfigureAwait(false);

if (recovered.IsSuccess)
{
return CloneResult(
recovered,
$"{recovered.Message} Automatic reconnect recovered the interrupted MMS file-transfer session.");
}

return CloneResult(
recovered,
$"Initial transfer failed and the dedicated session became unhealthy. " +
$"Automatic reconnect/retry also failed. First failure: {firstFailure}\n\n" +
$"Retry failure: {BuildFailureMessage(recovered)}");
}

return CloneResult(first, BuildFailureMessage(first));
}
finally
{
Expand All @@ -163,6 +140,86 @@ public async ValueTask DisposeAsync()
}
}

private async Task ConnectCoreAsync(
string normalizedHost,
int normalizedPort,
CancellationToken cancellationToken)
{
var sameEndpoint =
_host.Equals(normalizedHost, StringComparison.OrdinalIgnoreCase) &&
_port == normalizedPort;
if (sameEndpoint && IsSessionHealthy())
return;

// The connection operation token must not own the lifetime of a reusable MMS
// receive pump. A completed scan token is replaced before download; without this
// rebind the old cancellation would stop confirmed-service response routing while
// the association still appeared to be MmsInitiated.
if (_session.IsTransportConnected ||
_session.IsMmsInitiated ||
_session.IsReceivePumpRunning)
{
await _session.DisposeAsync().ConfigureAwait(false);
}

_service = null;
await _session.ConnectAsync(
normalizedHost,
normalizedPort,
TimeSpan.FromSeconds(8),
cancellationToken).ConfigureAwait(false);
await _session.RebindReceivePumpToSessionLifetimeAsync(cancellationToken).ConfigureAwait(false);

if (!IsSessionHealthy())
{
throw new InvalidOperationException(
$"The dedicated fault-record association is not operational after connect. {ConnectionState}.");
}

_host = normalizedHost;
_port = normalizedPort;
_service = new Iec61850FaultRecordService(_session);
}

private async Task<Iec61850FaultRecordDownloadResult> DownloadCoreAsync(
Iec61850FaultRecordSet record,
string destinationRoot,
IProgress<Iec61850FaultRecordDownloadProgress>? progress,
CancellationToken cancellationToken)
=> await Iec61850FaultRecordInteroperableDownloader.DownloadAsync(
_session,
record,
destinationRoot,
new Iec61850FaultRecordDownloadOptions
{
MaximumTotalBytes = 1024L * 1024L * 1024L,
MaximumFileBytes = 512L * 1024L * 1024L,
MaximumReadOperationsPerFile = 100_000,
// Completeness describes COMTRADE companion coverage; it must not block
// MMS FileOpen/FileRead of files that the IED actually exposes.
RequireCompleteRecord = false,
RequireDeclaredSizeMatch = false
},
progress,
cancellationToken).ConfigureAwait(false);

private string BuildFailureMessage(Iec61850FaultRecordDownloadResult result)
=> $"{result.Message} Dedicated session: {ConnectionState}. " +
$"Receive routing: {ValueOrDash(_session.LastReceiveRoutingSummary)}";

private static Iec61850FaultRecordDownloadResult CloneResult(
Iec61850FaultRecordDownloadResult source,
string message)
=> new()
{
IsSuccess = source.IsSuccess,
RecordId = source.RecordId,
DestinationDirectory = source.DestinationDirectory,
Files = source.Files,
BytesTransferred = source.BytesTransferred,
Message = message
};

private bool IsSessionHealthy()
=> _session.IsMmsInitiated &&
_session.IsTransportConnected &&
Expand All @@ -179,4 +236,4 @@ private void EnsureReady()

private static string ValueOrDash(string? value)
=> string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
}
}
Loading