Skip to content

Fix MBS Broadcast distribution session lifecycle and Service Announcement crashes - #49

Draft
jordijoangimenez wants to merge 15 commits into
developmentfrom
feature/mbs-compliance-fixes
Draft

Fix MBS Broadcast distribution session lifecycle and Service Announcement crashes#49
jordijoangimenez wants to merge 15 commits into
developmentfrom
feature/mbs-compliance-fixes

Conversation

@jordijoangimenez

Copy link
Copy Markdown

Summary

Fixes to make MBSF's MBS Broadcast Distribution Session lifecycle (create → activate/deactivate → update → delete) and Service Announcement channel work reliably end-to-end, including several crashes found by live-testing the full pipeline against a real gNB/UE/5GC/MBSTF stack.

Note on PATCH: none of this touches the client-facing PATCH (RFC 7396 JSON Merge Patch) on MBSUserService/MBSUserDataIngSession — that's intentionally on hold pending 5G-MAG/Standards#182 per #45, and stays that way here. Everything below is either the already-implemented PUT path on .../sessions/{id} (UpdateIndMBSUserDataIngSession, TS 29.580 §5.3.2.4.2), or the internal MBSF→MBSTF PATCH (RFC 6902 JSON Patch, Nmb2 reference point, TS 29.581 — a different method on a different resource, already in scope and unaffected by #45).

