Skip to content

cell M6: DAPS — same-channel uplink duplication + CU dedup - #531

Merged
josephnef merged 2 commits into
masterfrom
feat/m6-daps
Nov 5, 2026
Merged

josephnef merged 2 commits into
masterfrom
feat/m6-daps

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Part of M6 (#260), epic #254. On top of #530 (handover execution).

  • Common sequence: a UE's uplink is its hardware beacon, stamped with its egress TSF, so seq = round(egress / P) is identical in every DU's copy. The UE needs no change.
  • DU: forwards its own UEs' uplinks to the CU (F1 v5 Uplink). With CELL_DAPS=1 (the default), during a handover it also forwards the UE's other identity: the target forwards the source identity before the switch, and the source forwards the target identity after.
  • CU: UlStreams delivers each uplink once per UE stream over SeqDedup. The handover command aliases the target identity onto the source's stream, so the stream runs on across the switch. It logs ran.ul_gap for every delivery gap and ran.ul summaries.
  • cell_m6 ctest: a simulated crossing (two lossy paths, the identity flip, the source dying), the alias, the common seq, and the F1 round trip.

Rig (testrig scripts/m6_daps.sh, 3 runs per mode; the source DU is killed the moment its UE accepts the handover, about 2 s before the switch):

uplinks missing from the crossing UE's stream min median max
DAPS 0 0 1
no DAPS 13 14 14

ctest 92/92. Still open: downlink duplication, the different-channel (sequential) DAPS variant, and security pre-staging.

🤖 Generated with Claude Code

…eduplicated at the CU

The common sequence: a UE uplink is its hardware beacon, stamped with the
UE's egress TSF, so seq = round(egress / P) (ul_seq_from_egress) is the
same in every DU's copy. No UE change.

DU: every own-UE uplink is forwarded to the CU (F1 v5 Type::Uplink)
whenever F1 is on. With CELL_DAPS=1 (default), during a handover it also
forwards the UE's other identity: the target forwards the source id staged
with the command, until the switch + the release window; the source
forwards the target id after the switch.

CU: src/cell/Handover.h UlStreams delivers each uplink once per UE stream
over SeqDedup. The handover command aliases the target identity onto the
source's stream, so both identities dedup together and the stream runs on
across the switch. It logs ran.ul_gap for every delivery gap and ran.ul
summaries.

The cell_m6 ctest covers a simulated crossing (two 20 %-lossy paths, the
identity flip, the source dying), the alias, the common seq, and the F1
Uplink round trip.

Rig (testrig scripts/m6_daps.sh, 3 runs per mode, the source DU killed the
moment its UE accepts the handover, about 2 s before the switch): uplinks
missing from the crossing UE's stream are 0/0/1 (min/median/max) with
DAPS and 13/14/14 without. ctest 92/92. docs/cell-m6.md.

Refs #260

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add same-channel DAPS uplink forwarding and CU deduplication

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Forward UE beacon uplink metadata through both DUs during same-channel handover.
• Add F1 v5 Uplink messages and CU streams that deduplicate across UE identities.
• Test crossings and protocol handling; document rig results and remaining DAPS work.
Diagram

sequenceDiagram
    actor UE
    participant SourceDU as Source DU
    participant TargetDU as Target DU
    participant CU
    participant Streams as UlStreams
    CU->>SourceDU: Handover command
    CU->>TargetDU: Handover command
    CU->>Streams: Alias target identity
    UE->>SourceDU: Beacon with egress TSF
    UE->>TargetDU: Same beacon
    SourceDU->>CU: F1 Uplink metadata
    TargetDU->>CU: F1 Uplink metadata
    CU->>Streams: Deliver both copies
    Streams-->>CU: First copy only
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a persistent UE stream ID in F1
  • ➕ Makes stream continuity explicit rather than mapping source and target identities at the CU.
  • ➖ Requires distributing and maintaining a new identifier across the UE, DUs, and handover protocol; expands the scope of this uplink-only change.

Recommendation: Keep the CU-side identity alias. It reuses the existing sequence deduplicator and handover command without changing UE beacon behavior or introducing another distributed identifier.

Files changed (6) +321 / -15

Enhancement (4) +220 / -12
main.cppForward DU-heard uplinks over F1 during handover +51/-6

Forward DU-heard uplinks over F1 during handover

• Adds default-on CELL_DAPS handling and forwards own-UE beacon metadata whenever F1 is enabled. Stages and expires other-identity forwarding for handovers, deriving a shared sequence from each beacon’s egress TSF.

examples/cell/main.cpp

F1.hAdd authenticated F1 v5 Uplink messages +44/-4

Add authenticated F1 v5 Uplink messages

• Bumps the F1 version to 5 and adds an Uplink message carrying DU and UE identities, sequence, and RSSI. Adds encoding and decoding using the existing message authentication.

src/cell/F1.h

Handover.hAdd aliased uplink streams and common beacon sequencing +77/-0

Add aliased uplink streams and common beacon sequencing

• Introduces UlStreams to map source and target identities to one SeqDedup-backed stream and track delivery statistics by DU. Adds a helper that derives the common uplink sequence from egress TSF.

src/cell/Handover.h

main.cppDeduplicate CU uplinks and report stream gaps +48/-2

Deduplicate CU uplinks and report stream gaps

• Accepts F1 Uplink messages from configured DUs, aliases handover identities, and delivers copies through UlStreams. Logs sequence gaps on delivery and emits per-stream delivery and duplicate summaries every ten seconds.

tools/ran-controller/main.cpp

Tests (1) +57 / -0
cell_m6_selftest.cppTest deduplicated crossings and F1 Uplink encoding +57/-0

Test deduplicated crossings and F1 Uplink encoding

• Simulates two lossy paths, an identity switch, and source-path loss while checking once-only delivery and stream aliasing. Also tests sequence derivation and authenticated F1 Uplink round-trips, including rejection of truncated messages.

tests/cell_m6_selftest.cpp

Documentation (1) +44 / -3
cell-m6.mdDocument same-channel DAPS behavior and rig results +44/-3

Document same-channel DAPS behavior and rig results

• Explains common beacon sequencing, dual-DU forwarding, CU aliasing, and uplink metrics. Records DAPS versus non-DAPS rig results and identifies downlink duplication and different-channel DAPS as unfinished.

docs/cell-m6.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Forged beacons suppress real uplinks ✓ Resolved
Description
The foreign-cell branch forwards a packet when its source-address bytes match daps_ids, without
validating its UE beacon or the timestamp used to derive its sequence. During an active handover, a
nearby transmitter can supply that address and timestamp; the DU then signs the resulting F1
message, allowing the CU's deduplicator to advance past legitimate copies.


Remediation recommended

2. Recovered uplinks remain logged as lost ✓ Resolved
Description
The CU emits ran.ul_gap as soon as a fresh sequence jumps past ul_last, although SeqDedup also
accepts unseen older sequences within its window. If forwarding delivers sequences 10, 12, then 11,
the log permanently reports 11 missing even though the CU subsequently delivers it.

3. Uplink history grows without retirement ✓ Resolved
Description
UlStreams::stream_of() allocates permanent identity and stream entries for each newly seen `(cell,
ue)`, while the CU also retains a last sequence and reporting name for each stream. As UE identities
change over a long-running controller session, neither ordinary delivery nor periodic reporting
reclaims those entries, so memory use and ten-second reporting work keep growing.

4. Network stalls can delay radio reception ✓ Resolved
Description
send_f1() performs a synchronous sendto() on the ordinary blocking F1 socket from forward_ul,
which the radio RX callback invokes for each forwarded beacon. If the socket stalls, packet
processing on that RX path waits too; the ignored send result also leaves a failed forwarding
attempt unobserved by the DU.

View review recommended (1)
5. Reused UE ids corrupt the CU's uplink stream counts ✓ Resolved
Description
UlStreams installs a target-to-source alias when a handover command is created, but no path calls
forget to remove that alias after failure, timeout, or cancellation, or to retire the source
mapping after success. When a DU reuses a released source or target identity, the new UE's uplinks
reach the previous UE's stream and unrelated SeqDedup window, where they may be counted as
duplicates or produce bogus ran.ul_gap entries with large missing values.


Informational

6. Uplink summary can report another UE's stream ✓ Resolved
Description
ul_name stores each stream's latest identity, and the 10 s ran.ul summary looks up stats with
ul.stats(nm.second). That lookup resolves the identity through the current ids_ map, not the
stream id. Once that identity is re-aliased to a different stream (its target id reused by a later
handover), the old stream's summary prints the new stream's counts, the same numbers appear twice,
and the old stream's own stats are never shown again.

Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display > Comment parts

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

- The common seq is the reception instant on the shared PTP timebase
  (ul_seq_from_network_time), not the UE beacon's untagged timestamp: a
  forged or replayed frame cannot move the CU's dedup window.
- A copy heard under the other identity is forwarded only if its tag
  verifies.
- F1 socket non-blocking (forwarding runs on the radio RX path); send
  drops counted (cell.f1.summary).
- CU: gaps are confirmed after a 32-seq reorder window, one event per
  run, and a late copy recovers silently. Only identities a configured DU
  reports, or an active handover aliased, create state. Idle streams
  retire after 60 s. Provisional aliases are dropped on failure, timeout
  or cancel, and source identities after retirement. The summary resolves
  stats by stream id.
- UlStreams: stats_of_stream, stream_id, forget_stream; forget drops a
  stream with its last identity. Selftests extended.

Rig (scripts/m6_daps.sh, 3 runs each): missing 0/0/0 with DAPS vs 14/15/18
without; a single grouped no-DAPS gap per run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in 4ff81f3:

  1. Forged beacons: the common seq is now the reception instant on the shared PTP timebase (ul_seq_from_network_time), never the beacon's untagged timestamp. A forged or replayed frame cannot advance the window, and at worst it is one more copy of 'now'. A foreign-identity copy is also forwarded only if its tag verifies.
  2. Late copies: gaps are confirmed only once a run's last seq is 32 behind the newest, so a late copy recovers it silently. One event per run.
  3. Unbounded state: only identities a configured DU reports, or an active handover aliased, create state. Idle streams retire after 60 s, together with their CU records.
  4. Blocking sends: the F1 socket is non-blocking (forwarding runs on the RX path), and send drops are counted.
  5. Alias lifecycle: a provisional alias is dropped when its flight fails, times out, is cancelled, or loses a DU, and a completed handover's source identity leaves after its retirement. forget drops a stream with its last identity, so a reused id starts fresh (tested).
  6. Summary lookup: stats are resolved by stream id (stats_of_stream).

The 'persistent UE stream ID' item is an alternative-approach note: the alias created at command time already gives one stream across both identities.

Rig re-run on the final code: missing 0/0/0 with DAPS vs 14/15/18 without (3 runs each). ctest 92/92.

@josephnef

Copy link
Copy Markdown
Collaborator Author

All 6 Qodo findings resolved. The remaining item, 'Use a persistent UE stream ID in F1', is Qodo's alternative-approach note, not a finding: the command-time alias already gives the UE one stream across its identities. Merging under skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 5, 2026
@josephnef
josephnef merged commit 3a99bc7 into master Nov 5, 2026
51 of 53 checks passed
@josephnef
josephnef deleted the feat/m6-daps branch November 5, 2026 17:22
josephnef added a commit that referenced this pull request Nov 5, 2026
Part of M6 (#260), epic #254: **different-channel handover**, on top of
#530 and #531.

- **Command and IE:** `HoCommand.target_channel` comes from the target
DU's report, and `HoIe.channel` travels with it (IE v2, F1 v6).
- **UE:** at the switch it shifts its clock by the TSF delta (no target
beacon needed first), takes the target identity, and **stops its uplink
beacon → retunes (`FastRetune`) → restarts it on the new channel**. It
logs the retune time and the first serving beacon after it.
- **Deadline to return:** with no target grant within
`CELL_HO_FALLBACK_SF`, the UE retunes back to the source, which still
holds it. This bounds the deaf time.
- **Trigger:** with one radio per DU, no DU hears a UE across channels,
so the A3 has no neighbour value to compare. **UE measurement gaps are
the next step.** Until then, `RAN_HO_FORCE=SRC:UE:TGT@SECONDS` forces
one handover through the normal execution path.

Rig (testrig `scripts/m6_handover.sh --channel2 157`: ch149 → ch157, 5
normal + 3 runs with the target killed after staging; all pass):

| KPI | min | median | max |
|---|---|---|---|
| `FastRetune` (8852CE, 11 retunes) | 23.5 ms | 23.6 ms | 23.7 ms |
| first target beacon after the retune | 37 ms | 52 ms | 101 ms |
| interruption (sf, first in-slot uplink at the target) | 2 | 2 | 3 |
| target crash: fallback / source uplink missed (sf) | 10 / 10 | 10 / 10
| 10 / 10 |

The switch lands on the target's effective superframe in every run:
clock continuity holds across the channel change. ctest 92/92. Details
in `docs/cell-m6.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant