sm/dapp/build: fix ranFunctionData contract, working unsubscribe, consumable install - #70
Merged
Merged
Conversation
E3-RanFunctionDefinition.ranFunctionData is a mandatory
OCTET STRING (SIZE (1..32768)), but the C API documented it as "may be NULL"
and ServiceModel::ran_function_data() defaulted to {}. Nothing validated it,
and the value is only read later, per SetupRequest, when building the
ranFunctionList for every registered RAN function -- so a single SM
advertising nothing made the whole SetupResponse unencodable.
Reject it where it can still be reported to the caller: SmRegistry::register_sm
now returns INVALID_PARAM for an empty or oversized value, and the C API
screens the same rule before the ownership transfer, since register_sm() takes
the unique_ptr by value and a rejection on the far side would destroy the
object while the caller still holds the handle.
The docs on both sides now say the field is required rather than optional. The
base-class default stays {} -- a pure virtual would break every existing SM at
compile time, whereas the default plus a loud rejection is a diagnostic.
Three in-tree fixtures relied on that default and now override it.
Closes #30
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ranFunctionData is read from the SM on every SetupRequest, not once at
registration, so registration-time validation cannot vouch for the value that
reaches the encoder -- a real SM re-encodes it there and returns {} if that
fails. On the APER channel an empty entry violates SIZE(1..32768) and fails the
encode of the whole SetupResponse, which landed on std::abort(): one SM that
could not describe itself killed the agent for every dApp, on every setup.
Omit such an entry instead. ranFunctionList is OPTIONAL precisely so that
"nothing to advertise" has a representation, and the remaining RAN functions
still get advertised. The std::abort() stays as the internal-invariant guard it
was meant to be; what changes is that an SM can no longer reach it.
The new regression test aborts with 134 without this change.
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
DAppSubscriptionState::record_subscription() had no callers anywhere, so ran_function_to_subscription_id was permanently empty: queue_subscription_delete() always missed its lookup and E3Agent::unsubscribe() always returned SUBSCRIPTION_NOT_FOUND. subscribed_ran_functions() and active_subscription_ids() were always empty for the same reason. The operation was not slow or awkward, it was unavailable -- and there was no id-taking overload to route around it, so a dApp could only drop a subscription by ending the session. The cause is that E3-SubscriptionResponse echoes requestId but not ranFunctionIdentifier, so a response cannot say which RAN function it grants. E3Interface already generates the request id and threw it away; it now keeps a request_id -> (ran_function_id, is_delete) record from the moment the request is built until the response is attributed. No grammar or struct change: the correlation was already on the wire. A delete is answered with a SubscriptionResponse as well, so both directions arrive in the same handler and are told apart by the recorded op; a confirmed delete drops the local mapping. The existing unit tests exercised DAppSubscriptionState in isolation, which is exactly why this shipped. The role-pair integration test now follows its subscribe with the unsubscribe that was never tested. Closes #66 Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs that only bite a consumer of the installed library, which is why
neither was caught: everything in-tree builds against the targets directly.
libe3.pc emitted "Cflags: -I${includedir}" and nothing else, so a pkg-config
consumer had to know out of band which features the install was built with. A
wrong guess fails quietly rather than loudly -- the public headers change shape
under those macros. The CMake path was already correct (the defines are PUBLIC
on the target), so read them off the target at install time instead of
re-listing the options: a define added to libe3Targets.cmake now reaches the .pc
with no second edit, and cannot drift from it.
find_package(libe3 CONFIG REQUIRED) failed for every consumer: the exported
target set named tl::expected, which arrives via FetchContent and is therefore
never installed nor re-exported, so CMake's own generated guard set
libe3_FOUND=FALSE on a missing imported target. tl::expected is genuinely part
of the public interface (e3_encoder.hpp returns tl::expected<T, ErrorCode>), so
rather than hiding it, scope the target to the build interface and install the
one header it consists of. Both routes then work with no API change and nothing
extra for consumers to find.
tests/consume/ is a standalone project built against a staged install, exercised
in CI on both routes. Against the unfixed tree it reproduces both failures: an
empty Cflags, and "the following imported targets are referenced, but are
missing: tl::expected".
Closes #69
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
connector_factory.cpp instantiated ZmqE3Connector unconditionally while cmake/libe3Sources.cmake only compiles zmq_connector.cpp when LIBE3_ENABLE_ZMQ is on. The .so linked anyway -- undefined symbols are permitted there -- so the build looked fine and the failure surfaced in the consumer, as an undefined reference to libe3::ZmqE3Connector::ZmqE3Connector. Guard the branch on LIBE3_HAS_ZMQ, and return nullptr with a clear log line when the ZMQ link layer is requested from a build that has none: the value can arrive from a config file the build knows nothing about, so this is a runtime configuration error, not a compile-time impossibility. E3LinkLayer::ZMQ stays in the enum for the same reason. Two consequences of that path finally being reachable: - E3Config's default link layer follows the build, the way its default encoding already does. Otherwise every default-constructed config in a ZMQ-less build asks for a connector that cannot exist. Endpoint defaults need no counterpart: the POSIX connector accepts the same ZMQ-style URIs and strips the scheme. - The three tests that drive a raw ZMQ peer are skipped when ZMQ is off, via a LIBE3_ZMQ_ONLY_TESTS list mirroring the existing ASN.1 one. They include <zmq.h> and link libzmq directly, so there was nothing for them to link against either. CI now builds and tests that configuration, in the job that already covers non-default build options. Its name is left alone deliberately: renaming it would break the required-status ruleset. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minor rather than patch: ranFunctionData is now required at SM registration, so an SM that registered against 0.0.x can be rejected by this version. The subscription and packaging fixes are additive, but that one is a deliberate break of a documented contract and should be visible in the version. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
CI report —
|
| Workflow | Result | Time | Run |
|---|---|---|---|
| Commit policy | ✅ success | 4m54s | #78 |
| E2E dApp Integration | ✅ success | 1m56s | #94 |
| E2E Topologies (multi-dApp / multi-RAN) | ✅ success | 2m18s | #92 |
| Full-loop Latency Benchmark | ✅ success | 0m56s | #93 |
| Unit Tests | ✅ success | 3m49s | #120 |
| MPMC Queue Benchmark | ⏭️ not triggered (paths filter) | — | — |
E2E Topologies (multi-dApp / multi-RAN)
zmq/ipc
- ✅ 1 RAN - 1 dApp: indications=
5dapppeer=t11 ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
- ✅ 1 RAN - 2 dApps: dApp#1 ind=
5sub=1, dApp#2 ind=6sub=2, RAN saw2dApps - ✅ 2 RANs - 1 dApp: from ran-a ind=
5, from ran-b ind=5
zmq/tcp
- ✅ 1 RAN - 1 dApp: indications=
5dapppeer=default ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
- ✅ 1 RAN - 2 dApps: dApp#1 ind=
5sub=1, dApp#2 ind=6sub=2, RAN saw2dApps - ✅ 2 RANs - 1 dApp: from ran-a ind=
5, from ran-b ind=5
E2E dApp Integration
✅ posix/ipc
- dApp exit:
0 - Indications received:
7
✅ posix/tcp
- dApp exit:
0 - Indications received:
7
✅ zmq/ipc
- dApp exit:
0 - Indications received:
7
✅ zmq/tcp
- dApp exit:
0 - Indications received:
7
Full-loop Latency Benchmark
Full-loop latency
Full-loop latency benchmark (N=1099 after 50 warmup)
All values in microseconds (us). Link: zmq, transport: ipc, encoding: ASN.1 APER.
| Phase | mean | p50 | p99 | max |
|---|---|---|---|---|
| 1. Collect indication data | 0.15 | 0.15 | 0.31 | 0.42 |
| 2. Create & encode indication | 1.06 | 1.06 | 1.88 | 3.60 |
| 3. Deliver indication (RAN -> dApp) | 66.45 | 66.15 | 88.72 | 564.60 |
| 4. Decode indication | 0.67 | 0.64 | 0.99 | 1.14 |
| 5. Process data | 0.03 | 0.03 | 0.04 | 0.04 |
| 6. Create & encode control | 0.38 | 0.38 | 0.61 | 0.74 |
| 7. Deliver control (dApp -> RAN) | 83.85 | 82.69 | 100.46 | 669.50 |
| 8. Decode & handle control | 0.53 | 0.55 | 0.82 | 3.02 |
| Total round-trip | 153.12 | 151.76 | 187.90 | 708.09 |
ubuntu-latest, Release build, ZMQ + IPC, ASN.1 APER.
Ready to merge (fast-forward only)
A maintainer can land the reviewed commits with:
git fetch origin
git checkout main && git merge --ff-only abb62ce48924d77fc4966a621fa8870d1b91cdb2 && git push origin mainHead: abb62ce48924d77fc4966a621fa8870d1b91cdb2 (branch 30-66-69-sm-contract-unsubscribe-packaging). If --ff-only fails as non-fast-forward, the branch must be rebased on the latest main.
One comment per PR, rewritten in place once every workflow for
abb62cefinished.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ranFunctionDatais now required, and can no longer kill the agent. E3AP declares it a mandatoryOCTET STRING (SIZE (1..32768))while the C API documented it as "may be NULL" andServiceModel::ran_function_data()defaulted to{}. Registration now rejects an empty or oversized value, and the setup-response builder omits an entry it cannot encode instead of reachingstd::abort()— which is what one misconfigured SM used to do to every dApp, on every setup.E3Agent::unsubscribe()works.DAppSubscriptionState::record_subscription()had no callers, so the lookup behindunsubscribe()always missed and the operation was simply unavailable.E3-SubscriptionResponsecarries noranFunctionIdentifier, soE3Interfacenow keeps therequest_id → ran_function_idrecord it was already generating and throwing away. No grammar or struct change.-DLIBE3_ENABLE_ZMQ=OFFproduced a library nobody could link against:connector_factory.cppinstantiatedZmqE3Connectorunconditionally whilezmq_connector.cppis only compiled when the option is on. Undefined symbols are permitted in a.so, so the build looked fine and the failure landed on the consumer. Found by the new consume check below; CI now covers that configuration.libe3.pcnow carries the feature macros (read off the target, so it cannot drift fromlibe3Targets.cmake), andfind_package(libe3 CONFIG)no longer fails on an uninstalledtl::expected. A new out-of-treetests/consume/project is built against a staged install in CI on both routes — the check that would have caught both halves.Type of change
Linked issue
Closes #30
Closes #66
Closes #69
Notes for review
Why #30 needed two changes, not one.
ran_function_data()is a virtual re-evaluated on everySetupRequest, and a real SM re-encodes it there (returning{}if that encode fails — including our ownSimpleServiceModel). So registration-time validation cannot vouch for the value that reaches the encoder; the builder-side omission is not belt-and-braces, it closes the remaining path.ranFunctionListisOPTIONAL, so omitting an entry is how E3AP spells "nothing to advertise" — no padding, and a decoded{0x00}stays distinguishable from real data (this is the resolution #30 asked for over PR #29's placeholder). Thestd::abort()stays as the internal-invariant guard it was meant to be; what changes is that an SM can no longer reach it. Verified both ways: the new regression test exits 134 against the unfixed tree.The C API screens the same rule before the ownership transfer.
register_sm()takes theunique_ptrby value, so a rejection on the far side would destroy the object while the caller still holds the handle — ande3_agent_register_smdocuments that the caller keeps ownership on failure. Rejecting first is what makes that true. (The pre-existing consume-on-agent-side-failure behaviour is untouched and out of scope here.)Breaking change, hence 0.1.0 rather than 0.0.12. An SM that registered against 0.0.x can be rejected by this version. Three in-tree fixtures relied on the empty default and now override it; every integration test and both examples already supplied real bytes.
On the packaging fix.
tl::expectedis genuinely part of the public interface (e3_encoder.hppreturnstl::expected<T, ErrorCode>), so it is not hidden — the target is scoped to the build interface and the single header it consists of is installed, which keeps both routes working with no API change and nothing extra for consumers to find. The tradeoff is noted in the CMake comment: this puts a third-party header under our prefix, where it would shadow a systemtl-expectedinstalled to the same prefix. Reading the macros offINTERFACE_COMPILE_DEFINITIONSrather than re-listing the options means a define added tolibe3Targets.cmakereaches the.pcwith no second edit; a generator expression there is a hard configure error rather than a silent omission.On the ZMQ-off fix. Two consequences of that path finally being reachable, both handled here:
E3Config's default link layer now follows the build the way its default encoding already did (otherwise every default-constructed config in a ZMQ-less build asks for a connector that cannot exist), and the three tests that drive a raw ZMQ peer are skipped when ZMQ is off, via aLIBE3_ZMQ_ONLY_TESTSlist mirroring the existing ASN.1 one. The new CI leg lives in theAll Encodingsjob rather than its own: adding a job would not be gated until the ruleset changed, and renaming that job would break the required-status list. Happy to split it out if you would rather add the context.Mandatory test checklist
./build_libe3 -c -d build -j $(nproc) -r -tpasses (Release build + tests) — 19/19./build_libe3 -c -d build -j $(nproc) -g -tpasses (Debug build + tests) — 19/19cd build && ctest --output-on-failureis clean — 26/26 withLIBE3_BUILD_INTEGRATION_TESTS=ON; 15/15 withLIBE3_ENABLE_ZMQ=OFFmain(untouched by this PR)VERSIONbumped per SemVer — 0.0.11 → 0.1.0./build_libe3 --docsrenders without new Doxygen warnings (only the pre-existing obsoleteCLASS_DIAGRAMStag)libe3.pcinterface changed — verified by building a standalone consumer against a staged install on both routes, in five option combinations (default; +JSON; JSON-only; ZMQ-off; SWIG-enabled), plus the SWIG smoke testAlso run: every commit builds and tests independently (6/6),
scripts/check_commit_trailers.pyclean,git log --merges origin/main..HEADempty.Twin-repo coordination
The wire protocol is untouched — no grammar, no
Pdustruct, no encoder output changes. The public C/C++ API keeps its signatures; what changes is that a previously-accepted argument is now rejected, so an SM implementor indApp-openairinterface5gthat supplies realranFunctionData(as both shipped SMs do on their normal path) is unaffected.dApp-libraryis a dApp-role consumer and never registers a service model, so it is unaffected either way. A paired change is queued on thedApp-openairinterface5gside to harden the error path in its SM descriptor builders, which previously fell back to a NULL/0 descriptor on an encode failure.Paired PR(s): to follow.
Workflow confirmation
main, no merge commits.CONTRIBUTING.md.