What's included, in order

  1. Bump rt-common-shared submodule: fix CJson copy-assignment SIGILL crash — see the linked rt-common-shared PR. CJson::operator=(const CJson&) fell off the end of the function with no return statement; GCC lowered this to a real ud2/SIGILL trap the first time a plain lvalue copy-assignment exercised it, crashing MBSF outright. Found via core dump + gdb.

  2. Fix MBSF process-wide crash on invalid objDistrInfo updateupdateMBSDistributionSessionInfo() correctly throws when a client tries to PATCH objDistrInfo/pckDistrInfo while the Distribution Session isn't INACTIVE (a real, intentional validation), but nothing in the PUT handler caught that exception type, so it propagated out of the SBI request handler uncaught and crashed the whole process — taking down every other active session with it over one bad request. Confirmed live; now caught and converted to a proper error response.

  3. Fix distribution session state-change PATCH: wrong classification + missing wrapper — two compounding bugs that together broke every activate/deactivate on an already-created session:

    • The update-merge loop compared the entire session info (including mbsDistSessState) to decide "rebuild the whole MBSTF session" vs. "just PATCH the state" — since every activate/deactivate PUT necessarily changes state alongside resending the rest of the body, it always took the "rebuild" path instead of the lightweight state-only one.
    • That rebuild path was itself broken: it sent the DistSession's JSON directly as the PATCH value for an empty-path add/replace, but MBSTF's actual PATCH target for /dist-sessions/{id} is a CreateReqData, whose fromJSON() requires the value to be a full document with a distSession wrapper key — confirmed via MBSTF's own error, "Mandatory Information Element Missing: distSession: Field 'distSession' is required."

    Verified live end-to-end: PUT to ACTIVE on a real pushed-content Distribution Session succeeded and MBSTF actually transmitted the object, which previously failed outright.

  4. Fix MBSF crash: unbounded retry loop when MBSTF rejects a distribution sessioncreateMbsSession() wasn't idempotent: it rebuilt a brand-new session wrapper (re-triggering the underlying session-creation call) on every invocation, and the periodic status checker called it again every loop tick with no backoff since "is created" never became true after a rejection. This spun at tens of iterations/second until MBSF crashed. Reproduced with a deliberately malformed request; confirmed the fix stops the crash on repeat.

  5. Fix Service Announcement channel using a random port instead of the configured one — the announcement-channel-specific ingest branch drew a random ephemeral port instead of using the configured ssmPort, making it impossible for any statically-configured client to actually receive the broadcast Service Announcement carousel. Confirmed via packet capture: real traffic arrived on a random port while a statically-configured client filtered everything out silently.

  6. Fix uncaught-exception crash on a malformed/misrouted mbs-user-services POST — the POST dispatch matched sub-resource paths it shouldn't have, and a malformed body threw uncaught through checkAndSetUserServiceAnnouncementChannel(); now returns a proper 400.

  7. Fix announcement channel worker hanging forever on a lost MBSTF response — the worker's request-response wait had no deadline; a lost/dropped response (real, transient SBI hiccup — not a bug in the wait itself) left the announcement channel's Distribution Session permanently uncreated, with no way to recover short of restarting MBSF. Now retries after a bounded number of iterations.

  8. Propagate the real MBS User Service type instead of hardcoding MULTICASTcreateMbsSession() always sent MBS_SERVICE_TYPE_MULTICAST to the SMF regardless of the parent User Service's actual servType. SMF's handler only triggers Namf_MBSBroadcast context-create — the step that drives NGAP Broadcast Session Setup to the gNB — for service_type == BROADCAST. So every BROADCAST User Service was silently treated as MULTICAST at the MB-SMF boundary: PFCP/N4mb and MBSTF FLUTE transmission completed normally, but NGAP never reached the gNB, no MRB was ever created, and content had no bearer to travel over — dropped after leaving the UPF with no visible error anywhere. This was the single biggest reason real content pushed through MBSF never reached the UE.

  9. Fix MBSTF rejecting distSessionState PATCH with 'Unknown path in JSON Patch' — the internal Nmb2 PATCH path used a bare /distSessionState JSON Pointer, but MBSTF's actual PATCH target (CreateReqData, patching a nested DistSession) only recognises paths under its own /distSession prefix. Every state-only PATCH (activate/deactivate) silently failed and rolled back — including the one that activates the built-in Service Announcement channel session, so the real Service Announcement carousel never got updated with newly-provisioned services.

  10. Harden MBS User Data Ingest Session PUT validationactPeriods/actPeriodsRepRule mutual exclusion wasn't enforced on the PUT path the way it already was on POST; mbsSessionId/locationDependent (immutable-after-create per TS 29.580 §5.3.2.4.2) weren't fully protected from a PUT silently changing them.

  11. Preserve MNC digit count when building NCGI/TAI PLMN Idsmcc()/mnc() discarded whether the MNC has 2 or 3 digits, so downstream code re-guessed the digit count from the numeric value and misclassified any real 3-digit MNC under 100 (e.g. "001"–"099") as 2-digit.

  12. Stop gating ExtTgtServAreas/NrRedCapUeInfo updates on INACTIVE state — TS 26.502 §4.5.6 lists these among the parameters an MBS Application Provider may update at any time; a PATCH/PUT changing either while ACTIVE/ESTABLISHED was previously silently dropped instead of applied.

TS 29.581 defines PATCH /dist-sessions/{distSessionRef} as operating on
the flat DistSession resource directly, with no wrapper property. The
JSON-Patch built here addressed paths under a non-existent /distSession
prefix instead: '' for a full replace (RFC 6901: the whole document is
the empty JSON Pointer) and /distSessionState for the state-only case.
A conformant MBSTF peer would reject these patches; this only worked
because the paired MBSTF side independently tolerates the same
non-standard prefix.
These fields were previously only ever copied across when the
Distribution Session is INACTIVE -- if PATCHed while ESTABLISHED/ACTIVE
the request was accepted (200) but the change was silently dropped, so
e.g. a PATCH narrowing objAcqIds would echo back the old array. Reject
the request outright (ModelException, MODIFICATION_NOT_ALLOWED) instead
of silently no-op'ing it.
TS 26.502 clause 4.5.6 lists external target service areas and the NR
RedCap UE class among the parameters the MBS Application Provider may
update at any time, alongside mbsServInfo/mbsFSAId/tgtServAreas -- not
restricted to INACTIVE like the fields below them. A PATCH/PUT changing
either while the session was ACTIVE/ESTABLISHED was previously silently
dropped instead of applied.
mcc()/mnc() converted the spec's digit strings to plain uint16_t,
discarding whether the MNC has 2 or 3 digits (and any leading zero).
Downstream code then re-guessed the digit count from the numeric value
(mnc<100?2:3), misclassifying any real 3-digit MNC under 100 (e.g.
"001"-"099") as 2-digit. Added mncLen() (from the source string's
actual length) and wired both call sites to the new length-aware
mb_smf_sc_ncgi_set_plmn_id_len()/mb_smf_sc_tai_new_len() functions in
rt-5gc-service-consumers instead of the guessing ones.
Two gaps in processUserDataIngSessionUpdate()'s PUT path (PATCH on this
resource is intentionally not implemented yet, so unaffected):
- actPeriods and actPeriodsRepRule are mutually exclusive per TS 29.580
  clause 6, but a request setting both silently prioritised actPeriods
  instead of being rejected, unlike the equivalent POST-path check.
