Skip to content

cell M6: UE re-attachment after a DU restart - #536

Merged
josephnef merged 2 commits into
masterfrom
feat/m6-ue-reattach
Nov 6, 2026
Merged

josephnef merged 2 commits into
masterfrom
feat/m6-ue-reattach

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

M6 (#260, EPIC #254): a UE re-attaches to its DU after the DU restarts. This closes the open item from #535.

Why

#535 made the UE's clock follow a restarted DU. But the restarted DU's grant maps begin again at a low epoch on a new superframe count, and the UE's GrantMapReceiver refused them as old. The UE kept its old map and was never heard in the new grid (0 uplinks on the rig).

What

  • GrantMapReceiver::offer_after_restart(m, beacon_sf, window = 16): a map at a strictly lower epoch than the newest accepted, but due within 16 superframes of the beacon carrying it, starts the receiver over.
    • A stale map from before the restart is due far from the new count and is still refused.
    • The same epoch again is the live map repeated, which is not a restart. A first rig run found this case.
    • Also adds a newest_epoch() getter.
  • UE:
    • the restart path is asked only for CELL_UE_REATTACH_SF (200) superframes after the UE's clock fit confirms a step of the DU's TSF (cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync step detection) #535);
    • on a re-attach it resets what belonged to the old DU instance, as a handover switch does: DL HARQ space, MR generation (the DU checks it against its new count), neighbour list, gap parking, and pending handover IEs;
    • it logs cell.ue.reattach.
  • Logging fix: the UE's cell.dl.rx in_slot now judges against the DL slot of its grant in force, not the position matching its id. Any CU move showed as DL out of slot (0.05), although the frames were right.
  • Docs: docs/cell-m6.md gains a "Re-attachment after a DU restart" section and a fix for the doubled DAPS heading left by cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync step detection) #535. RACH-joined UEs across a DU restart are listed as open.
  • Selftest (cell_m6): refused when not near the beacon's count, a pre-restart stale map, another cell's map, accepted and then running on, never back to a higher epoch, and the live map repeated.

Rig (testrig scripts/m6_fitstep.sh: DU1 killed and restarted under its UE, 3/3 pass)

KPI value
re-attachments per run 1 (old epoch 7–11 → epoch 1)
uplink heard by the restarted DU, from 10 sf after its service start 99.8–100 % of superframes
of them in slot 99.8 %
first in-slot uplink after service start superframe 0
UE DL in its granted slot after the re-attach 100 %
  • Detection off (counterfactual): no re-attach, and the restarted DU never hears the UE.
  • Regression: same-channel handover 2/2 plus fallback 1/1, measurement-gap handover 1/1, phase planning 1/1, transient TSF jump 3/3. No spurious re-attach in any of them.

Residual: grant maps carry no tag (they never did), so the epoch rule orders maps; it doesn't authenticate them.

🤖 Generated with Claude Code

A restarted DU's grant maps begin again at a low epoch on a new superframe
count; the UE's receiver refused them as old and the UE was never heard in
the new grid. GrantMapReceiver::offer_after_restart: a map at a strictly
lower epoch but due within 16 superframes of the beacon carrying it starts
the receiver over — asked only for CELL_UE_REATTACH_SF (200) after the UE's
clock fit confirmed a step of the DU's TSF (#535). The UE then resets what
belonged to the old DU instance, as on a switch: DL HARQ space, MR
generation, neighbour list, gap parking, pending handover IEs
(cell.ue.reattach).

Also: the UE's cell.dl.rx in_slot now judges against its grant's DL slot,
not the position matching its id (a CU move showed as DL out of slot); the
doubled DAPS heading in docs/cell-m6.md.

Rig (testrig scripts/m6_fitstep.sh, restart case, 3/3): one re-attach per
run, the restarted DU hears the UE 99.8-100 % of superframes from service
start, in slot 99.8 %, first in-slot uplink at service start; UE DL in its
granted slot 100 %. Regression: handover 2/2 + fallback 1/1, measgap 1/1,
phase planning 1/1; no spurious re-attach.

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

