From 910adaa5d427af8bc9a867688c3f14603ac22839 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 04:44:03 +0700 Subject: [PATCH 1/2] discovery: stop optional probes after association loss and retain prior evidence --- src/mms/live_discovery.cpp | 14 ++++++++--- tests/test_live_discovery.cpp | 45 +++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index b25d97ea1..85f31dac0 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -376,6 +376,10 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( } for (const auto& domain : domains) { + // A failed confirmed exchange closes the association. Keep earlier + // successful domain evidence; never manufacture additional failed + // optional probe entries for an already-disconnected peer. + if (!association_.associated()) break; try { result.names.domain_variables.emplace( domain, @@ -393,6 +397,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.diagnostics.push_back(optional_probe_error( "NamedVariable", domain, exception)); } + if (!association_.associated()) break; try { result.names.domain_variable_lists.emplace( @@ -417,12 +422,13 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.report_inventory = MmsReportInventoryBuilder::build(result.names); const auto readDataSetDirectories = [&] { - if (!options.read_data_set_directories) return; + if (!options.read_data_set_directories || !association_.associated()) return; const std::size_t count = std::min( options.maximum_data_set_directories, result.report_inventory.data_sets.size()); result.data_set_directories.reserve(count); for (std::size_t index = 0U; index < count; ++index) { + if (!association_.associated()) break; MmsDataSetDirectoryEvidence evidence; evidence.candidate = result.report_inventory.data_sets[index]; try { @@ -452,7 +458,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( }; const auto probeVariableTypes = [&] { - if (!options.probe_variable_types) return; + if (!options.probe_variable_types || !association_.associated()) return; auto candidates = build_logical_node_type_probe_candidates( result.names, options.maximum_variable_type_probes); if (options.use_verified_data_set_type_hints) { @@ -472,6 +478,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.variable_types.reserve(candidates.size()); for (const auto& candidate : candidates) { + if (!association_.associated()) break; MmsVariableTypeEvidence evidence; evidence.variable = candidate; try { @@ -514,12 +521,13 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( readDataSetDirectories(); } - if (options.probe_report_controls) { + if (options.probe_report_controls && association_.associated()) { const std::size_t count = std::min( options.maximum_report_control_probes, result.report_inventory.report_controls.size()); result.report_controls.reserve(count); for (std::size_t index = 0U; index < count; ++index) { + if (!association_.associated()) break; MmsReportControlEvidence evidence; evidence.candidate = result.report_inventory.report_controls[index]; evidence.requested_attributes = select_report_attributes( diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 7cae64465..b29ea384e 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -678,10 +678,46 @@ void disconnected_optional_probes_do_not_inflate_wire_budget() { CHECK(transport.sent().size() == 6U); // 2 handshake + 4 actual requests CHECK(result.variable_types.size() == 1U); CHECK(!result.variable_types.front().success()); - CHECK(result.data_set_directories.size() == 1U); - CHECK(!result.data_set_directories.front().success()); - CHECK(result.report_controls.size() == 1U); - CHECK(!result.report_controls.front().success()); + // Inventory still exposes the configured DataSet/RCB identity, while + // unattempted enrichment remains absent, never synthetic failures. + CHECK(result.report_inventory.data_sets.size() == 1U); + CHECK(result.report_inventory.report_controls.size() == 1U); + CHECK(result.data_set_directories.empty()); + CHECK(result.report_controls.empty()); +} + +void association_loss_preserves_completed_domain_evidence_without_dead_probes() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"OLS501LD0", "OLS501LD1"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {2U, {"LLN0$ST$Mod$stVal"}, false}))); + // Third GetNameList (DataSet names for LD0) is sent but times out. + // Do not enumerate LD1 after the association is no longer usable. + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + + mms::MmsLiveDiscoveryOptions options; + options.probe_variable_types = false; + options.read_data_set_directories = false; + options.probe_report_controls = false; + const auto result = mms::MmsLiveDiscoveryClient{association}.discover(options); + + CHECK(!association.associated()); + CHECK(result.partial()); + CHECK(result.names.domain_variables.size() == 1U); + CHECK(result.names.domain_variables.at("OLS501LD0").size() == 1U); + CHECK(result.names.domain_variable_lists.size() == 1U); + CHECK(result.names.domain_variable_lists.at("OLS501LD0").empty()); + CHECK(result.names.domain_variables.count("OLS501LD1") == 0U); + CHECK(result.wire_budget.get_name_list == 3U); + CHECK(result.wire_budget.get_variable_access_attributes == 0U); + CHECK(result.wire_budget.get_named_variable_list_attributes == 0U); + CHECK(result.wire_budget.read_report_controls == 0U); + CHECK(transport.sent().size() == 5U); // 2 handshake + exactly 3 sends } void pagination_without_forward_progress_is_rejected() { @@ -716,6 +752,7 @@ int main() { case_distinct_ln_type_probes_are_preserved(); verified_data_set_hints_restore_late_ln_type_schema(); disconnected_optional_probes_do_not_inflate_wire_budget(); + association_loss_preserves_completed_domain_evidence_without_dead_probes(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; return 0; From 2539da2f96966b048990b63b42c2c2633f46fede Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 04:50:23 +0700 Subject: [PATCH 2/2] discovery: retain all observed VMD domains when optional enrichment faults --- src/mms/live_discovery.cpp | 20 ++++++++++++-------- tests/test_live_discovery.cpp | 10 ++++++++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index 85f31dac0..e39bed3cc 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -375,25 +375,29 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( "Domain discovery exceeded the configured domain bound."); } + // VMD Domain names are already observed wire evidence, even if a later + // per-domain GetNameList faults. Keep every proven domain in the canonical + // identity inventory without inventing LN variables or DataSet contents. + for (const auto& domain : domains) { + result.names.domain_variables.try_emplace(domain); + } for (const auto& domain : domains) { // A failed confirmed exchange closes the association. Keep earlier // successful domain evidence; never manufacture additional failed // optional probe entries for an already-disconnected peer. if (!association_.associated()) break; try { - result.names.domain_variables.emplace( + result.names.domain_variables.at(domain) = get_name_list( + MmsGetNameListObjectClass::named_variable, domain, - get_name_list( - MmsGetNameListObjectClass::named_variable, - domain, - options, - result.wire_budget, - stop_token)); + options, + result.wire_budget, + stop_token); } catch (const std::exception& exception) { if (!options.continue_on_optional_probe_error) { throw; } - result.names.domain_variables.emplace(domain, std::vector{}); + // Seeded VMD domain remains with no claimed variable evidence. result.diagnostics.push_back(optional_probe_error( "NamedVariable", domain, exception)); } diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index b29ea384e..82b03b87f 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -708,11 +708,17 @@ void association_loss_preserves_completed_domain_evidence_without_dead_probes() CHECK(!association.associated()); CHECK(result.partial()); - CHECK(result.names.domain_variables.size() == 1U); + CHECK(result.names.domain_variables.size() == 2U); CHECK(result.names.domain_variables.at("OLS501LD0").size() == 1U); CHECK(result.names.domain_variable_lists.size() == 1U); CHECK(result.names.domain_variable_lists.at("OLS501LD0").empty()); - CHECK(result.names.domain_variables.count("OLS501LD1") == 0U); + CHECK(result.names.domain_variables.at("OLS501LD1").empty()); + // The second domain is observed from VMD but its variables are unknown. + // Canonical identity reconciliation must not mistake partial enumeration + // for a genuine single-domain IED. + const auto model = mms::MmsLiveModelBuilder::build(result); + CHECK(model.identity.logical_device_aliases.count("OLS501LD0") == 1U); + CHECK(model.identity.logical_device_aliases.count("OLS501LD1") == 1U); CHECK(result.wire_budget.get_name_list == 3U); CHECK(result.wire_budget.get_variable_access_attributes == 0U); CHECK(result.wire_budget.get_named_variable_list_attributes == 0U);