- mbsSessionId and locationDependent must never be updated after initial
  provisioning per TS 29.580 clause 5.3.2.4.2, but only mbsDistSessionId
  was actually restored from the stored value before the change-detection
  comparison -- the other two passed through unprotected.
… Patch'

MBSTF's actual PATCH target for /dist-sessions/{id} is CreateReqData, whose
generated applyPatch() only recognises paths under its own "/distSession"
property -- it does not expose DistSession's fields at the top level. The
previous "/distSessionState" (no wrapper) path was rejected outright,
meaning every state-only PATCH (activate/deactivate) silently failed and
triggered a rollback -- including the one that activates the built-in
"USER SERVICE ANNOUNCEMENT CHANNEL" session, i.e. the real MBS-4-MC
broadcast Service Announcement carousel never got updated with
newly-provisioned services.
createMbsSession() always sent MBS_SERVICE_TYPE_MULTICAST to the SMF/MB-SMF
regardless of the parent MBS User Service's own servType. SMF's Nmbsmf
handler only triggers the Namf_MBSBroadcast context-create call -- the step
that actually drives NGAP Broadcast Session Setup to the gNB -- if the
service type is broadcast; for MULTICAST it correctly does nothing there
(multicast UE-join uses a separate, currently-unimplemented
Namf_MBSCommunication procedure instead). So every BROADCAST User Service
ended up silently treated as MULTICAST at the MB-SMF boundary: PFCP/N4mb
and MBSTF FLUTE transmission all completed normally, but NGAP never reached
the gNB, no MRB was ever created for the new session, and content had no
bearer to travel over -- dropped after leaving the UPF with no visible
error anywhere.

The actual servType has to be captured at construction time in the owning
UserDataIngSession instance (which has access to mbsUserService()), not
looked up later via a static locate(ingSessionId) call -- that lookup races
against this object's own registration into the id->instance map and always
loses (the ContextData is built and createMbsSession() invoked on it before
the constructing UserDataIngSession finishes registering itself), silently
falling back to MULTICAST regardless.
sendMbstfRequests() used to be called exactly once (guarded by
requested_mbstf_dist_session, which was never reset), and the wait
following it had no deadline of its own. If MBSTF's response to that
single request was ever lost, confirmed live via gdb: this worker thread
stuck forever in the wait_for() below, with a real, established TCP
connection to MBSTF sitting idle (some transient SBI/SCP hiccup around the
same moment, not a deadlock or a bug in the wait itself) -- the
announcement channel's distribution session would never be created and no
Service Announcement content would ever be pushed, for the lifetime of the
MBSF process, with no way to recover short of restarting it.

Retry after a bounded number of wait iterations (5 seconds) instead of
waiting on the same request forever.
…es POST

