From b6b9fd4465366dcbb9c389cbec4c7c01515901a2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:45:23 +0700 Subject: [PATCH 1/3] Add bounded MMS file-transfer diagnostics --- .../Mms/MmsFileTransferDiagnostics.cs | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsFileTransferDiagnostics.cs diff --git a/src/AR.Iec61850/Mms/MmsFileTransferDiagnostics.cs b/src/AR.Iec61850/Mms/MmsFileTransferDiagnostics.cs new file mode 100644 index 0000000..371809f --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsFileTransferDiagnostics.cs @@ -0,0 +1,271 @@ +using System.Text; + +namespace AR.Iec61850.Mms; + +/// +/// Captures a bounded, human-readable trace for the most recent MMS file transfer. +/// The trace is intended for field interoperability diagnostics and deliberately keeps +/// the first protocol failure even when the subsequent best-effort FileClose also fails. +/// +public sealed partial class MmsClientSession +{ + private readonly object _fileTransferDiagnosticSync = new(); + private FileTransferDiagnosticState? _activeFileTransferDiagnostic; + private string _lastFileTransferDiagnosticText = string.Empty; + + /// + /// Complete diagnostic text for the most recent MMS file transfer on this session. + /// + public string LastFileTransferDiagnosticText + { + get + { + lock (_fileTransferDiagnosticSync) + return _lastFileTransferDiagnosticText; + } + } + + /// + /// Last terminal receive-pump fault, when one occurred. + /// + public string ReceivePumpFaultMessage => _receivePump.LastFaultMessage; + + private void BeginFileTransferDiagnostic(string remotePath) + { + lock (_fileTransferDiagnosticSync) + { + _activeFileTransferDiagnostic = new FileTransferDiagnosticState( + remotePath, + DateTimeOffset.UtcNow); + _lastFileTransferDiagnosticText = string.Empty; + } + } + + private void RecordFileTransferDiagnostic( + string stage, + bool success, + string message, + int? invokeId = null, + int? fileReadStateMachineId = null, + int? readOperation = null, + long bytesTransferred = 0, + bool? moreFollows = null, + string? requestHex = null, + string? responseHex = null, + Exception? exception = null) + { + lock (_fileTransferDiagnosticSync) + { + _activeFileTransferDiagnostic ??= new FileTransferDiagnosticState( + remotePath: string.Empty, + DateTimeOffset.UtcNow); + + _activeFileTransferDiagnostic.Add(new FileTransferDiagnosticEntry + { + TimestampUtc = DateTimeOffset.UtcNow, + Stage = stage, + Success = success, + Message = message, + InvokeId = invokeId, + FileReadStateMachineId = fileReadStateMachineId, + ReadOperation = readOperation, + BytesTransferred = bytesTransferred, + MoreFollows = moreFollows, + RequestHex = requestHex ?? string.Empty, + ResponseHex = responseHex ?? string.Empty, + ExceptionType = exception?.GetType().FullName ?? string.Empty, + ExceptionMessage = exception?.Message ?? string.Empty, + ReceiveRouting = LastReceiveRoutingSummary, + ReceivePumpFault = _receivePump.LastFaultMessage, + AssociationState = State.ToString(), + TransportConnected = IsTransportConnected, + ReceivePumpRunning = IsReceivePumpRunning, + PendingConfirmedOperations = PendingConfirmedOperationCount + }); + } + } + + private void CompleteFileTransferDiagnostic( + bool success, + string message, + long bytesTransferred, + int readOperations, + int? fileReadStateMachineId, + bool remoteFileClosed) + { + lock (_fileTransferDiagnosticSync) + { + _activeFileTransferDiagnostic ??= new FileTransferDiagnosticState( + remotePath: string.Empty, + DateTimeOffset.UtcNow); + + _activeFileTransferDiagnostic.CompletedUtc = DateTimeOffset.UtcNow; + _activeFileTransferDiagnostic.Success = success; + _activeFileTransferDiagnostic.CompletionMessage = message; + _activeFileTransferDiagnostic.BytesTransferred = bytesTransferred; + _activeFileTransferDiagnostic.ReadOperations = readOperations; + _activeFileTransferDiagnostic.FileReadStateMachineId = fileReadStateMachineId; + _activeFileTransferDiagnostic.RemoteFileClosed = remoteFileClosed; + _activeFileTransferDiagnostic.FinalAssociationState = State.ToString(); + _activeFileTransferDiagnostic.FinalTransportConnected = IsTransportConnected; + _activeFileTransferDiagnostic.FinalReceivePumpRunning = IsReceivePumpRunning; + _activeFileTransferDiagnostic.FinalPendingConfirmedOperations = PendingConfirmedOperationCount; + _activeFileTransferDiagnostic.FinalReceiveRouting = LastReceiveRoutingSummary; + _activeFileTransferDiagnostic.FinalReceivePumpFault = _receivePump.LastFaultMessage; + + _lastFileTransferDiagnosticText = _activeFileTransferDiagnostic.BuildText(); + _activeFileTransferDiagnostic = null; + } + } + + private sealed class FileTransferDiagnosticState + { + private const int MaximumEntries = 64; + private readonly List _entries = new(); + private FileTransferDiagnosticEntry? _primaryFailure; + private int _omittedEntryCount; + + public FileTransferDiagnosticState(string remotePath, DateTimeOffset startedUtc) + { + RemotePath = remotePath; + StartedUtc = startedUtc; + } + + public string RemotePath { get; } + public DateTimeOffset StartedUtc { get; } + public DateTimeOffset? CompletedUtc { get; set; } + public bool Success { get; set; } + public string CompletionMessage { get; set; } = string.Empty; + public long BytesTransferred { get; set; } + public int ReadOperations { get; set; } + public int? FileReadStateMachineId { get; set; } + public bool RemoteFileClosed { get; set; } + public string FinalAssociationState { get; set; } = string.Empty; + public bool FinalTransportConnected { get; set; } + public bool FinalReceivePumpRunning { get; set; } + public int FinalPendingConfirmedOperations { get; set; } + public string FinalReceiveRouting { get; set; } = string.Empty; + public string FinalReceivePumpFault { get; set; } = string.Empty; + + public void Add(FileTransferDiagnosticEntry entry) + { + if (!entry.Success && _primaryFailure == null) + _primaryFailure = entry; + + if (_entries.Count < MaximumEntries) + { + _entries.Add(entry); + return; + } + + _omittedEntryCount++; + if (!entry.Success) + _entries[^1] = entry; + } + + public string BuildText() + { + var builder = new StringBuilder(4096); + builder.AppendLine("ARSAS / ARIEC61850 MMS FILE TRANSFER DIAGNOSTIC"); + builder.AppendLine(new string('=', 72)); + builder.AppendLine($"Started UTC : {StartedUtc:O}"); + builder.AppendLine($"Completed UTC : {(CompletedUtc.HasValue ? CompletedUtc.Value.ToString("O") : "-")}"); + builder.AppendLine($"Remote path : {ValueOrDash(RemotePath)}"); + builder.AppendLine($"Result : {(Success ? "SUCCESS" : "FAILED")}"); + builder.AppendLine($"Completion : {ValueOrDash(CompletionMessage)}"); + builder.AppendLine($"FRSM : {(FileReadStateMachineId?.ToString() ?? "not-opened")}"); + builder.AppendLine($"Read operations : {ReadOperations}"); + builder.AppendLine($"Bytes transferred : {BytesTransferred}"); + builder.AppendLine($"Remote closed : {RemoteFileClosed}"); + + if (_primaryFailure != null) + { + builder.AppendLine(); + builder.AppendLine("PRIMARY FAILURE"); + builder.AppendLine(new string('-', 72)); + AppendEntry(builder, _primaryFailure); + } + + builder.AppendLine(); + builder.AppendLine("PROTOCOL TIMELINE"); + builder.AppendLine(new string('-', 72)); + if (_entries.Count == 0) + { + builder.AppendLine("No protocol operation was recorded."); + } + else + { + foreach (var entry in _entries) + { + AppendEntry(builder, entry); + builder.AppendLine(); + } + } + + if (_omittedEntryCount > 0) + builder.AppendLine($"... {_omittedEntryCount} additional successful operation(s) omitted by the bounded trace."); + + builder.AppendLine("FINAL SESSION STATE"); + builder.AppendLine(new string('-', 72)); + builder.AppendLine($"Association : {ValueOrDash(FinalAssociationState)}"); + builder.AppendLine($"Transport ready : {FinalTransportConnected}"); + builder.AppendLine($"Receive pump : {FinalReceivePumpRunning}"); + builder.AppendLine($"Pending confirmed : {FinalPendingConfirmedOperations}"); + builder.AppendLine($"Receive routing : {ValueOrDash(FinalReceiveRouting)}"); + builder.AppendLine($"Receive pump fault: {ValueOrDash(FinalReceivePumpFault)}"); + return builder.ToString().TrimEnd(); + } + + private static void AppendEntry(StringBuilder builder, FileTransferDiagnosticEntry entry) + { + builder.AppendLine($"[{entry.TimestampUtc:O}] {entry.Stage} => {(entry.Success ? "OK" : "FAILED")}"); + builder.AppendLine($"Message : {ValueOrDash(entry.Message)}"); + builder.AppendLine($"Invoke ID : {(entry.InvokeId?.ToString() ?? "-")}"); + builder.AppendLine($"FRSM : {(entry.FileReadStateMachineId?.ToString() ?? "-")}"); + builder.AppendLine($"Read operation : {(entry.ReadOperation?.ToString() ?? "-")}"); + builder.AppendLine($"Bytes before/after: {entry.BytesTransferred}"); + builder.AppendLine($"moreFollows : {(entry.MoreFollows?.ToString() ?? "-")}"); + builder.AppendLine($"Exception : {ValueOrDash(CombineException(entry))}"); + builder.AppendLine($"Association : {entry.AssociationState}; transport={entry.TransportConnected}; pump={entry.ReceivePumpRunning}; pending={entry.PendingConfirmedOperations}"); + builder.AppendLine($"Receive routing : {ValueOrDash(entry.ReceiveRouting)}"); + builder.AppendLine($"Receive pump fault: {ValueOrDash(entry.ReceivePumpFault)}"); + builder.AppendLine($"Request hex : {ValueOrDash(entry.RequestHex)}"); + builder.AppendLine($"Response hex : {ValueOrDash(entry.ResponseHex)}"); + } + + private static string CombineException(FileTransferDiagnosticEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.ExceptionType)) + return string.Empty; + return string.IsNullOrWhiteSpace(entry.ExceptionMessage) + ? entry.ExceptionType + : $"{entry.ExceptionType}: {entry.ExceptionMessage}"; + } + + private static string ValueOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + } + + private sealed class FileTransferDiagnosticEntry + { + public DateTimeOffset TimestampUtc { get; init; } + public string Stage { get; init; } = string.Empty; + public bool Success { get; init; } + public string Message { get; init; } = string.Empty; + public int? InvokeId { get; init; } + public int? FileReadStateMachineId { get; init; } + public int? ReadOperation { get; init; } + public long BytesTransferred { get; init; } + public bool? MoreFollows { get; init; } + public string RequestHex { get; init; } = string.Empty; + public string ResponseHex { get; init; } = string.Empty; + public string ExceptionType { get; init; } = string.Empty; + public string ExceptionMessage { get; init; } = string.Empty; + public string ReceiveRouting { get; init; } = string.Empty; + public string ReceivePumpFault { get; init; } = string.Empty; + public string AssociationState { get; init; } = string.Empty; + public bool TransportConnected { get; init; } + public bool ReceivePumpRunning { get; init; } + public int PendingConfirmedOperations { get; init; } + } +} \ No newline at end of file From de577db6a7449d77e98c2be80035f7cd463549de Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:46:45 +0700 Subject: [PATCH 2/3] Trace exact MMS file transfer failure stages --- .../Mms/MmsInteroperableFileTransfer.cs | 228 ++++++++++++++++-- 1 file changed, 208 insertions(+), 20 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs b/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs index 79e3a81..2df574e 100644 --- a/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs +++ b/src/AR.Iec61850/Mms/MmsInteroperableFileTransfer.cs @@ -123,6 +123,8 @@ public async Task DownloadFileInteroperableAsync( throw new ArgumentOutOfRangeException(nameof(options), "MaximumReadOperations must be greater than zero."); var normalizedPath = MmsFileNameEncoding.Normalize(remotePath); + BeginFileTransferDiagnostic(normalizedPath); + int? stateMachineId = null; long bytesTransferred = 0; long? expectedBytes = null; @@ -144,6 +146,12 @@ public async Task DownloadFileInteroperableAsync( if (expectedBytes > options.MaximumBytes) { failure = $"Remote file declares {expectedBytes.Value} byte(s), exceeding the configured limit of {options.MaximumBytes}."; + RecordFileTransferDiagnostic( + stage: "Validation after FileOpen", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + bytesTransferred: bytesTransferred); } else { @@ -154,10 +162,22 @@ public async Task DownloadFileInteroperableAsync( if (++readOperations > options.MaximumReadOperations) { failure = $"FileRead exceeded the configured operation limit of {options.MaximumReadOperations}."; + RecordFileTransferDiagnostic( + stage: "FileRead limit", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + moreFollows: moreFollows); break; } - var chunk = await FileReadInteroperableAsync(stateMachineId.Value, cancellationToken).ConfigureAwait(false); + var chunk = await FileReadInteroperableAsync( + stateMachineId.Value, + readOperations, + bytesTransferred, + cancellationToken).ConfigureAwait(false); if (!chunk.IsSuccess) { failure = chunk.Message; @@ -167,19 +187,52 @@ public async Task DownloadFileInteroperableAsync( if (chunk.Data.Length == 0 && chunk.MoreFollows) { failure = "FileRead returned an empty block while moreFollows remained true."; + RecordFileTransferDiagnostic( + stage: $"FileRead #{readOperations} validation", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + moreFollows: chunk.MoreFollows); break; } if (bytesTransferred + chunk.Data.LongLength > options.MaximumBytes) { failure = $"File transfer exceeded the configured limit of {options.MaximumBytes} byte(s)."; + RecordFileTransferDiagnostic( + stage: $"FileRead #{readOperations} size validation", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + moreFollows: chunk.MoreFollows); break; } if (chunk.Data.Length > 0) { - await destination.WriteAsync(chunk.Data.AsMemory(), cancellationToken).ConfigureAwait(false); - bytesTransferred += chunk.Data.LongLength; + try + { + await destination.WriteAsync(chunk.Data.AsMemory(), cancellationToken).ConfigureAwait(false); + bytesTransferred += chunk.Data.LongLength; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + failure = $"Local destination write failed: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: $"Local write after FileRead #{readOperations}", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + moreFollows: chunk.MoreFollows, + exception: ex); + break; + } } moreFollows = chunk.MoreFollows; @@ -199,16 +252,48 @@ public async Task DownloadFileInteroperableAsync( expectedBytes.Value != bytesTransferred) { failure = $"Transferred size {bytesTransferred} does not match declared size {expectedBytes.Value}."; + RecordFileTransferDiagnostic( + stage: "Declared-size validation", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred); } if (failure == null && options.FlushDestinationOnSuccess) - await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + { + try + { + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + failure = $"Local destination flush failed: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: "Local destination flush", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + exception: ex); + } + } } } } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { - failure = $"File transfer failed: {ex.GetType().Name}: {ex.Message}"; + failure = $"File transfer pipeline failed: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: "Transfer pipeline", + success: false, + message: failure, + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + exception: ex); } finally { @@ -217,17 +302,41 @@ public async Task DownloadFileInteroperableAsync( try { using var closeCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - var close = await FileCloseInteroperableAsync(stateMachineId.Value, closeCancellation.Token).ConfigureAwait(false); + var close = await FileCloseInteroperableAsync( + stateMachineId.Value, + bytesTransferred, + readOperations, + closeCancellation.Token).ConfigureAwait(false); remoteFileClosed = close.IsSuccess; if (!close.IsSuccess && failure == null) failure = close.Message; } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { if (failure == null) failure = "FileClose did not complete within the bounded close timeout."; + RecordFileTransferDiagnostic( + stage: "FileClose timeout", + success: false, + message: "FileClose did not complete within the bounded close timeout.", + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + exception: ex); } } + else if (stateMachineId.HasValue) + { + RecordFileTransferDiagnostic( + stage: "FileClose skipped", + success: false, + message: "FileClose was skipped because the MMS association was no longer initiated.", + fileReadStateMachineId: stateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred); + if (failure == null) + failure = "FileClose was skipped because the MMS association was no longer initiated."; + } } var success = failure == null; @@ -243,6 +352,18 @@ public async Task DownloadFileInteroperableAsync( }); } + var completionMessage = success + ? $"Downloaded {bytesTransferred} byte(s) from '{normalizedPath}' in {readOperations} FileRead operation(s); FRSM={stateMachineId}." + : $"{failure ?? "MMS file transfer failed."} RemotePath='{normalizedPath}', FRSM={stateMachineId?.ToString() ?? "not-opened"}."; + + CompleteFileTransferDiagnostic( + success, + completionMessage, + bytesTransferred, + readOperations, + stateMachineId, + remoteFileClosed); + return new MmsFileTransferResult { IsSuccess = success, @@ -251,9 +372,7 @@ public async Task DownloadFileInteroperableAsync( ExpectedBytes = expectedBytes, ReadOperations = readOperations, RemoteFileClosed = remoteFileClosed, - Message = success - ? $"Downloaded {bytesTransferred} byte(s) from '{normalizedPath}' in {readOperations} FileRead operation(s); FRSM={stateMachineId}." - : $"{failure ?? "MMS file transfer failed."} RemotePath='{normalizedPath}', FRSM={stateMachineId?.ToString() ?? "not-opened"}." + Message = completionMessage }; } @@ -263,7 +382,8 @@ private async Task FileOpenInteroperableAsync( { var invokeId = NextInvokeId(); var request = MmsFileOpenRequest.Build(invokeId, remotePath, initialPosition: 0); - LastDiscoveryRequestHex = HexDump.ToCompactString(request); + var requestHex = HexDump.ToCompactString(request); + LastDiscoveryRequestHex = requestHex; try { @@ -271,27 +391,47 @@ private async Task FileOpenInteroperableAsync( var result = MmsInteroperableFileOpenResponseDecoder.Decode(response, invokeId); LastDiscoveryResponseHex = result.ResponseHexPreview; LastDiscoveryAttemptSummary = result.Message; + RecordFileTransferDiagnostic( + stage: "FileOpen", + success: result.IsSuccess, + message: result.Message, + invokeId: invokeId, + fileReadStateMachineId: result.IsSuccess ? result.FileReadStateMachineId : null, + requestHex: requestHex, + responseHex: result.ResponseHexPreview); return result; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { + var message = $"FileOpen transport fault: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: "FileOpen", + success: false, + message: message, + invokeId: invokeId, + requestHex: requestHex, + responseHex: string.Empty, + exception: ex); await MarkProtocolFaultAsync().ConfigureAwait(false); return new MmsFileOpenResult { IsSuccess = false, - Message = $"FileOpen transport fault: {ex.GetType().Name}: {ex.Message}", - ResponseHexPreview = LastDiscoveryResponseHex + Message = message, + ResponseHexPreview = string.Empty }; } } private async Task FileReadInteroperableAsync( int fileReadStateMachineId, + int readOperation, + long bytesTransferred, CancellationToken cancellationToken) { var invokeId = NextInvokeId(); var request = MmsInteroperableFileReadRequest.Build(invokeId, fileReadStateMachineId); - LastDiscoveryRequestHex = HexDump.ToCompactString(request); + var requestHex = HexDump.ToCompactString(request); + LastDiscoveryRequestHex = requestHex; try { @@ -299,28 +439,54 @@ private async Task FileReadInteroperableAsync( var result = MmsFileReadResponseDecoder.Decode(response, invokeId, fileReadStateMachineId); LastDiscoveryResponseHex = result.ResponseHexPreview; LastDiscoveryAttemptSummary = result.Message; + RecordFileTransferDiagnostic( + stage: $"FileRead #{readOperation}", + success: result.IsSuccess, + message: result.Message, + invokeId: invokeId, + fileReadStateMachineId: fileReadStateMachineId, + readOperation: readOperation, + bytesTransferred: bytesTransferred + (result.IsSuccess ? result.Data.LongLength : 0), + moreFollows: result.IsSuccess ? result.MoreFollows : null, + requestHex: requestHex, + responseHex: result.ResponseHexPreview); return result; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { + var message = $"FileRead transport fault: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: $"FileRead #{readOperation}", + success: false, + message: message, + invokeId: invokeId, + fileReadStateMachineId: fileReadStateMachineId, + readOperation: readOperation, + bytesTransferred: bytesTransferred, + requestHex: requestHex, + responseHex: string.Empty, + exception: ex); await MarkProtocolFaultAsync().ConfigureAwait(false); return new MmsFileReadResult { IsSuccess = false, FileReadStateMachineId = fileReadStateMachineId, - Message = $"FileRead transport fault: {ex.GetType().Name}: {ex.Message}", - ResponseHexPreview = LastDiscoveryResponseHex + Message = message, + ResponseHexPreview = string.Empty }; } } private async Task FileCloseInteroperableAsync( int fileReadStateMachineId, + long bytesTransferred, + int readOperations, CancellationToken cancellationToken) { var invokeId = NextInvokeId(); var request = MmsInteroperableFileCloseRequest.Build(invokeId, fileReadStateMachineId); - LastDiscoveryRequestHex = HexDump.ToCompactString(request); + var requestHex = HexDump.ToCompactString(request); + LastDiscoveryRequestHex = requestHex; try { @@ -328,18 +494,40 @@ private async Task FileCloseInteroperableAsync( var result = MmsFileCloseResponseDecoder.Decode(response, invokeId, fileReadStateMachineId); LastDiscoveryResponseHex = result.ResponseHexPreview; LastDiscoveryAttemptSummary = result.Message; + RecordFileTransferDiagnostic( + stage: "FileClose", + success: result.IsSuccess, + message: result.Message, + invokeId: invokeId, + fileReadStateMachineId: fileReadStateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + requestHex: requestHex, + responseHex: result.ResponseHexPreview); return result; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) { + var message = $"FileClose transport fault: {ex.GetType().Name}: {ex.Message}"; + RecordFileTransferDiagnostic( + stage: "FileClose", + success: false, + message: message, + invokeId: invokeId, + fileReadStateMachineId: fileReadStateMachineId, + readOperation: readOperations, + bytesTransferred: bytesTransferred, + requestHex: requestHex, + responseHex: string.Empty, + exception: ex); await MarkProtocolFaultAsync().ConfigureAwait(false); return new MmsFileCloseResult { IsSuccess = false, FileReadStateMachineId = fileReadStateMachineId, - Message = $"FileClose transport fault: {ex.GetType().Name}: {ex.Message}", - ResponseHexPreview = LastDiscoveryResponseHex + Message = message, + ResponseHexPreview = string.Empty }; } } -} +} \ No newline at end of file From 2c73e80ac68f72f3abf660807c18c25c7d292819 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:47:15 +0700 Subject: [PATCH 3/3] Preserve primary file transfer trace in bundle failures --- .../Iec61850FaultRecordInteroperableDownloader.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs index 6f61cec..92804af 100644 --- a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs +++ b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordInteroperableDownloader.cs @@ -93,9 +93,9 @@ public static async Task DownloadAsync( if (!transfer.IsSuccess) { throw new InvalidDataException( - $"{file.RemotePath}: {transfer.Message} " + - $"Request={ValueOrDash(session.LastDiscoveryRequestHex)}; " + - $"Response={ValueOrDash(session.LastDiscoveryResponseHex)}"); + $"Fault-record file transfer failed for '{file.RemotePath}'.\n" + + $"Transfer summary: {transfer.Message}\n\n" + + session.LastFileTransferDiagnosticText); } totalBytes += transfer.BytesTransferred; @@ -186,7 +186,4 @@ private static Iec61850FaultRecordDownloadResult Fail(Iec61850FaultRecordSet rec RecordId = record.RecordId, Message = message }; - - private static string ValueOrDash(string? value) - => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); -} +} \ No newline at end of file