Re-attach static UEs after a DU restart

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Re-attach static UEs to restarted DUs whose grant-map epochs begin again.
• Clear state tied to the old DU instance and correct DL slot logging after grant moves.
• Test restart-map acceptance and document rig results and remaining RACH limitations.
Diagram

graph TD
  DU["DU beacon"] --> FIT["Clock fit"] --> RX["Grant receiver"] --> GATE{"Restart eligible?"}
  GATE -->|yes| RESET["Reset grant state"] --> CLEAN["Clear UE state"]
  GATE -->|no| KEEP["Keep existing state"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a DU instance identifier to grant maps
  • ➕ Distinguishes a new DU instance from old maps without relying on epoch and timing proximity.
  • ➖ Requires a grant-map protocol change and coordinated DU and UE deployment.
  • ➖ An instance identifier alone would not authenticate a map.

Recommendation: Use the proposed clock-step-gated epoch rule for the current static-UE scope: it restores service without changing the wire format and limits when old epochs can be accepted. Consider an explicit DU instance identifier if restart recovery must extend beyond this timing assumption; RACH-joined UEs still need a separate re-join path.

Files changed (4) +163 / -10

Bug fix (2) +73 / -3
main.cppGate UE re-attachment on confirmed DU clock steps +55/-3

Gate UE re-attachment on confirmed DU clock steps

• Adds a configurable 200-superframe re-attachment period after a confirmed clock step. On an accepted restart map, clears state belonging to the old DU instance and emits a re-attachment event. DL receive logging now judges slot placement against the grant in force rather than the UE-id position.

examples/cell/main.cpp

GrantMap.hAllow eligible lower-epoch maps after a restart +18/-0

Allow eligible lower-epoch maps after a restart

• Adds a newest-epoch getter and a restart-specific offer method. It rebinds the receiver only for a same-cell map with a strictly lower epoch whose effective superframe is within the beacon-relative window.

src/cell/GrantMap.h

Tests (1) +34 / -0
cell_m6_selftest.cppExercise restart-map acceptance and rejection +34/-0

Exercise restart-map acceptance and rejection

• Tests rejection of distant, stale, other-cell, equal-epoch, and higher-epoch maps through the restart path. Verifies that a nearby lower-epoch map starts a new receiver epoch and subsequent normal maps advance.

tests/cell_m6_selftest.cpp

Documentation (1) +56 / -7
cell-m6.mdDocument restart re-attachment and rig results +56/-7

Document restart re-attachment and rig results

• Explains the lower-epoch acceptance rule, its clock-step gate, reset behavior, and lack of map authentication. Records rig outcomes, identifies RACH-joined UE recovery as open, and fixes a duplicated DAPS heading.

docs/cell-m6.md

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

qodo-free-for-open-source-projects Bot commented Nov 6, 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


Remediation recommended

1. Forged maps can reset a UE's grant state ✓ Resolved
Description
GrantMapReceiver::offer_after_restart accepts any map whose epoch is strictly lower than the
newest accepted, provided its effective_sf falls within 16 superframes of the beacon's superframe,
and then calls rebind and offer, which discards the current grant state. Grant maps carry no tag
or other integrity field, so nothing authenticates the map or binds it to the DU that sent it, and a
replayed or crafted map can pass this check during the 200-superframe re-attach window after a clock
step. The PR description acknowledges this as a residual, but the new path widens what an
unauthenticated map can do by letting it overwrite the UE's existing grant state.

2. Gaps and clock fixes stall after restart ✓ Resolved
Description
The reattachment reset clears gap_parked but leaves next_gap_sf and no_step_until_sf expressed
in the old DU's local superframe count. When the restarted DU's count drops, measurement gaps wait
for the old deadline, and a recent gap can suppress needed TSF corrections until the new count
catches up.

3. Restart can delay handover fallback ✓ Resolved
Description
The reattachment reset leaves ho_back and its old target-superframe deadline intact even though it
replaces the target's grant-map state. If a handover is still awaiting a target grant when that
target restarts without the staged UE, the fallback check waits for the restarted DU to reach the
old deadline before returning to the source.

View review recommended (1)
4. Some received downlinks lose their log ✓ Resolved
Description
The new cell.dl.rx slot lookup leaves want unset when no grant map is current or that map lacks
the UE, so the existing if (!want) return skips the event after HARQ processing. A valid downlink
received before its first grant map, or while absent from the map in force, is therefore omitted
from reception logs rather than recorded as out of slot.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

…based on the new count; ungranted DL still logged; forged-map exposure documented

- next_gap_sf and no_step_until_sf return to their sentinels on a re-attach
  (they were on the old DU's count).
- A handover awaiting a target grant when the target restarted keeps its way
  back, its deadline re-based on the restarted target's count.
- cell.dl.rx is logged without a grant in force too (in_slot 0, granted 0).
- docs: the forged-map exposure the restart path adds, and grant-map
  authentication as an open item.

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

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in d40aed3:

  1. Forged maps can reset grant state (requirement gap): grant maps have never carried a tag, so a forged map could already redirect a UE's slots. The restart path is additionally gated by a confirmed step of the DU's clock (6 agreeing beacons, cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync step detection) #535) and limited to the re-attach window. Authenticating the grant map IE (a per-cell tag, as the NL/MR/HO IEs have) is a wire change for DU, UE and witness. It is out of scope here: the exposure is now stated in docs/cell-m6.md next to the rule, and grant-map authentication is listed as an open item.
  2. Gap and step deadlines on the old count: next_gap_sf and no_step_until_sf return to their sentinels on a re-attach.
  3. Handover fallback delayed by a target restart: with ho_back active, the way back is kept and its deadline is re-based on the restarted target's count.
  4. Ungranted DL not logged: cell.dl.rx is emitted without a grant in force too (in_slot 0, new field granted 0).

ctest 95/95. Rig re-run (m6_fitstep.sh, restart + transient + detection-off): passes; one re-attach, uplink 99.8 % heard and in slot from service start, DL 100 % in its granted slot.

@josephnef

Copy link
Copy Markdown
Collaborator Author

CI green; all four Qodo findings resolved. The remaining item is the high-level alternative (a DU instance identifier in grant maps), for which Qodo's own recommendation is the clock-step-gated epoch rule used here for the static-UE scope, without a wire change. Map authentication and RACH re-join are listed as open in docs/cell-m6.md. Merging with skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 6, 2026
@josephnef
josephnef merged commit 27e5928 into master Nov 6, 2026
27 of 28 checks passed
@josephnef
josephnef deleted the feat/m6-ue-reattach branch November 6, 2026 09:00
josephnef added a commit that referenced this pull request Nov 6, 2026
M6 (#260, EPIC #254): the grant map IE is now authenticated. This closes
the open item from #536.

## Why
The grant map was the only scheduling IE without a tag. A forged map
could already move a UE's slots, and since #536 it could also reset the
UE's grant state through the DU-restart path.

## What
- **GM IE v2** (`src/cell/GrantMap.h`) has the layout `… crc16 tag(8)`.
The tag is the first 8 bytes of `mac('G' || body..crc)` under the cell
key (`CELL_PSK`), the same scheme as the NL/MR/handover IEs.
- `encode_grant_ie`, `decode_grant_ie` and `find_grant_map` take the
`Mac`. It is mandatory, so every caller decides.
- With a key, a forged map or an untagged one (zeros) is refused, using
a constant-time compare.
- Without a key (an unauthenticated development cell, or a passive
witness), only the structure is checked.
  - v1 maps are refused. A failing Mac airs nothing.
  - `Mac` moves from `Rach.h` to `GrantMap.h`, which `Rach.h` includes.
- **Callers:**
  - the DU's `build_du_beacon(map, mac, …)`;
- the UE (serving maps, the handover target's maps, the restart path),
with `auth`;
  - the witness, with `CELL_PSK` if it has one.
- **The UE** counts serving maps taken and refused: `gm_ok` /
`gm_refused` in `cell.ue.done`, and the first refusal logs
`cell.ue.gm_refused`.
- **Selftests (cell, cell_node):** round trip with a key, another key,
tag bit flip, zero tag at a keyed receiver, structure-only without a
key, an edited map re-CRC'd under another key, v1 refused, a failing
Mac, and the 24-grant map fitting 255 bytes (226).

## A correction this PR makes
The M5/M6 rig scripts never set `CELL_PSK`. So the handover, DAPS,
inter-frequency, measurement-gap, phase-planning and clock-fit rig
results ran as an **unauthenticated development cell**: zero tags, not
checked. F1 was keyed throughout. `docs/cell-m6.md` now says so. The
testrig scripts give every cell role one key, and the suite was re-run
keyed (below).

## Rig
**Wrong-key test** (testrig `scripts/m6_gmauth.sh`, 2/2). Every role
holds the cell key except UE2:
| | maps taken | maps refused | heard in slot by its DU |
|---|---|---|---|
| UE1 (cell key) | 586–587 | 0 | 99.8 % |
| UE2 (another key) | 0 | 586–587 | never |

**The M6 suite keyed, all pass.** No keyed UE refused a map (36 UE
runs), apart from UE2 above.
- same-channel handover: 2, plus fallback 1;
- inter-frequency handover: 2;
- DAPS: 2 + 2 (missing uplinks 0/0 vs 11/16 without);
- measurement-gap handover: 2;
- phase planning: 2;
- clock fit, transient jump and DU restart with re-attach: 1 + 1.

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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
josephnef added a commit that referenced this pull request Nov 6, 2026
…he TBTT protection (#538)

M6 (#260, EPIC #254): a UE that joined through the RACH re-joins after
its DU restarts. Closes the open item from #536. It also includes a
Kestrel driver fix that this work turned up.

## Why
A RACH-joined UE holds an id that a restarted DU instance doesn't know.
After re-attaching (#536), the UE was in no map and was never heard
again.

## What
- **UE (`examples/cell`):** a joined UE that the authentic map in force
has not granted for `CELL_UE_REJOIN_SF` (30) superframes drops its id
and joins again, logged as `cell.ue.rejoin`. That covers both a DU that
restarted and one that released it.
- Its uplink beacon goes off, its ACK responder is cleared, and the old
id's DL HARQ space goes.
- It gets a new RACH client with a **fresh nonce**. A DU answers a
repeated (MAC, nonce) request from its cache, which would be the old id.
  - It keeps the stable joiner MAC.
  - The rule is skipped mid-handover. Static UEs never re-join.
- **Kestrel driver fix (`HalKestrel::stop_beacon`):** `StopBeacon`
cleared the beacon-TX enable but left the TBTT protection that
`StartBeacon` turns on (`TBTT_PROHIB_EN`, `BRK_SETUP`). The port kept
holding injected frames to its old beacon grid.
- On the rig, every re-join msgA arrived at a fixed ~29.9 ms into the
superframe, past the RACH slot. It drifted with the UE's own crystal,
whatever offset the UE drew.
  - An A/B with the beacon left running landed in slot.
- `stop_beacon` now clears both bits. The value of the ~13 ms hold,
presumably `BCN_HOLD`, is not separately established.
- **Docs:** `docs/cell-m6.md` gains a section on re-joining through the
RACH, and the `StopBeacon` contract note in `docs/scheduled-mac.md`
covers Kestrel.

## Rig (testrig `scripts/m6_rejoin.sh`, keyed, 3/3 pass)
UE1 joins cell 1 through the RACH, and DU1 is killed and restarted under
it 35 s in.
| KPI | value |
|---|---|
| re-attach, then one re-join | every run |
| second join after the restarted DU's service start | −4 to +28 sf
(median 11) |
| its RACH attempts | 4–8 (the first fall in the restarted DU's
acquisition) |
| new id's uplink at the restarted DU, from 10 sf after the join | heard
100 %, in slot 100 % |

**Counterfactual (`CELL_UE_REJOIN_SF=0`):** the UE keeps a dead id and
is never heard.

**Regression for the `StopBeacon` change:**
- inter-frequency handover 2, plus fallback 1 (each a beacon stop and
restart);
- measurement-gap handover 2;
- clock-fit restart and transient 1 + 1;
- same-channel handover 1.

All pass.

🤖 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