The POST branch of the /nmbsf-mbs-us/v1/mbs-user-services dispatch matched on
resource0 == "mbs-user-services" alone, without checking that there was no
sub-resource after it -- so a request actually meant for a sub-resource path (e.g.
a client hitting /mbs-user-services/{id}/ingest-sessions instead of the real
/nmbsf-mbs-ud-ingest/v1/sessions endpoint) got parsed as if it were a brand-new
MBSUserService creation body instead. Confirmed live: that body is missing fields
MBSUserService's constructor requires (e.g. extServiceIds), and
checkAndSetUserServiceAnnouncementChannel() constructs a raw MBSUserService from it
with no try/catch of its own, so the resulting fiveg_mag_reftools::ModelException
was uncaught, called std::terminate(), and took the whole MBSF process down --
instead of the 400 Bad Request a malformed or misrouted client request should get.

Two independent fixes: only match the POST-creates-a-new-service case when there is
no resource1 (mbs-user-services is a collection endpoint, so a real create request
never has one), and wrap checkAndSetUserServiceAnnouncementChannel() in a try/catch
so a genuinely malformed but correctly-routed body (missing a required field) gets
a proper error response instead of crashing the process either way.

mbsf.yaml.in: cross-reference comment on userServiceAnnouncement pointing at
rt-mbs-client's new static announcement-channel bootstrap config, which must agree
with ssmDestinationAddress/ssmPort/the hardcoded announcement TSI here.
…onfigured one

The announcement-channel-specific branch of UserDataIngSession's distribution
session setup drew a fresh random port (32768-65535) for its SSM the same way
the regular per-content-session branch does -- correct there (a new content
session legitimately gets a new port every time), wrong here: the Service
Announcement channel is meant to be a single, fixed, well-known channel, which
is exactly why mbsf.yaml already has a userServiceAnnouncement.ssmPort config
value and rt-mbs-client has a matching static mbsf_client.announcement_channel
bootstrap config -- ssmPort was already correctly plumbed through Context
(Context::userServiceAnnSsmPort()), it just wasn't being used for this.

Confirmed live via tcpdump on the UE's own TUN device: with the random port,
MBSTF genuinely transmitted the real FLUTE carousel content on some other,
unpredictable port every run (e.g. 41873) while a client statically configured
with ssmPort's value (3000) filtered every real packet out silently, since the
destination port never matched -- despite the PDCP/RLC/GW chain now correctly
delivering the content to the TUN device (confirmed by the prior PDCP SN-size
fix in srsRAN_Project_mbs). This made the whole reproducible-bootstrap design
pointless: the one value it depended on being fixed wasn't actually fixed.

Fix: use App::self().context()->userServiceAnnSsmPort() for the announcement
channel's own SSM port instead of the random generator.

Builds clean.
…n session

createMbsSession() unconditionally built a brand new MBSMFMBSSession (and the
underlying mb_smf_sc_mbs_session_new_ipv4()/_ipv6() C session object) on every
call, only guarding the *assignment* to context_data->MBSSession
('if (!context_data->MBSSession)' further down) rather than the work itself.
isMBSSessionCreated() only flips true once the underlying MB-SMF session
genuinely reaches CREATED state -- if MBSTF ever rejects the distribution
session (confirmed live: a malformed request), that never happens, and
userServiceAnnChannelDistributionSessionInfo()'s periodic check
('if (!isMBSSessionCreated(key)) createMbsSession(...)') called this again,
immediately, every single loop iteration, forever: no backoff, no bound.

Confirmed live: MBSF spun at the announcement-channel workerLoop's tick rate
(tens of iterations/second) reconstructing the C session object and
re-notifying MB-SMF each time, until it crashed.

Fix: skip entirely once a session object for this context already exists. The
caller's own retry-driving state (MBSSessionStatus, receivedMBSTFResponse) is
what should progress it from here, not another blind rebuild.

Builds clean.
…issing wrapper

Two compounding bugs blocked every activate/deactivate on an
already-created distribution session:

1. UserDataIngSession's update-merge loop compared the full
   MBSDistributionSessionInfo (including mbsDistSessState) to decide
   needsUpdate vs the lightweight stateUpdate path. Since this API has
   no separate state-only endpoint, every activate/deactivate PUT
   changed state alongside resending the rest of the body, so it always
   (mis)classified as needsUpdate -- triggering a full session rebuild
   PATCH instead of the purpose-built stateUpdate PATCH.

