diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 9d08c0b9..e4a82f78 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -382,7 +382,7 @@ pass; both cells on ch149). | case | steps followed | UE skew, max \|ppm\| | DL frames in the DL area after | uplinks off-slot, 20 sf after | |---|---|---|---|---| -| restart, detection on | the restart jump (−30.5 to −31.1 s) and the acquisition steps | 8.0–10.1 | 100 % | not judged (below) | +| restart, detection on | the restart jump (−30.5 to −31.1 s) and the acquisition steps | 8.0–10.1 | 100 % | see re-attachment (below) | | restart, detection off (1 run) | — | 996 125 | 11 % | not judged | | transient, detection on | none (5 beacons held, then dropped) | 8.4–11.9 | 100 % | 0 | @@ -392,7 +392,62 @@ pass; both cells on ch149). undo), the UE followed the transient out and back, 3 beacons late each way. That put 4–5 of the next 20 uplinks off-slot, in 3 of 3 runs. -## DAPS: the duplicated uplink (same channel)## DAPS: the duplicated uplink (same channel) +## Re-attachment after a DU restart + +A restarted DU's clock is followed (above), but its grant maps begin again +at a low epoch on the new superframe count. A UE's grant receiver refuses +them as old, so without re-attachment the UE keeps its old map and is never +heard in the new grid. + +- **The rule** (`GrantMapReceiver::offer_after_restart`): 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, so it is still refused. The same + epoch again is the live map repeated, not a restart. +- **When:** only for `CELL_UE_REATTACH_SF` (200) superframes after the UE's + clock fit confirms a step of the DU's TSF. A DU that never jumped is never + re-attached to. +- **What else resets** (`cell.ue.reattach`): what belonged to the old DU + instance, as on a handover switch. That is the DL HARQ sequence space, the + MR generation (the DU checks it against its own new count), the neighbour + list and gap parking, and any handover IE the old instance aired. +- **The exposure:** grant maps carry no tag (they never did), so the epoch + rule orders maps; it does not authenticate them. A forged map could + already redirect a UE's slots. The restart path widens that: within the + re-attach window after a clock step, a forged lower-epoch map due near the + beacon's count also resets the UE's per-DU state (HARQ, MR generation, + neighbours, pending handover IEs). Grant-map authentication is open + (below). + +**Rig result** (testrig `scripts/m6_fitstep.sh`, restart case, 3 runs, all +pass). +- **The case:** DU1 is killed 30 s in and restarted at once, under its + attached UE. Service start is the restarted DU's first in-loop discipline + pass. + +| KPI (3 runs) | value | +|---|---| +| re-attachments per run | 1 (old epoch 7–11 → epoch 1, at the new DU's superframe 6) | +| uplink heard by the restarted DU, from 10 sf after service start | 99.8–100 % of superframes | +| of them in slot | 99.8 % | +| first in-slot uplink after service start | superframe 0 | +| UE DL frames in their granted slot after the re-attach | 100 % | + +- **Before the restarted DU serves,** its grid is still moving: it is + acquiring, with its beacon on. The UE has already re-attached to it, and + its uplinks in that time are off-slot against the moving grid. They are + not counted. +- **With detection off,** the UE's clock fit breaks (above). Re-attachment + is never armed, and the restarted DU never hears the UE. +- **No spurious re-attachment** was seen in the transient runs or in the + regression runs. Those were same-channel handover 2/2 plus fallback 1/1, + measurement-gap handover 1/1 and phase planning 1/1, all passing. +- **A logging fix found on the way:** the UE judged its DL frames against + the slot matching its id, not the slot of its grant. A CU move to another + position showed as DL out of slot (0.05 in slot); the frames themselves + were right. It now uses the grant in force. + +## DAPS: the duplicated uplink (same channel) **Common sequence.** A UE uplinks once per superframe. Every DU that hears one transmission hears it at the same instant on the shared PTP timebase, @@ -492,11 +547,14 @@ superframe when there is nothing to serve. running ones, which never move. With three or more cells on different channels, the 26 ms retune leaves no layout where every pair can measure both ways. Re-phasing live cells, or longer superframes, is open. -- **A DU restart under attached UEs:** the UE's clock now follows it, but - its grant state does not. The restarted DU's grant maps start again at a - low epoch and another superframe count. The UE keeps its old map and is - not heard in the new grid. Re-attachment after a DU restart (a fresh map - epoch base, or the UE dropping a map no longer in force) is open. +- **Grant-map authentication:** the grant map IE has no tag, unlike the + NL, MR and handover IEs. A tag keyed per cell (as `CELL_PSK`), checked + before any `offer`, would close the forged-map exposure, including the + restart path. It is a wire change for the DU, UE and witness. +- **A DU restart under RACH-joined UEs:** re-attachment (above) is for the + rig's static UEs, whose id the restarted DU grants again. A UE that joined + through the RACH holds an id the new DU instance does not know; re-joining + it is open. - **No crossing test:** every handover result here is from stationary UEs. The trigger is either forced or the measured standing difference between two cells. No A→B path-loss ramp has been run, so none of these numbers diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index bbdf85e5..fb087db7 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -130,6 +130,11 @@ // beacons outlast the DU's discipline pass // (CELL_SYNC_LOOP_MS 500): a jump the DU undoes // itself is never followed, only a lasting one. +// CELL_UE_REATTACH_SF [200] UE: for this long after a confirmed step of the +// DU's clock, a grant map at a LOWER epoch that is +// due near its beacon's superframe means the DU +// restarted: the grant state starts over +// (cell.ue.reattach). 0 = never. // CELL_DU_TEST_TSF_STEP DU, test only: US@SF — add US to the TSF once at // local superframe SF, behind the discipline's back // (cell.test.tsf_step); the discipline undoes it. @@ -2191,6 +2196,16 @@ int run_ue(IRadio *dev, const Cfg &c) { sync.SetStepDetect(envl("CELL_UE_FIT_STEP_US", 200, 0, 1000000), static_cast(envl("CELL_UE_FIT_CONFIRM", 6, 1, 50))); long long fit_steps_seen = 0, fit_rejected_seen = 0; + /* Re-attachment after a DU restart (mu): after a confirmed step of the + * DU's clock, for CELL_UE_REATTACH_SF superframes, a grant map at a lower + * epoch but due near the beacon's own superframe starts the UE's grant + * state over (GrantMapReceiver::offer_after_restart); the main loop then + * resets what belonged to the old DU instance. */ + const int64_t reattach_sf = envl("CELL_UE_REATTACH_SF", 200, 0, 100000); + long long reattach_armed_steps = 0; /* sync.Steps() at the arming */ + int64_t reattach_until_ms = 0; + bool reattached = false; + uint32_t reattach_old_epoch = 0, reattach_new_epoch = 0; Wrap32 wrap; GrantMapReceiver rxr(c.cell); /* DL HARQ receiver + the DL SNR since the last feedback publish (mu). */ @@ -2345,8 +2360,20 @@ int run_ue(IRadio *dev, const Cfg &c) { static_cast(me_a.load()), auth)) ho_seen = h; wrap.feed(p.RxAtrib.tsfl); - if (auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size())) - rxr.offer(*m); + if (reattach_sf > 0 && sync.Steps() != reattach_armed_steps) { + reattach_armed_steps = sync.Steps(); + reattach_until_ms = ue_ms() + reattach_sf * P / 1000; + } + if (auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size())) { + const uint32_t old_epoch = rxr.newest_epoch(); + if (!rxr.offer(*m) && ue_ms() < reattach_until_ms && + rxr.offer_after_restart(*m, static_cast(*eg) / static_cast(P))) { + reattached = true; + reattach_until_ms = 0; + reattach_old_epoch = old_epoch; + reattach_new_epoch = m->epoch; + } + } if (me_a == 0) { const auto rb = find_rach_responses(p.Data.data(), p.Data.size()); if (rb.first) { @@ -2379,19 +2406,23 @@ int run_ue(IRadio *dev, const Cfg &c) { return; const int64_t du_at = sync.RemoteForLocal(static_cast(wrap.extend(p.RxAtrib.tsfl))); - want = grid->find(SlotKind::Downlink, static_cast(me)); pos = grid->at(du_at); + /* The DL slot of our grant in the map in force (the CU may have + * moved us off the position matching our id). */ + if (const GrantMap *gm = rxr.current(static_cast(std::max(pos.superframe, 0)))) + if (const Grant *g = gm->find(static_cast(me))) + want = g->dl_slot; } - if (!want) - return; + /* No grant in force: still logged, as out of slot (granted 0). */ char l[260]; snprintf(l, sizeof l, "{\"ev\":\"cell.dl.rx\",\"ue\":%u,\"sf\":%llu,\"seq\":%u,\"attempt\":%u," - "\"new\":%d,\"rx_sf\":%lld,\"phase_us\":%lld,\"in_slot\":%d,\"rssi\":%d," - "\"snr_half_db\":%d}", + "\"new\":%d,\"rx_sf\":%lld,\"phase_us\":%lld,\"in_slot\":%d,\"granted\":%d," + "\"rssi\":%d,\"snr_half_db\":%d}", me, (unsigned long long)d->sf, d->seq, d->harq.attempt, fresh ? 1 : 0, (long long)pos.superframe, (long long)pos.into_us, - pos.slot && *pos.slot == *want ? 1 : 0, p.RxAtrib.rssi[0] - 110, p.RxAtrib.snr[0]); + want && pos.slot && *pos.slot == *want ? 1 : 0, want ? 1 : 0, + p.RxAtrib.rssi[0] - 110, p.RxAtrib.snr[0]); emit(l); }); }); @@ -2441,6 +2472,33 @@ int run_ue(IRadio *dev, const Cfg &c) { fit_steps_seen = sync.Steps(); fit_rejected_seen = sync.Rejected(); } + /* Re-attached to a restarted DU (the RX path started the grant state + * over): what belonged to the old DU instance goes, as on a switch. */ + if (reattached) { + reattached = false; + hrx = HarqReceiver{}; /* its DL sequence space starts again */ + mr_gen = 0; /* its MR window is its new superframe count */ + nbrs.clear(); + have_nbrs_gen = false; + gap_parked.clear(); + next_gap_sf = -1; /* deadlines on the old count: re-set on the new one */ + no_step_until_sf = -1; + ho_seen.reset(); /* commands aired by the old instance */ + ho_pending.reset(); + /* A target that restarted mid-handover lost the staged UE: the way + * back stays, its deadline re-based on the new count. */ + if (ho_back) + ho_back->deadline_sf = du_now / P + ho_fallback_sf; + force_beacon = true; + listen_from = -1; + char l[200]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.reattach\",\"ue\":%d,\"cell\":%u,\"old_epoch\":%u," + "\"new_epoch\":%u,\"sf\":%lld}", + me_a.load(), cell_a.load(), reattach_old_epoch, reattach_new_epoch, + (long long)(du_now / P)); + emit(l); + } int64_t sfn = du_now >= 0 ? du_now / P : 0; /* M6: a fresh handover command is accepted once (HoReceiver: newer * id, instant ahead); at its instant the UE switches: the clock is diff --git a/src/cell/GrantMap.h b/src/cell/GrantMap.h index ea31883e..7b544bd5 100644 --- a/src/cell/GrantMap.h +++ b/src/cell/GrantMap.h @@ -192,6 +192,7 @@ class GrantMapReceiver { explicit GrantMapReceiver(uint16_t cell_id) : cell_id_(cell_id) {} uint16_t cell_id() const { return cell_id_; } + uint32_t newest_epoch() const { return newest_epoch_; } /* True when `m` was accepted (queued). */ bool offer(const GrantMap &m) { @@ -221,6 +222,23 @@ class GrantMapReceiver { } return current_ ? &*current_ : nullptr; } + /* A restarted DU: its maps begin again at a low epoch, on another + * superframe count. A map offer() would refuse as old, but due within + * `window` superframes of the beacon carrying it (`beacon_sf`, that beacon's + * TSF / period: the DU's count NOW), starts the receiver over — the caller + * asks only once it has seen the DU's clock jump (a TsfSync step). A stale + * map from before the restart is due far from the new count and still + * refused. True when it started over. */ + bool offer_after_restart(const GrantMap &m, uint64_t beacon_sf, uint64_t window = 16) { + /* Strictly lower: the same epoch again is the live map repeated. */ + if (m.cell_id != cell_id_ || !have_any_ || m.epoch >= newest_epoch_) + return false; + const uint64_t d = m.effective_sf > beacon_sf ? m.effective_sf - beacon_sf : beacon_sf - m.effective_sf; + if (d > window) + return false; + rebind(cell_id_); + return offer(m); + } /* Serve another cell from scratch (handover, re-association). */ void rebind(uint16_t cell_id) { cell_id_ = cell_id; diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index 83728463..062e2134 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -14,6 +14,7 @@ #include #include "cell/F1.h" +#include "cell/GrantMap.h" #include "cell/Handover.h" #include "cell/HoCommand.h" #include "cell/MeasGap.h" @@ -589,6 +590,39 @@ int main() { CHECK(disc.phase() == 51200); } + /* --- Re-attachment after a DU restart (GrantMap.h offer_after_restart). --- */ + { + GrantMapReceiver rx(1); + GrantMap m; + m.cell_id = 1; + m.epoch = 13; + m.effective_sf = 400; + m.grants = {{}}; + m.grants[0].ue = 1; + CHECK(rx.offer(m) && rx.newest_epoch() == 13); + /* The restarted DU: epoch 2, due at 97 while its beacons read sf 94. */ + GrantMap n = m; + n.epoch = 2; + n.effective_sf = 97; + CHECK(!rx.offer(n)); /* an old epoch: refused */ + CHECK(!rx.offer_after_restart(n, 400)); /* not near the beacon's count */ + GrantMap stale = m; + stale.epoch = 12; + stale.effective_sf = 390; + CHECK(!rx.offer_after_restart(stale, 94)); /* a map from before the restart */ + GrantMap other = n; + other.cell_id = 2; + CHECK(!rx.offer_after_restart(other, 94)); /* another cell's */ + CHECK(rx.offer_after_restart(n, 94)); /* starts over */ + CHECK(rx.newest_epoch() == 2 && rx.current(97) && rx.current(97)->epoch == 2); + GrantMap n2 = n; + n2.epoch = 3; + n2.effective_sf = 115; + CHECK(rx.offer(n2) && rx.current(115)->epoch == 3); /* and runs on normally */ + CHECK(!rx.offer_after_restart(m, 115)); /* never back to a higher epoch this way */ + CHECK(!rx.offer_after_restart(n2, 115)); /* the live map repeated: no restart */ + } + if (failures == 0) std::printf("cell_m6_selftest: all checks passed\n"); return failures == 0 ? 0 : 1;