Skip to content

sm/dapp/build: fix ranFunctionData contract, working unsubscribe, consumable install - #70

Merged
Thecave3 merged 6 commits into
mainfrom
30-66-69-sm-contract-unsubscribe-packaging
Aug 21, 2026
Merged

sm/dapp/build: fix ranFunctionData contract, working unsubscribe, consumable install#70
Thecave3 merged 6 commits into
mainfrom
30-66-69-sm-contract-unsubscribe-packaging

Conversation

@Thecave3

Copy link
Copy Markdown
Collaborator

Summary

  • C API allows empty ran_function_data but E3AP schema mandates SIZE(1..32768) #30ranFunctionData is now required, and can no longer kill the agent. E3AP declares it a mandatory OCTET STRING (SIZE (1..32768)) while the C API documented it as "may be NULL" and ServiceModel::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 reaching std::abort() — which is what one misconfigured SM used to do to every dApp, on every setup.
  • E3Agent::unsubscribe() can never succeed: no subscription is ever recorded #66E3Agent::unsubscribe() works. DAppSubscriptionState::record_subscription() had no callers, so the lookup behind unsubscribe() always missed and the operation was simply unavailable. E3-SubscriptionResponse carries no ranFunctionIdentifier, so E3Interface now keeps the request_id → ran_function_id record it was already generating and throwing away. No grammar or struct change.
  • Also folded in-DLIBE3_ENABLE_ZMQ=OFF produced a library nobody could link against: connector_factory.cpp instantiated ZmqE3Connector unconditionally while zmq_connector.cpp is 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.
  • Packaging: pkg-config omits feature macros, and find_package(libe3 CONFIG) is broken #69 — the installed package is consumable by both routes. libe3.pc now carries the feature macros (read off the target, so it cannot drift from libe3Targets.cmake), and find_package(libe3 CONFIG) no longer fails on an uninstalled tl::expected. A new out-of-tree tests/consume/ project is built against a staged install in CI on both routes — the check that would have caught both halves.

Type of change

  • Bug fix
  • New feature / enhancement
  • New Service Model
  • Refactor (no behavior change)
  • Documentation
  • Test / CI / packaging
  • Other (explain):

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 every SetupRequest, and a real SM re-encodes it there (returning {} if that encode fails — including our own SimpleServiceModel). 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. ranFunctionList is OPTIONAL, 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). 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. 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 the unique_ptr by value, so a rejection on the far side would destroy the object while the caller still holds the handle — and e3_agent_register_sm documents 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::expected is genuinely part of the public interface (e3_encoder.hpp returns tl::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 system tl-expected installed to the same prefix. Reading the macros off INTERFACE_COMPILE_DEFINITIONS rather than re-listing the options means a define added to libe3Targets.cmake reaches the .pc with 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 a LIBE3_ZMQ_ONLY_TESTS list mirroring the existing ASN.1 one. The new CI leg lives in the All Encodings job 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 -t passes (Release build + tests) — 19/19
  • ./build_libe3 -c -d build -j $(nproc) -g -t passes (Debug build + tests) — 19/19
  • cd build && ctest --output-on-failure is clean — 26/26 with LIBE3_BUILD_INTEGRATION_TESTS=ON; 15/15 with LIBE3_ENABLE_ZMQ=OFF
  • MPMC queue benchmark shows no regression vs main (untouched by this PR)
  • VERSION bumped per SemVer — 0.0.11 → 0.1.0
  • ./build_libe3 --docs renders without new Doxygen warnings (only the pre-existing obsolete CLASS_DIAGRAMS tag)
  • No new build dependencies
  • libe3.pc interface 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 test

Also run: every commit builds and tests independently (6/6), scripts/check_commit_trailers.py clean, git log --merges origin/main..HEAD empty.

Twin-repo coordination

  • This PR does not change the E3 wire protocol or public ABI, OR a paired PR exists in each affected twin repo (link below).

The wire protocol is untouched — no grammar, no Pdu struct, 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 in dApp-openairinterface5g that supplies real ranFunctionData (as both shipped SMs do on their normal path) is unaffected. dApp-library is a dApp-role consumer and never registers a service model, so it is unaffected either way. A paired change is queued on the dApp-openairinterface5g side 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

  • My branch is a linear, fast-forward-able descendant of main, no merge commits.
  • Every commit builds and passes tests on its own, with a descriptive message.
  • I have read and followed CONTRIBUTING.md.

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>
@Thecave3 Thecave3 changed the title sm/dapp/build: honest ranFunctionData contract, working unsubscribe, consumable install sm/dapp/build: fix ranFunctionData contract, working unsubscribe, consumable install Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CI report — abb62ce — ✅ all checks passed

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=5
    • dapp peer=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=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=t12 ran=ran-shared 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]
    • dapp2 peer=t12 ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=t2a ran=ran-a sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0.2 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
    • dapp peer=t2b ran=ran-b 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]

zmq/tcp

  • 1 RAN - 1 dApp: indications=5
    • dapp peer=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=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=default ran=ran-shared 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]
    • dapp2 peer=default ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=default ran=ran-a 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]
    • dapp peer=off100 ran=ran-b 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]
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 main

Head: 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 abb62ce finished.

@Thecave3
Thecave3 merged commit abb62ce into main Aug 21, 2026
24 checks passed
@Thecave3
Thecave3 deleted the 30-66-69-sm-contract-unsubscribe-packaging branch August 21, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant