From 454a1aff360f7b7df2dda8d6cac19adcfe79b701 Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Thu, 11 Jun 2026 23:29:33 -0400 Subject: [PATCH 1/4] tests: only build ASN.1-bound tests when LIBE3_ENABLE_ASN1 is ON test_asn1_size drives the APER encoder directly and test_e2e_report_path hardcodes EncodingFormat::ASN1 for both the agent under test and its fake-dApp peer. On a JSON-only build (-DLIBE3_ENABLE_ASN1=OFF) the encoder factory returns nullptr for ASN.1, so both tests fail with 'encoder unavailable' (-12) rather than telling us anything about the build. Exclude the two targets at configure time when ASN.1 support is compiled out, mirroring the existing test_json_encoder skip. The tests themselves are unchanged, so ASN.1-enabled builds keep full coverage. --- cmake/libe3Tests.cmake | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmake/libe3Tests.cmake b/cmake/libe3Tests.cmake index 03e9d10..a965142 100644 --- a/cmake/libe3Tests.cmake +++ b/cmake/libe3Tests.cmake @@ -16,6 +16,16 @@ target_include_directories(libe3_test_framework INTERFACE file(GLOB LIBE3_TEST_SOURCES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "tests/*.cpp") +# Tests that hardcode the ASN.1 wire format: test_asn1_size exercises the +# APER encoder directly, and test_e2e_report_path configures its agent and +# fake-dApp peer with EncodingFormat::ASN1. On builds with +# LIBE3_ENABLE_ASN1=OFF the encoder factory cannot create an ASN.1 encoder, +# so these tests can only fail; exclude them instead of weakening them. +set(LIBE3_ASN1_ONLY_TESTS + asn1_size + e2e_report_path +) + foreach(test_src IN LISTS LIBE3_TEST_SOURCES) # Derive a target name from the source file name: tests/test_foo.cpp -> test_foo get_filename_component(test_name ${test_src} NAME_WE) @@ -27,6 +37,10 @@ foreach(test_src IN LISTS LIBE3_TEST_SOURCES) message(STATUS "Skipping test_json_encoder: JSON support disabled") continue() endif() + if(NOT LIBE3_ENABLE_ASN1 AND simple_name IN_LIST LIBE3_ASN1_ONLY_TESTS) + message(STATUS "Skipping test_${simple_name}: ASN.1 support disabled") + continue() + endif() add_executable(${target_name} "${CMAKE_CURRENT_SOURCE_DIR}/${test_src}") target_link_libraries(${target_name} From 64c8c195dbaba792de9deca43af0fe7879100343 Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Thu, 11 Jun 2026 23:40:18 -0400 Subject: [PATCH 2/4] core: never leave the setup REP socket without a reply The RAN side of the setup channel is a ZMQ REP socket, which must send exactly one reply per received request before it can receive again. The setup loop bailed out without replying whenever the incoming message could not be answered (undecodable bytes from a wrong-encoding or misbehaving dApp, an unexpected PDU type, or a SetupResponse encode failure), leaving the socket stuck in the send state: every subsequent zmq_recv fails and all future dApp setups stall until the connector happens to reset or the agent restarts. Complete the REQ/REP exchange on every such path with a best-effort empty reply (new send_empty_setup_reply helper). The peer treats the empty reply as a failed setup and retries; the setup channel stays usable for the next dApp. On the POSIX connector, which has no REP state machine, the empty send is a harmless zero-length frame. Malformed-message logs are demoted to one concise warn line per message. Add test_setup_bad_request: a raw ZMQ REQ peer sends garbage to a running agent and must still get a reply, and a well-formed SetupRequest on the same socket afterwards must still receive a positive SetupResponse. The test fails by timeout without this fix and uses whichever encoding the build provides (JSON preferred), so it runs on JSON-only builds. --- include/libe3/e3_interface.hpp | 11 +++ src/core/e3_interface.cpp | 43 +++++++-- tests/test_setup_bad_request.cpp | 152 +++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 tests/test_setup_bad_request.cpp diff --git a/include/libe3/e3_interface.hpp b/include/libe3/e3_interface.hpp index af00c61..95b84ab 100644 --- a/include/libe3/e3_interface.hpp +++ b/include/libe3/e3_interface.hpp @@ -308,6 +308,17 @@ class E3Interface { void handle_release_message(const ReleaseMessage &release); void handle_dapp_disconnection(uint32_t dapp_id); + /** + * @brief Complete a setup REQ/REP exchange with a best-effort empty reply. + * + * Used whenever a received setup message cannot be answered with a real + * SetupResponse (undecodable bytes, wrong PDU type, response encode + * failure). A ZMQ REP socket must send exactly one reply per received + * request before it can receive again; bailing out without replying + * wedges the setup channel for every subsequent dApp until restart. + */ + void send_empty_setup_reply(); + // dApp-role handlers void handle_setup_response(const SetupResponse& response); void handle_subscription_response(const SubscriptionResponse& response); diff --git a/src/core/e3_interface.cpp b/src/core/e3_interface.cpp index 7f3847e..cd12a0a 100644 --- a/src/core/e3_interface.cpp +++ b/src/core/e3_interface.cpp @@ -315,26 +315,32 @@ void E3Interface::setup_loop_ran() { E3_LOG_INFO(LOG_TAG) << "Setup request received: " << ret << " bytes"; - // Decode the setup request + // Decode the setup request. On any failure we must still complete + // the REQ/REP exchange (see send_empty_setup_reply) or the setup + // channel wedges for every subsequent dApp. auto decode_result = encoder_->decode(buffer.data(), static_cast(ret)); if (!decode_result) { - E3_LOG_ERROR(LOG_TAG) << "Failed to decode setup request; ret=" << ret; + E3_LOG_WARN(LOG_TAG) << "Undecodable setup message (" << ret + << " bytes, wrong encoding or garbage); replying empty"; + send_empty_setup_reply(); continue; } - + Pdu& pdu = *decode_result; if (pdu.type != PduType::SETUP_REQUEST) { - E3_LOG_ERROR(LOG_TAG) << "Unexpected PDU type in setup: " - << pdu_type_to_string(pdu.type); + E3_LOG_WARN(LOG_TAG) << "Unexpected PDU type in setup: " + << pdu_type_to_string(pdu.type) << "; replying empty"; + send_empty_setup_reply(); continue; } - + auto* request = std::get_if(&pdu.choice); if (!request) { - E3_LOG_ERROR(LOG_TAG) << "Failed to get SetupRequest from PDU"; + E3_LOG_WARN(LOG_TAG) << "Failed to get SetupRequest from PDU; replying empty"; + send_empty_setup_reply(); continue; } - + handle_setup_request(*request, pdu.message_id); } @@ -603,9 +609,11 @@ void E3Interface::handle_setup_request(const SetupRequest& request, uint32_t req if (!encode_result) { E3_LOG_ERROR(LOG_TAG) << "Failed to encode setup response for request id " << request_message_id; + // Still complete the REQ/REP exchange so the setup channel survives. + send_empty_setup_reply(); return; } - + ErrorCode send_result = connector_->send_response(encode_result->buffer); if (send_result != ErrorCode::SUCCESS) { E3_LOG_ERROR(LOG_TAG) << "Failed to send setup response for request id " << request_message_id @@ -615,6 +623,23 @@ void E3Interface::handle_setup_request(const SetupRequest& request, uint32_t req } } +void E3Interface::send_empty_setup_reply() { + // The RAN side of the setup channel is a ZMQ REP socket: it must send + // exactly one reply per received request before it can receive again. + // Returning without replying (undecodable request, wrong PDU type, + // response-encode failure) leaves the socket in the send state, where + // every later recv fails — all future dApp setups stall until the + // connector resets or the agent restarts. Completing the exchange with + // an empty frame keeps the channel alive; the peer treats the empty + // reply as a failed setup and retries. On connectors without a REP + // state machine (POSIX) the empty send is harmless. + ErrorCode rc = connector_->send_response({}); + if (rc != ErrorCode::SUCCESS) { + E3_LOG_WARN(LOG_TAG) << "Failed to send empty setup reply: " + << error_code_to_string(rc); + } +} + void E3Interface::handle_subscription_request(const SubscriptionRequest& request, uint32_t request_message_id) { E3_LOG_INFO(LOG_TAG) << "Handling subscription request from dApp " << request.dapp_identifier << " for RAN function " << request.ran_function_identifier; diff --git a/tests/test_setup_bad_request.cpp b/tests/test_setup_bad_request.cpp new file mode 100644 index 0000000..70bc7f1 --- /dev/null +++ b/tests/test_setup_bad_request.cpp @@ -0,0 +1,152 @@ +/** + * @file test_setup_bad_request.cpp + * @brief Regression test: a malformed setup request must not wedge the + * RAN's setup REP socket. + * + * The RAN side of the E3 setup channel is a ZMQ REP socket. The REP state + * machine requires exactly one reply per received request before the socket + * can receive again. If the agent bails out of the setup loop without + * replying (e.g. the incoming bytes do not decode — wrong encoding dialect, + * or plain garbage), the socket is stuck in the send state and every later + * setup request from any dApp stalls until the agent restarts. + * + * Properties verified, using a raw ZMQ REQ peer against a real E3Agent: + * + * (1) Sending undecodable bytes on the setup channel produces a reply + * (best-effort empty), i.e. the REQ/REP exchange always completes. + * + * (2) After the garbage exchange, a well-formed SetupRequest on the very + * same channel still receives a positive SetupResponse — the setup + * channel survived the malformed message. + * + * The test uses whichever encoding the build provides (JSON preferred) so + * it runs on JSON-only, ASN.1-only, and dual-encoder builds. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "test_framework.hpp" +#include "libe3/libe3.hpp" +#include "libe3/e3_agent.hpp" +#include "libe3/e3_encoder.hpp" +#include "libe3/types.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include // getpid + +using namespace libe3; +using namespace std::chrono_literals; + +namespace { + +inline int error_to_int(ErrorCode e) { return static_cast(e); } + +/// Counter so each test instance uses distinct IPC namespaces — required +/// when running ctest -j to avoid socket-file collisions. +std::atomic g_seq{0}; + +std::string unique_ipc(const char* tag) { + std::ostringstream oss; + oss << "ipc:///tmp/dapps/badsetup_test_" << getpid() << "_" + << g_seq.fetch_add(1) << "_" << tag; + return oss.str(); +} + +/// Pick an encoding that is compiled into this build (JSON preferred so the +/// test also runs on JSON-only builds). +EncodingFormat pick_encoding() { +#if defined(LIBE3_ENABLE_JSON) + return EncodingFormat::JSON; +#else + return EncodingFormat::ASN1; +#endif +} + +} // namespace + +TEST(SetupChannel_garbageRequest_repliesAndChannelSurvives) { + const std::string setup_ep = unique_ipc("setup"); + const std::string sub_ep = unique_ipc("inbound"); + const std::string pub_ep = unique_ipc("outbound"); + const EncodingFormat encoding = pick_encoding(); + + E3Config cfg; + cfg.role = E3Role::RAN; + cfg.link_layer = E3LinkLayer::ZMQ; + cfg.transport_layer = E3TransportLayer::IPC; + cfg.setup_endpoint = setup_ep; + cfg.subscriber_endpoint = sub_ep; + cfg.publisher_endpoint = pub_ep; + cfg.encoding = encoding; + cfg.log_level = 0; + cfg.ran_identifier = "badsetup-test"; + + E3Agent agent(std::move(cfg)); + ASSERT_EQ(error_to_int(agent.start()), error_to_int(ErrorCode::SUCCESS)); + ASSERT_TRUE(agent.is_running()); + + // The REP socket is bound synchronously inside start(); a short settle + // keeps the IPC connect race-free. + std::this_thread::sleep_for(100ms); + + void* ctx = zmq_ctx_new(); + ASSERT_TRUE(ctx != nullptr); + void* req = zmq_socket(ctx, ZMQ_REQ); + ASSERT_TRUE(req != nullptr); + int recv_timeout = 5000; // a wedged REP socket shows up as a timeout + zmq_setsockopt(req, ZMQ_RCVTIMEO, &recv_timeout, sizeof(recv_timeout)); + int linger = 0; + zmq_setsockopt(req, ZMQ_LINGER, &linger, sizeof(linger)); + ASSERT_EQ(zmq_connect(req, setup_ep.c_str()), 0); + + // (1) Garbage on the setup channel: a reply must still arrive (the + // agent sends a best-effort empty frame). Without the fix the agent + // never replies and this recv times out with -1/EAGAIN. + const char garbage[] = "definitely-not-an-e3ap-setup-request"; + ASSERT_GE(zmq_send(req, garbage, sizeof(garbage) - 1, 0), 0); + + uint8_t buf[4096]; + int n = zmq_recv(req, buf, sizeof(buf), 0); + ASSERT_GE(n, 0); + + // (2) The same channel must still serve a well-formed SetupRequest. + auto encoder = create_encoder(encoding); + ASSERT_TRUE(encoder != nullptr); + auto setup = encoder->encode_setup_request( + 42, "1.0.0", "badsetup-dapp", "0.0.1", "test-vendor"); + ASSERT_TRUE(setup.has_value()); + ASSERT_GE(zmq_send(req, setup->buffer.data(), setup->buffer.size(), 0), 0); + + n = zmq_recv(req, buf, sizeof(buf), 0); + ASSERT_GT(n, 0); + auto decoded = encoder->decode(buf, static_cast(n)); + ASSERT_TRUE(decoded.has_value()); + ASSERT_EQ(static_cast(decoded->type), + static_cast(PduType::SETUP_RESPONSE)); + auto* resp = std::get_if(&decoded->choice); + ASSERT_TRUE(resp != nullptr); + ASSERT_EQ(static_cast(resp->response_code), + static_cast(ResponseCode::POSITIVE)); + ASSERT_EQ(resp->request_id, 42u); + ASSERT_TRUE(resp->dapp_identifier.has_value()); + + zmq_close(req); + zmq_ctx_destroy(ctx); + agent.stop(); +} + +// --------------------------------------------------------------------------- + +int main() { + return RUN_ALL_TESTS(); +} From 60ff520eef3636d51f6892069011b0647da590f8 Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Thu, 11 Jun 2026 23:43:50 -0400 Subject: [PATCH 3/4] asn1_encoder: tolerate SMs with empty ran_function_data in setupResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServiceModel::ran_function_data() defaults to an empty vector, but the E3AP schema (messages/asn1/V1/e3ap-1.0.0.asn1) declares E3-RanFunctionDefinition.ranFunctionData as a MANDATORY OCTET STRING (SIZE (1..32768)) — it can be neither omitted nor empty. Encoding a setupResponse that advertises such an SM therefore violates the APER size constraint and aborts the encode of the whole PDU: one data-less SM silently breaks setup for every dApp, including the RAN functions that do provide data. Since the field is mandatory (omitting it is not an option), substitute a 1-byte 0x00 placeholder when the SM provides no data, and say so in a comment next to the schema reference. SMs with real data are encoded exactly as before. Verified against the asn1c runtime generated from this grammar: encoding E3-RanFunctionDefinition with an empty ranFunctionData fails the PER size constraint, while the 1-byte placeholder encodes and decodes. Extend test_asn1_size (the ASN.1-only test binary) with a setupResponse that carries one SM with data and one without: the encode must succeed, the result must decode, the real payload must round-trip byte-for-byte, and the data-less SM must surface the 0x00 placeholder. --- src/encoder/asn1_encoder.cpp | 22 ++++++++++++-- tests/test_asn1_size.cpp | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/encoder/asn1_encoder.cpp b/src/encoder/asn1_encoder.cpp index da03e24..40c39be 100644 --- a/src/encoder/asn1_encoder.cpp +++ b/src/encoder/asn1_encoder.cpp @@ -219,9 +219,25 @@ E3_PDU* Asn1E3Encoder::pdu_to_asn1(const Pdu& pdu) const { ASN_SEQUENCE_ADD(&ran_func->controlIdentifierList, id); } - OCTET_STRING_fromBuf(&ran_func->ranFunctionData, - reinterpret_cast(func.ran_function_data.data()), - static_cast(func.ran_function_data.size())); + if (func.ran_function_data.empty()) { + // ranFunctionData is MANDATORY in the E3AP schema and + // constrained to OCTET STRING (SIZE (1..32768)) + // (messages/asn1/V1/e3ap-1.0.0.asn1, + // E3-RanFunctionDefinition): it can be neither omitted + // nor empty. An SM that provides no RAN-function data + // (ServiceModel::ran_function_data() defaults to {}) + // would otherwise violate the APER size constraint and + // abort the encode of the entire setupResponse, taking + // every other registered RAN function down with it. + // Substitute a 1-byte 0x00 placeholder instead. + static const char kPlaceholder = '\0'; + OCTET_STRING_fromBuf(&ran_func->ranFunctionData, + &kPlaceholder, 1); + } else { + OCTET_STRING_fromBuf(&ran_func->ranFunctionData, + reinterpret_cast(func.ran_function_data.data()), + static_cast(func.ran_function_data.size())); + } // Ensure ranFunctionList container is allocated (optional field) if (!asn1_pdu->msg.choice.setupResponse->ranFunctionList) { diff --git a/tests/test_asn1_size.cpp b/tests/test_asn1_size.cpp index 455cb67..9f3273a 100644 --- a/tests/test_asn1_size.cpp +++ b/tests/test_asn1_size.cpp @@ -225,6 +225,63 @@ TEST(Asn1Size_growsLinearlyWithPayload) { ASSERT_LE(delta, 2 * naive_payload_delta + ENVELOPE_OVERHEAD_MAX); } +/** + * A setupResponse that advertises an SM with EMPTY ran_function_data must + * still encode. The schema makes ranFunctionData a mandatory + * OCTET STRING (SIZE (1..32768)), so the encoder substitutes a 1-byte 0x00 + * placeholder; without that substitution the APER size constraint fails and + * the whole setupResponse encode aborts. The result must also decode, with + * the placeholder visible to the peer and every other field intact. + */ +TEST(Asn1_setupResponse_emptyRanFunctionData_stillEncodes) { + auto enc = make_encoder(); + + RanFunctionDef with_data; + with_data.ran_function_identifier = 1; + with_data.telemetry_identifier_list = {1, 2}; + with_data.control_identifier_list = {10}; + with_data.ran_function_data = {0xAB, 0xCD}; + + RanFunctionDef without_data; + without_data.ran_function_identifier = 2; + without_data.telemetry_identifier_list = {3}; + without_data.control_identifier_list = {20}; + // ran_function_data deliberately left empty (ServiceModel default) + + auto encoded = enc->encode_setup_response( + 7, // message_id + 42, // request_id + ResponseCode::POSITIVE, + std::string("1.0.0"), // e3ap_protocol_version + 3u, // dapp_identifier + "asn1-test-ran", // ran_identifier + {with_data, without_data}); + ASSERT_TRUE(encoded.has_value()); + + auto decoded = enc->decode(encoded->buffer.data(), encoded->buffer.size()); + ASSERT_TRUE(decoded.has_value()); + ASSERT_EQ(static_cast(decoded->type), + static_cast(PduType::SETUP_RESPONSE)); + + auto* resp = std::get_if(&decoded->choice); + ASSERT_TRUE(resp != nullptr); + ASSERT_EQ(static_cast(resp->response_code), + static_cast(ResponseCode::POSITIVE)); + ASSERT_EQ(resp->request_id, 42u); + ASSERT_EQ(resp->ran_function_list.size(), 2u); + + // SM with real data: payload round-trips byte-for-byte. + ASSERT_EQ(resp->ran_function_list[0].ran_function_identifier, 1u); + ASSERT_EQ(resp->ran_function_list[0].ran_function_data.size(), 2u); + ASSERT_EQ(static_cast(resp->ran_function_list[0].ran_function_data[0]), 0xAB); + ASSERT_EQ(static_cast(resp->ran_function_list[0].ran_function_data[1]), 0xCD); + + // SM without data: the wire carries the 1-byte 0x00 placeholder. + ASSERT_EQ(resp->ran_function_list[1].ran_function_identifier, 2u); + ASSERT_EQ(resp->ran_function_list[1].ran_function_data.size(), 1u); + ASSERT_EQ(static_cast(resp->ran_function_list[1].ran_function_data[0]), 0x00); +} + // --------------------------------------------------------------------------- int main() { From ef2a2a7262a59a81aa3c94aa5d76c84fa4bcf616 Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Thu, 11 Jun 2026 23:47:09 -0400 Subject: [PATCH 4/4] core: make SmRegistry per-E3Interface instead of a process singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SmRegistry was a process-wide singleton, so every E3Interface in a process shared one SM table. That breaks any multi-agent process in two ways: - registering an SM with the same RAN function id on a second, completely independent agent fails with SM_ALREADY_REGISTERED, and - E3Interface::stop() cleared the SHARED registry, wiping the Service Models of every other RAN-role agent still running in the process (the existing role guard only protected against the dApp-role case). Each E3Interface now owns an SmRegistry member, and all call sites in e3_interface.cpp (register_sm, get_available_ran_functions, the setup and sm-data loops, handle_setup_request, handle_control_action, on_sm_lifecycle_change, and the teardown clear) go through the owning interface's registry; stop() clears only the agent's own SMs. The SmRegistry class API is unchanged apart from dropping instance() and making construction public. Nothing else references the singleton — the C API and SWIG layer reach SMs through E3Agent, which already routes via its E3Interface — so single-agent behaviour is identical, including the clear-on-stop semantics. Add test_multi_agent_registry: two RAN-role E3Agents in one process (on distinct IPC endpoints) both register an SM with ran_function_id 1 — both must succeed — then one agent is stopped and destroyed and the survivor must still be running, still advertise RAN function 1, and still serve a full setup handshake to a real dApp-role agent that is offered that RAN function. The test fails on the singleton design (second registration returns SM_ALREADY_REGISTERED) and uses the build's available encoding (JSON preferred), so it runs on JSON-only builds. --- include/libe3/e3_interface.hpp | 6 + include/libe3/sm_interface.hpp | 20 ++- src/core/e3_interface.cpp | 29 ++-- src/core/sm_registry.cpp | 5 - tests/test_multi_agent_registry.cpp | 211 ++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 tests/test_multi_agent_registry.cpp diff --git a/include/libe3/e3_interface.hpp b/include/libe3/e3_interface.hpp index 95b84ab..66dc82d 100644 --- a/include/libe3/e3_interface.hpp +++ b/include/libe3/e3_interface.hpp @@ -208,6 +208,12 @@ class E3Interface { // Core components std::unique_ptr connector_; std::unique_ptr encoder_; + // Service Models registered with THIS interface. Owned per interface + // (not process-wide) so multiple agents in one process can host SMs + // with the same RAN function id, and stopping one agent cannot wipe + // another agent's registry. Only the RAN role registers SMs; on the + // dApp role this stays empty. + SmRegistry sm_registry_; // RAN-only state (nullptr when role==DAPP). std::unique_ptr subscription_manager_; // dApp-only state (nullptr when role==RAN). diff --git a/include/libe3/sm_interface.hpp b/include/libe3/sm_interface.hpp index f1f85a6..b3f66b5 100644 --- a/include/libe3/sm_interface.hpp +++ b/include/libe3/sm_interface.hpp @@ -212,15 +212,18 @@ using SmFactory = std::function()>; /** * @brief SM Registry for managing registered Service Models * - * This class provides a central registry for Service Models. - * It's used by the E3Agent to find and manage SMs. + * Each E3Interface owns one SmRegistry instance: the registry is scoped to + * its owning interface/agent, not to the process. Two agents in the same + * process (e.g. integration tests, multi-RAN deployments) can therefore + * register Service Models with the same RAN function id independently, and + * tearing one agent down only clears that agent's own SMs. */ class SmRegistry { public: - /** - * @brief Get the singleton instance - */ - static SmRegistry& instance(); + SmRegistry() = default; + ~SmRegistry() = default; + SmRegistry(const SmRegistry&) = delete; + SmRegistry& operator=(const SmRegistry&) = delete; /** * @brief Register a Service Model @@ -277,11 +280,6 @@ class SmRegistry { void clear(); private: - SmRegistry() = default; - ~SmRegistry() = default; - SmRegistry(const SmRegistry&) = delete; - SmRegistry& operator=(const SmRegistry&) = delete; - mutable std::mutex mutex_; std::unordered_map> sms_; std::unordered_map factories_; diff --git a/src/core/e3_interface.cpp b/src/core/e3_interface.cpp index cd12a0a..0795db9 100644 --- a/src/core/e3_interface.cpp +++ b/src/core/e3_interface.cpp @@ -240,13 +240,12 @@ void E3Interface::stop() { } setup_complete_cv_.notify_all(); - // Clean up SM registry — only the RAN role owns SMs. Doing this - // unconditionally would wipe a sibling RAN-role E3Interface's registered - // SMs in a two-roles-in-one-process scenario (integration tests, the - // latency benchmark, multi-peer dApps colocated with a RAN). The dApp - // role never registers an SM, so there's nothing to clear. + // Clean up this interface's own SM registry — only the RAN role owns + // SMs (the dApp role never registers any, so its registry is empty). + // The registry is per-interface, so this cannot affect a sibling + // E3Interface's SMs in a multi-agent process. if (config_.role == E3Role::RAN) { - SmRegistry::instance().clear(); + sm_registry_.clear(); } // Dispose connector @@ -266,7 +265,7 @@ ErrorCode E3Interface::queue_outbound(Pdu pdu) { } std::vector E3Interface::get_available_ran_functions() const { - return SmRegistry::instance().get_available_ran_functions(); + return sm_registry_.get_available_ran_functions(); } ErrorCode E3Interface::register_sm(std::unique_ptr sm) { @@ -286,7 +285,7 @@ ErrorCode E3Interface::register_sm(std::unique_ptr sm) { } return queue_outbound(std::move(pdu)); }); - return SmRegistry::instance().register_sm(std::move(sm)); + return sm_registry_.register_sm(std::move(sm)); } void E3Interface::notify_dapp_status_changed() { @@ -302,7 +301,7 @@ void E3Interface::notify_dapp_status_changed() { void E3Interface::setup_loop_ran() { E3_LOG_INFO(LOG_TAG) << "Setup loop (RAN) started"; - auto available_ran_functions = SmRegistry::instance().get_available_ran_functions(); + auto available_ran_functions = sm_registry_.get_available_ran_functions(); while (!should_stop_.load()) { std::vector buffer; @@ -529,7 +528,7 @@ void E3Interface::sm_data_handler_loop() { } // Get SM for this RAN function - ServiceModel* sm = SmRegistry::instance().get_by_ran_function(ran_func); + ServiceModel* sm = sm_registry_.get_by_ran_function(ran_func); if (!sm || !sm->is_running()) { continue; @@ -574,13 +573,13 @@ void E3Interface::handle_setup_request(const SetupRequest& request, uint32_t req // Create and send response // Get available RAN functions and convert to RanFunctionDef list - auto available_ran_function_ids = SmRegistry::instance().get_available_ran_functions(); + auto available_ran_function_ids = sm_registry_.get_available_ran_functions(); E3_LOG_DEBUG(LOG_TAG) << "Available RAN function ids count: " << available_ran_function_ids.size(); std::vector ran_function_list; for (auto id : available_ran_function_ids) { RanFunctionDef func; func.ran_function_identifier = id; - ServiceModel* sm = SmRegistry::instance().get_by_ran_function(id); + ServiceModel* sm = sm_registry_.get_by_ran_function(id); if (sm) { func.telemetry_identifier_list = sm->telemetry_ids(); func.control_identifier_list = sm->control_ids(); @@ -723,7 +722,7 @@ void E3Interface::handle_control_action(const DAppControlAction& action, uint32_ << " control " << action.control_identifier << " (" << action.action_data.size() << " bytes)"; - ServiceModel* sm = SmRegistry::instance().get_by_ran_function(action.ran_function_identifier); + ServiceModel* sm = sm_registry_.get_by_ran_function(action.ran_function_identifier); if (sm && sm->is_running()) { ErrorCode result = sm->handle_control_action(request_message_id, action); @@ -1154,13 +1153,13 @@ ErrorCode E3Interface::wait_for_setup(std::chrono::milliseconds timeout) { void E3Interface::on_sm_lifecycle_change(uint32_t ran_function_id, bool should_start) { if (should_start) { - ErrorCode result = SmRegistry::instance().start_sm(ran_function_id); + ErrorCode result = sm_registry_.start_sm(ran_function_id); if (result != ErrorCode::SUCCESS) { E3_LOG_ERROR(LOG_TAG) << "Failed to start SM for RAN function " << ran_function_id << ": " << error_code_to_string(result); } } else { - ErrorCode result = SmRegistry::instance().stop_sm(ran_function_id); + ErrorCode result = sm_registry_.stop_sm(ran_function_id); if (result != ErrorCode::SUCCESS) { E3_LOG_WARN(LOG_TAG) << "Failed to stop SM for RAN function " << ran_function_id << ": " << error_code_to_string(result); diff --git a/src/core/sm_registry.cpp b/src/core/sm_registry.cpp index af7ff02..50155e1 100644 --- a/src/core/sm_registry.cpp +++ b/src/core/sm_registry.cpp @@ -15,11 +15,6 @@ namespace { constexpr const char* LOG_TAG = "SmReg"; } -SmRegistry& SmRegistry::instance() { - static SmRegistry registry; - return registry; -} - ErrorCode SmRegistry::register_sm(std::unique_ptr sm) { if (!sm) { return ErrorCode::INVALID_PARAM; diff --git a/tests/test_multi_agent_registry.cpp b/tests/test_multi_agent_registry.cpp new file mode 100644 index 0000000..500533f --- /dev/null +++ b/tests/test_multi_agent_registry.cpp @@ -0,0 +1,211 @@ +/** + * @file test_multi_agent_registry.cpp + * @brief Multi-instance correctness of the (now per-interface) SM registry. + * + * Historically SmRegistry was a process-wide singleton, which broke any + * process hosting more than one E3 agent: + * + * - registering an SM with the same RAN function id on a second agent + * failed with SM_ALREADY_REGISTERED even though the agents are + * completely independent peers on different endpoints, and + * + * - stopping/destroying one RAN-role agent cleared the shared registry, + * silently wiping the OTHER agent's Service Models. + * + * Properties verified with two real RAN-role E3Agents in one process (and + * a real dApp-role agent as the client): + * + * (1) Both agents can register an SM with the SAME ran_function_id. + * + * (2) Each agent advertises its own registration independently. + * + * (3) After one agent is stopped and destroyed, the surviving agent is + * still running, still has its SM registered, and still serves a + * full setup handshake advertising that RAN function to a dApp. + * + * The test uses whichever encoding the build provides (JSON preferred) so + * it runs on JSON-only, ASN.1-only, and dual-encoder builds. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "test_framework.hpp" +#include "libe3/libe3.hpp" +#include "libe3/e3_agent.hpp" +#include "libe3/sm_interface.hpp" +#include "libe3/types.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include // getpid + +using namespace libe3; +using namespace std::chrono_literals; + +namespace { + +inline int error_to_int(ErrorCode e) { return static_cast(e); } + +/// Counter so each test instance uses distinct IPC namespaces — required +/// when running ctest -j to avoid socket-file collisions. +std::atomic g_seq{0}; + +std::string unique_ipc(const char* tag) { + std::ostringstream oss; + oss << "ipc:///tmp/dapps/multireg_test_" << getpid() << "_" + << g_seq.fetch_add(1) << "_" << tag; + return oss.str(); +} + +/// Pick an encoding that is compiled into this build (JSON preferred so the +/// test also runs on JSON-only builds). +EncodingFormat pick_encoding() { +#if defined(LIBE3_ENABLE_JSON) + return EncodingFormat::JSON; +#else + return EncodingFormat::ASN1; +#endif +} + +/// Minimal ServiceModel so register_sm() succeeds. +class RegistryTestSM : public ServiceModel { +public: + explicit RegistryTestSM(uint32_t ran_function_id) : id_(ran_function_id) {} + std::string name() const override { return "RegistryTestSM"; } + uint32_t version() const override { return 1; } + uint32_t ran_function_id() const override { return id_; } + std::vector telemetry_ids() const override { return {1}; } + std::vector control_ids() const override { return {10}; } + ErrorCode init() override { return ErrorCode::SUCCESS; } + void destroy() override { running_ = false; } + ErrorCode start() override { running_ = true; return ErrorCode::SUCCESS; } + void stop() override { running_ = false; } + bool is_running() const override { return running_; } + ErrorCode handle_control_action(uint32_t, const DAppControlAction&) override { + return ErrorCode::SUCCESS; + } +private: + uint32_t id_; + bool running_ = false; +}; + +struct RanEndpoints { + std::string setup; + std::string sub; + std::string pub; +}; + +E3Config make_ran_config(const RanEndpoints& ep, const char* ran_id, + EncodingFormat encoding) { + E3Config cfg; + cfg.role = E3Role::RAN; + cfg.link_layer = E3LinkLayer::ZMQ; + cfg.transport_layer = E3TransportLayer::IPC; + cfg.setup_endpoint = ep.setup; + cfg.subscriber_endpoint = ep.sub; + cfg.publisher_endpoint = ep.pub; + cfg.encoding = encoding; + cfg.log_level = 0; + cfg.ran_identifier = ran_id; + return cfg; +} + +} // namespace + +TEST(MultiAgent_sameRanFunctionId_independentRegistries) { + constexpr uint32_t RAN_FUNC_ID = 1; + const EncodingFormat encoding = pick_encoding(); + + const RanEndpoints ep1{unique_ipc("setup1"), unique_ipc("in1"), unique_ipc("out1")}; + const RanEndpoints ep2{unique_ipc("setup2"), unique_ipc("in2"), unique_ipc("out2")}; + + // Agent 1 hosts SM with ran_function_id 1. + auto agent1 = std::make_unique(make_ran_config(ep1, "multireg-ran-1", encoding)); + ASSERT_EQ(error_to_int(agent1->register_sm(std::make_unique(RAN_FUNC_ID))), + error_to_int(ErrorCode::SUCCESS)); + ASSERT_EQ(error_to_int(agent1->start()), error_to_int(ErrorCode::SUCCESS)); + + // Agent 2 hosts an SM with the SAME ran_function_id. With a process-wide + // registry this registration failed with SM_ALREADY_REGISTERED. + E3Agent agent2(make_ran_config(ep2, "multireg-ran-2", encoding)); + ASSERT_EQ(error_to_int(agent2.register_sm(std::make_unique(RAN_FUNC_ID))), + error_to_int(ErrorCode::SUCCESS)); + ASSERT_EQ(error_to_int(agent2.start()), error_to_int(ErrorCode::SUCCESS)); + + // Both agents advertise their own registration. + auto funcs1 = agent1->get_available_ran_functions(); + ASSERT_EQ(funcs1.size(), 1u); + ASSERT_EQ(funcs1[0], RAN_FUNC_ID); + auto funcs2 = agent2.get_available_ran_functions(); + ASSERT_EQ(funcs2.size(), 1u); + ASSERT_EQ(funcs2[0], RAN_FUNC_ID); + + // Stop and destroy agent 1. With a process-wide registry this cleared + // agent 2's SM as collateral damage. + agent1->stop(); + agent1.reset(); + + ASSERT_TRUE(agent2.is_running()); + auto funcs2_after = agent2.get_available_ran_functions(); + ASSERT_EQ(funcs2_after.size(), 1u); + ASSERT_EQ(funcs2_after[0], RAN_FUNC_ID); + + // Agent 2 must still SERVE: a real dApp-role agent performs the full + // setup handshake against it and must be offered RAN function 1. + E3Config dapp_cfg; + dapp_cfg.role = E3Role::DAPP; + dapp_cfg.link_layer = E3LinkLayer::ZMQ; + dapp_cfg.transport_layer = E3TransportLayer::IPC; + dapp_cfg.setup_endpoint = ep2.setup; + dapp_cfg.subscriber_endpoint = ep2.sub; + dapp_cfg.publisher_endpoint = ep2.pub; + dapp_cfg.encoding = encoding; + dapp_cfg.log_level = 0; + dapp_cfg.dapp_name = "multireg-dapp"; + dapp_cfg.dapp_version = "0.0.1"; + dapp_cfg.vendor = "test-vendor"; + + std::mutex resp_mu; + std::vector offered; + std::atomic got_positive{false}; + + E3Agent dapp(std::move(dapp_cfg)); + dapp.set_setup_response_handler([&](const SetupResponse& resp) { + std::lock_guard lk(resp_mu); + offered = resp.ran_function_list; + if (resp.response_code == ResponseCode::POSITIVE) { + got_positive.store(true); + } + }); + ASSERT_EQ(error_to_int(dapp.start()), error_to_int(ErrorCode::SUCCESS)); + ASSERT_EQ(error_to_int(dapp.wait_for_setup(10s)), + error_to_int(ErrorCode::SUCCESS)); + ASSERT_TRUE(got_positive.load()); + { + std::lock_guard lk(resp_mu); + bool found = false; + for (const auto& rf : offered) { + if (rf.ran_function_identifier == RAN_FUNC_ID) { + found = true; + } + } + ASSERT_TRUE(found); + } + + dapp.stop(); + agent2.stop(); +} + +// --------------------------------------------------------------------------- + +int main() { + return RUN_ALL_TESTS(); +}