diff --git a/FaultRecordWindow.FastWorkflow.cs b/FaultRecordWindow.FastWorkflow.cs new file mode 100644 index 00000000..eb5cfc62 --- /dev/null +++ b/FaultRecordWindow.FastWorkflow.cs @@ -0,0 +1,37 @@ +using System.Windows.Threading; + +namespace ArIED61850Tester; + +public partial class FaultRecordWindow +{ + private bool _initialFastWorkflowObserved; + + /// + /// 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. + /// + 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); + } +} \ No newline at end of file diff --git a/Services/FaultRecordTransferClient.cs b/Services/FaultRecordTransferClient.cs index c72a7bd3..57c33a9c 100644 --- a/Services/FaultRecordTransferClient.cs +++ b/Services/FaultRecordTransferClient.cs @@ -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 { @@ -110,37 +78,46 @@ public async Task 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 { @@ -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 DownloadCoreAsync( + Iec61850FaultRecordSet record, + string destinationRoot, + IProgress? 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 && @@ -179,4 +236,4 @@ private void EnsureReady() private static string ValueOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); -} +} \ No newline at end of file