# План: эпоха (reset_id) для ETCP Router Аналог фикса `reset_id` из ETCP (см. `61c572c`), применённый к сервисному слою маршрутизации `etcp_router` (src/routing_layer/etcp_router.c). ## 1. Контекст `etcp_router` — «упрощённый TCP поверх ETCP»: своя seq-нумерация (`tx_seq`/`rx_seq`), reorder (`recv_q`), inflight-контроль (`inflight_q`), ретрансмиты, START/RST handshake. Текущий механизм детекции рестарта пира: - `SVC_ROUTE_HDR.flags & ROUTER_FLAG_START` — флаг на первом data-пакете сеанса (`seq==0 && !start_sent && pl_len>0`, `etcp_router.c:207`). - `router_check_peer_restart` (`etcp_router.c:1055`) — если пришёл START на уже синхронизированное соединение (`peer_sync_done==1`), то это рестарт пира; **дедуп ретрансмитов** — побайтовое сравнение с `last_start_dgram` (`etcp_router.c:1063`). Совпало → это ретрансмит (не рестарт). Не совпало → `etcp_router_conn_restart` (полный сброс + `sess_id++`). - `ROUTER_FLAG_RST` + `router_restart_send` (`etcp_router.c:285`) — сброс только TX-стороны; повторные RST гасятся через `send_restart_pending`. - `sess_id` (`etcp_router.h:121`) — **локальный счётчик переустановок, на wire не пишется**. Используется только в `DEBUG_INFO`. ## 2. Проблема (аналог ETCP seq-коллизии) Дедуп рестарта строится на **побайтовой идентичности** `last_start_dgram`. Это тот же класс хрупкости, что убрали в ETCP: 1. **Байт-идентичный START** может быть и ретрансмитом, и (теоретически) новым сеансом — если у нового START совпали `seq==0`, `svc_id`, `timestamp` (16-битные часы 0.1ms заворачиваются каждые ~6.5 сек) и payload. Тогда рестарт **не детектится**. 2. Если рестарт не распознан: у пира `tx_seq` ушёл в 0, а у нас `rx_seq` остался «высоким». В `router_handle_data_packet` (`etcp_router.c:1104`) ветка `(rx_seq - seq) > 0` / `queue_find_data_by_index` считает пакеты новой сессии **дубликатами** → молчаливый `c_dup_dropped` + пере-ACK (как `RX dup` в ETCP) → потеря сервисных пакетов. 3. `sess_id` не на wire → пир вообще не может отличить «мы перезапустились» от «продолжаем сеанс» иначе как по байтам START. То есть нужен надёжный маркер **эпохи сеанса** на wire — аналог `reset_id`. ## 3. Решение (аналог reset_id) Семантика та же, что в ETCP: - эпоха генерится **один раз** при создании `ETCP_ROUTER_CONN`; - меняется **только** при (фатальном) рестарте соединения; - при приёме START/RST с **другой** эпохой → рестарт (adopt); - **первая** увиденная эпоха принимается без рестарта (свежие при создании); - дедуп ретрансмитов START — по эпохе, а не по байтам. Два варианта источника эпохи: - **(A) Перенести `sess_id` на wire** — он уже есть как `uint32` счётчик (0 при создании, `++` при рестарте). Минимально инвазивно. - **(B) Новый `reset_id` (uint64, случайный)** — симметрично ETCP; случайный надёжнее счётчика (нет wrap-around и предсказуемости), но чуть больше поле. Рекомендация — **(A)** как минимальный шаг (поле уже есть), при желании апгрейд до (B). ### Разрыв цикла (master/slave) Как и в ETCP, симметричное «увидел другую эпоху → рестарт» зацикливается при одновременном рестарте обеих сторон (A++ → B++ → A видит B → A++ → …). Ти-брейкер по `node_id` (как в ETCP): - на приёме START/RST с чужой эпохой: - **master** (меньший `node_id`) — держит свою эпоху, шлёт RST пиру; - **slave** (больший `node_id`) — принимает эпоху пира и рестартится. Существующие предохранители (`send_restart_pending`, `router_check_peer_restart` на стороне master) сохраняются. ## 4. Wire-формат `SVC_ROUTE_HDR` (`etcp_router.h:26`) сейчас 33 байта: ``` cmd(1) group_id(8) dst(8) src(8) seq(4) svc_id(1) flags(1) timestamp(2) ``` Добавить поле эпохи (только START/RST, в обычных data/ACK — 0): - вариант (A): `uint32_t sess_id` → заголовок 33→37 байт (упакован); - вариант (B): `uint8_t reset_id[8]` → 33→41 байт. Заполняется в `router_build_packet` (START) и `router_send_rst`; читается в `router_check_peer_restart` / обработчике RST. ## 5. Шаги реализации Файлы: `src/routing_layer/etcp_router.h`, `src/routing_layer/etcp_router.c`, (опц.) `tests/`. 1. **Поле в заголовке**: добавить `sess_id`/`reset_id` в `struct SVC_ROUTE_HDR`; обновить `SVC_ROUTE_HDR_SIZE`. 2. **Локальное состояние** (`struct ETCP_ROUTER_CONN`): - `uint32_t sess_id` (уже есть) — оставить как «моя эпоха»; - добавить `uint32_t peer_sess_id` — последняя увиденная эпоха пира. - `router_conn_reset` (`etcp_router.c:380`) НЕ сбрасывает `sess_id` (комментарий на :379 уже это гарантирует). 3. **Запись эпохи**: - `router_build_packet` (`:193`) — при `ROUTER_FLAG_START` писать `sess_id`; - `router_send_rst` (`:251`) — писать `sess_id`. 4. **Детект рестарта по эпохе** — переписать `router_check_peer_restart` (`:1055`): - если `peer_sess_id==0` → первая эпоха, принять без рестарта (сохранить); - если `hdr->sess_id == peer_sess_id` → ретрансмит (не рестарт); - если отличается → master/slave: slave → `etcp_router_conn_restart` + adopt; master → держать свою, при необходимости RST. - `last_start_dgram`-байт-дедуп заменить на это сравнение (поле можно удалить). 5. **RST-обработчик** (`:1151`) — при RST также сверять `sess_id` (дедуп RST по эпохе вместо/в дополнение к `send_restart_pending`). 6. **Инициализация** — `peer_sess_id = 0` в `router_conn_reset`/`etcp_router_conn_get`. ## 6. Тест `tests/test_etcp_router_*` (существующие `test_etcp_router`, `test_etcp_router_reconnect`, `test_etcp_router_unit`) — прогнать после правки заголовка (37/41 байт). Добавить (по возможности) регрессию, аналогичную `test_etcp_seq_collision`: - два инстанса, роутер-коннект, отправка N пакетов; - принудительный рестарт одной стороны (смена эпохи); - assert: пакеты новой эпохи доставлены (не `c_dup_dropped`), обе стороны сходятся к одной эпохе. ## 7. Решения (зафиксировано) 1. **Вариант (B)** — `uint64_t reset_id`, случайный (`random_bytes`). Причина: вариант (A) `sess_id` стартует с 0 и конфликтует с сентинелом «эпоха не задана». На wire — native order (как остальные uint64-поля заголовка, SVC_ROUTE не использует htobe*). 2. **master/slave по node_id** — нужен: `etcp_router_conn_restart` делает полный двунаправленный сброс, без ти-брейкера симметричный рестарт зациклится. master (меньший node_id) держит эпоху и шлёт RST; slave принимает и ресетится. 3. **route_crypto** — совместим: заголовок не шифруется (только payload), подписывается только в SIGN-режиме; `SVC_ROUTE_HDR_SIZE = sizeof(...)` авто-адаптируется. START идёт через `route_crypto_encode`, RST/ACK/CLOSE — plain header-only. ## 8. Статус Реализовано и проверено: `etcp_router.h/.c` + `tests/test_etcp_router_unit.c`. Сборка `make clean && make` без ошибок; `./check.sh` → 74 passed / 0 failed / 1 skipped; `test_etcp_router_unit` 34/34, `test_etcp_router` и `test_etcp_router_reconnect` — PASS.