From a525efd902f7482c0f2349def0e769780cfb723b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:43:25 +0700 Subject: [PATCH 1/3] fix(reporting): register static receiver before RptEna and GI --- ...istentReportMonitor.StaticReceiverFirst.cs | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs new file mode 100644 index 00000000..f6c19b78 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs @@ -0,0 +1,228 @@ +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Static-DataSet activation path that closes the receiver-registration race present in the + /// legacy persistent monitor start sequence. A fast IED is allowed to emit an + /// InformationReport immediately after RptEna=true or GI=true, so the monitor must already + /// be registered before either write is issued. + /// + /// This path never creates, rebinds or deletes a DataSet. It preserves the configured static + /// DataSet/RCB authority supplied by the caller and performs only the minimum RCB ownership, + /// enable and GI writes needed to start reporting. + /// + public async Task StartStaticPersistentReportMonitorReceiverFirstAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = true, + bool deleteDynamicDataSetOnStop = false, + MmsIedModelDirectory? directory = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(plan); + + if (plan.Mode != MmsReportSubscriptionPlanMode.StaticDataSet || !plan.IsReady || plan.ReportControl == null) + { + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + Message = "Receiver-first static monitor requires a ready StaticDataSet plan with selected RCB." + }; + } + + var rcb = plan.ReportControl; + var writes = new List(); + var warnings = new List(); + var rcbSnapshots = new List(); + var dataSetSnapshots = new List(); + var originalDataSetReference = rcb.DataSetReference; + var reservationAttempted = false; + var reservationTouched = false; + var enableAttempted = false; + MmsPersistentReportMonitorSession? monitor = null; + + try + { + var beforeSnapshot = await CaptureReportControlSnapshotAsync( + rcb, + "before-static-receiver-first-start", + cancellationToken).ConfigureAwait(false); + rcbSnapshots.Add(beforeSnapshot); + + if (!string.IsNullOrWhiteSpace(plan.DataSetReference)) + { + var dataSetBefore = await CaptureDataSetSnapshotAsync( + plan.DataSetReference, + plan.Members, + "before-static-receiver-first-start", + directory, + cancellationToken).ConfigureAwait(false); + dataSetSnapshots.Add(dataSetBefore); + } + + if (rcb.Buffered && rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) + { + warnings.Add( + "BRCB ResvTms pre-reserve was skipped. Receiver-first static activation keeps the existing relay-compatible RptEna ownership path."); + } + else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + reservationAttempted = true; + var reserve = await WriteReportAttributeAsync( + rcb, + "Resv", + MmsDataValue.Boolean(true), + cancellationToken).ConfigureAwait(false); + writes.Add(reserve); + reservationTouched = reserve.IsSuccess; + if (!reserve.IsSuccess) + warnings.Add("URCB Resv write failed. Continuing only if RptEna=true is accepted by the IED."); + } + + // P0 ordering invariant: register first. From this point onward an unsolicited or GI + // InformationReport has an unambiguous active monitor to route into. + monitor = new MmsPersistentReportMonitorSession( + plan, + rcb, + originalDataSetReference, + isDynamic: false, + deleteDynamicDataSetOnStop, + dataSetCreated: false, + reservationTouched, + enabledByThisClient: false); + RegisterPersistentReportMonitor(monitor); + warnings.Add("InformationReport receiver registered before RptEna/GI (receiver-first static activation)."); + + enableAttempted = true; + var enable = await WriteReportAttributeAsync( + rcb, + "RptEna", + MmsDataValue.Boolean(true), + cancellationToken).ConfigureAwait(false); + writes.Add(enable); + monitor.EnabledByThisClient = enable.IsSuccess; + + if (!enable.IsSuccess) + { + var cleanup = await CleanupFailedStaticReceiverFirstStartAsync( + monitor, + enableAttempted, + reservationAttempted || reservationTouched).ConfigureAwait(false); + writes.AddRange(cleanup.Steps); + warnings.AddRange(cleanup.Warnings); + + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = rcbSnapshots, + DataSetSnapshots = dataSetSnapshots, + Message = "RptEna=true failed after the static receiver was registered; receiver/RCB state was rolled back best-effort." + }; + } + + var afterEnableSnapshot = await CaptureReportControlSnapshotAsync( + rcb, + "after-static-receiver-first-enable", + cancellationToken).ConfigureAwait(false); + rcbSnapshots.Add(afterEnableSnapshot); + + if (triggerGeneralInterrogation) + { + var gi = await WriteReportAttributeAsync( + rcb, + "GI", + MmsDataValue.Boolean(true), + cancellationToken).ConfigureAwait(false); + writes.Add(gi); + if (!gi.IsSuccess) + warnings.Add("GI=true write failed or is not supported by this RCB. Receiver remains active for spontaneous/integrity reports."); + } + + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = true, + Session = monitor, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = rcbSnapshots, + DataSetSnapshots = dataSetSnapshots, + Message = $"Receiver-first static persistent report monitor started for {rcb.Reference}. Receiver was registered before RptEna/GI." + }; + } + catch (OperationCanceledException) + { + if (monitor is not null) + await CleanupFailedStaticReceiverFirstStartAsync( + monitor, + enableAttempted, + reservationAttempted || reservationTouched).ConfigureAwait(false); + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + if (monitor is not null) + { + var cleanup = await CleanupFailedStaticReceiverFirstStartAsync( + monitor, + enableAttempted, + reservationAttempted || reservationTouched).ConfigureAwait(false); + writes.AddRange(cleanup.Steps); + warnings.AddRange(cleanup.Warnings); + } + + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = rcbSnapshots, + DataSetSnapshots = dataSetSnapshots, + Message = $"Receiver-first static persistent report monitor start failed: {ex.GetType().Name}: {ex.Message}" + }; + } + } + + private async Task<(IReadOnlyList Steps, IReadOnlyList Warnings)> + CleanupFailedStaticReceiverFirstStartAsync( + MmsPersistentReportMonitorSession monitor, + bool enableAttempted, + bool reservationMayHaveBeenTouched) + { + var steps = new List(); + var warnings = new List(); + + // Keep the receiver registered while disabling the RCB. If the IED emits a final report + // during disable, it is still routed deterministically and then discarded when the failed + // monitor is unregistered below. + if (enableAttempted) + { + var disable = await TryWriteReportAttributeForCleanupAsync( + monitor.ReportControl, + "RptEna", + MmsDataValue.Boolean(false), + CancellationToken.None).ConfigureAwait(false); + steps.Add(disable); + if (!disable.IsSuccess) + warnings.Add("Failed-start cleanup could not prove RptEna=false. Re-read RCB state on a fresh association before another activation attempt."); + } + + if (reservationMayHaveBeenTouched && !monitor.ReportControl.Buffered) + { + var release = await TryWriteReportAttributeForCleanupAsync( + monitor.ReportControl, + "Resv", + MmsDataValue.Boolean(false), + CancellationToken.None).ConfigureAwait(false); + steps.Add(release); + if (!release.IsSuccess) + warnings.Add("Failed-start cleanup could not prove URCB Resv=false. Re-read ownership before another activation attempt."); + } + + UnregisterPersistentReportMonitor(monitor); + monitor.IsStopped = true; + return (steps, warnings); + } +} From 38fa5a71422c27805c14facec5c7b40df1407866 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:44:12 +0700 Subject: [PATCH 2/3] fix(reporting): route static starts through receiver-first activation --- ...sPersistentReportMonitorAttemptEvidence.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index af6e046c..6a1869e0 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -98,12 +98,22 @@ public async Task StartPersistentReport } } - var start = await StartPersistentReportMonitorAsync( - plan, - triggerGeneralInterrogation, - deleteDynamicDataSetOnStop, - directory, - cancellationToken).ConfigureAwait(false); + // Static DataSet monitoring has a stricter startup contract than the legacy generic + // path: the InformationReport receiver must be registered before RptEna or GI can make + // the IED emit a report. Dynamic activation retains its existing mutation/rollback path. + var start = isDynamic + ? await StartPersistentReportMonitorAsync( + plan, + triggerGeneralInterrogation, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false) + : await StartStaticPersistentReportMonitorReceiverFirstAsync( + plan, + triggerGeneralInterrogation, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); if (probe is not null) start = MergeProbeEvidence(start, probe); From 4e19516b9ec80ba7fb6d3573014a2399a1188451 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:44:29 +0700 Subject: [PATCH 3/3] test(reporting): guard receiver-before-enable static startup ordering --- ...ticReceiverFirstActivationContractTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsStaticReceiverFirstActivationContractTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsStaticReceiverFirstActivationContractTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsStaticReceiverFirstActivationContractTests.cs new file mode 100644 index 00000000..c37c0f50 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsStaticReceiverFirstActivationContractTests.cs @@ -0,0 +1,64 @@ +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsStaticReceiverFirstActivationContractTests +{ + [Fact] + public void AttemptEvidence_StaticPlans_RouteThroughReceiverFirstActivation() + { + var source = Read("src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs"); + + Assert.Contains("var start = isDynamic", source, StringComparison.Ordinal); + Assert.Contains("StartPersistentReportMonitorAsync(", source, StringComparison.Ordinal); + Assert.Contains("StartStaticPersistentReportMonitorReceiverFirstAsync(", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticActivation_RegistersReceiverBeforeRptEnaAndGi() + { + var source = Read("src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs"); + + var register = source.IndexOf("RegisterPersistentReportMonitor(monitor);", StringComparison.Ordinal); + var enable = source.IndexOf("\"RptEna\",\n MmsDataValue.Boolean(true)", StringComparison.Ordinal); + var gi = source.IndexOf("\"GI\",\n MmsDataValue.Boolean(true)", StringComparison.Ordinal); + + Assert.True(register >= 0, "receiver registration marker missing"); + Assert.True(enable > register, "RptEna=true must occur after receiver registration"); + Assert.True(gi > enable, "GI=true must occur after RptEna=true"); + Assert.Contains("InformationReport receiver registered before RptEna/GI", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticActivation_FailedStartDisablesBeforeUnregisteringAndNeverMutatesDataSet() + { + var source = Read("src/AR.Iec61850/Mms/MmsPersistentReportMonitor.StaticReceiverFirst.cs"); + + var cleanupStart = source.IndexOf("CleanupFailedStaticReceiverFirstStartAsync", StringComparison.Ordinal); + var cleanupBody = source.LastIndexOf("CleanupFailedStaticReceiverFirstStartAsync", StringComparison.Ordinal); + var disable = source.IndexOf("\"RptEna\",\n MmsDataValue.Boolean(false)", cleanupBody, StringComparison.Ordinal); + var unregister = source.IndexOf("UnregisterPersistentReportMonitor(monitor);", cleanupBody, StringComparison.Ordinal); + + Assert.True(cleanupStart >= 0 && cleanupBody >= cleanupStart); + Assert.True(disable > cleanupBody, "failed-start cleanup must attempt RptEna=false"); + Assert.True(unregister > disable, "receiver must remain registered until disable cleanup is attempted"); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("DeleteNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"DatSet\"", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}