2. That needsUpdate PATCH path itself was also broken: it sent the
   DistSession's JSON directly as the patch value for an empty-path
   add/replace, but MBSTF's actual patch target for /dist-sessions/{id}
   is a CreateReqData, whose fromJSON() requires the value to be a full
   CreateReqData document (a distSession key wrapping the fields) --
   confirmed live via MBSTF's own error: 'Mandatory Information Element
   Missing: distSession: Field "distSession" is required'.

Fixed both: state-only changes now correctly route through the
lightweight stateUpdate path (state normalised out before the equality
check), and the needsUpdate path now wraps its patch value correctly.
Verified live: PUT to ACTIVE on a real pushed-content distribution
session succeeded and MBSTF actually transmitted the object, which
previously failed outright.
processUserDataIngSessionUpdate()'s caller only caught std::out_of_range,
not ModelException. updateMBSDistributionSessionInfo() correctly throws
a ModelException when a client PATCHes objDistrInfo/pckDistrInfo while
the Distribution Session isn't INACTIVE (a real, intentional validation,
not a bug) -- but with no catch for that type, the exception propagated
all the way out of the SBI request handler uncaught and crashed the
entire MBSF process via std::terminate(), taking down every other
active session with it over a single bad client request.

Confirmed live: PATCHing objAcqIds on an ACTIVE session crashed MBSF
outright. Fixed by catching ModelException here too and converting it
to a proper error response, same pattern already used for the
actPeriods/actPeriodsRepRule validation a few lines above.
Context::addMbsSessionId()/deleteMbsSessionId() track SSM addresses currently
in use, keyed by a UniqueMbsSessionId built from the SSM plus service area
info. Both of the two deletion-completion call sites that were supposed to
release this on delete passed the wrong key type:

- setMBSSessionDeleted() passed ids.second (the distSessionInfoKey, e.g.
  "AP_MBS_SESSION_1") to removeFromRegistry() -- which is keyed by the real
  MBSTF-assigned distribution session ID -- and ids.first (the ingSessionId,
  a UUID) to removeDistributionSessionInfo() -- which is keyed by
  distSessionInfoKey. Both erase()-by-wrong-key calls silently no-op.

- setMBSTFDistSessionDeletedFlag()'s whole cleanup block was commented out,
  and its own draft had the identical ids->first/ids->second mismatch, so
  re-enabling it as originally written would have no-op'd the same way.

Net effect, confirmed live: a deleted MBS User Service's SSM address stayed
registered in Context::m_mbsSessionIds forever, so recreating a session on
the same address (e.g. any repeated test run, or any deployment that reuses
a small SSM pool) logs a permanent "Attempt to insert duplicate
UniqueMBSSessionId" warning and leaks a UserDataIngSession object every time.

Fixed both call sites to use ContextData's own correctly-populated fields
(mbstfDistSessionId, distSessionInfoKey) instead of the mismatched ids pair.
Re-enabling setMBSTFDistSessionDeletedFlag()'s full cleanup (including the
final deleteUserDataIngSession() call) also had to be reverted after live
testing surfaced a double-deletion race with setMBSSessionDeleted() -- both
fire for the same logical session on a normal delete, and both would try to
tear down the same UserDataIngSession, destroying it before
setMBSSessionDeleted()'s own deferred DELETE HTTP response could be sent
("User Data Ingest Session deleted before N pending responses sent", then
the portal's request timing out). setMBSSessionDeleted() remains the sole
trigger for the final teardown; setMBSTFDistSessionDeletedFlag() now only
does its own partial-removal (markForDeletion) and registry-cleanup duties.

Verified live: 3 consecutive create+delete cycles on the same SSM address
produce no duplicate-registration warnings and no deletion errors, and the
full MBS Broadcast Service Announcement flow (verify-e2e.sh) still completes
cleanly end to end afterwards.
@jordijoangimenez
jordijoangimenez marked this pull request as draft August 14, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant