# §208 — Round-robin балансировщик в auto-группе + просмотр пула (ядро SPEC 019) > **СТАТУС: РЕАЛИЗОВАНО (28.06.2026).** Ветка `feat/urltest-balancer-208`. > Ядро: **`v1.14.0-lx.1-rc.15`** (sticky_hash-контракт `["none"]` + фиксы > балансировщика — см. [§210](210-libbox-rc15-sticky-none.md)). Исходно > реализовано на rc.14, бамп до rc.15 — отдельной таской §210. > §207 занят другой сессией (goroutine-cpu-dump) — взят 208. > 1402 теста зелёные; Dart analyze чист; Kotlin compile OK. > > **DEVICE-проверка 28.06 (Debug API):** конфиг `vpn-1-auto` эмитит > `mode:round_robin` + `balancer{}` корректно; трафик раскидан по слотам пула. > НО виден **перекос — весь трафик в один узел** (89/3/2/1). Это оказался **БАГ > ЯДРА, не §208:** sticky `domain` был всегда пустым (роутер перезаписывал > `metadata.Destination` ДО балансировщика → `destination.Fqdn`="" и для TCP, и > для UDP) → все ключи схлопывались в один слот. **Фикшен в ядре rc.15** > (читает `metadata.Domain`); наш бамп + контракт-фикс — [§210](210-libbox-rc15-sticky-none.md). > Перепроверка размазывания — на rc.15 (НЕ device-verified). ## Контекст Ядро (SPEC 019) расширило `urltest`-группу режимом балансировки нагрузки. Раньше urltest = **least_test**: один «лучший» узел по delay, все соединения идут через него (через `tolerance`-гистерезис). Новый режим **round_robin** раскидывает соединения по **пулу** из N узлов: фиксированные слоты, ленивый health-check, sticky-привязка сессий по ключу (process/domain/…). Для юзера с 1000 нод это нагрузочная балансировка; для обычного — «несколько серверов параллельно, сессии липнут к своему». У нас auto-двойник канала (`-auto`, §125) — это и есть urltest-группа. Сейчас редактор канала ([channel_edit_screen.dart](../../app/lib/screens/channel_edit_screen.dart)) правит только апстрим-поля (`url`/`interval`/`tolerance`/`idle_timeout`/ `interrupt_exist_connections`). Нужно (1) добавить выбор **режима** и параметры балансировщика, прокинуть в config через билдер, и (2) дать **просмотр текущего состава пула** по long-press на auto-ноде (через новый RPC `GetPool`). ## Что даёт ядро (SPEC 019) Поля urltest-группы (в дополнение к существующим): | поле | дефолт | смысл | |---|---|---| | `mode` | `least_test` | `least_test` (апстрим, как сейчас) \| `round_robin` | | `balancer` | — | объект; **только** при `round_robin`; при `mode != round_robin` с ним → **ошибка старта ядра** | | `balancer.pool` | 3 | размер пула; `0`/опущено → 3; `< 0` → ошибка; факт = `min(pool, len(nodes))` | | `balancer.pool_tolerance` | 0 | мс. `0` — держать `pool` живых (скорость неважна); `> 0` — отбирать лучших по delay | | `balancer.sticky_hash` | `["process","domain"]` (если поле **опущено**) | компоненты ключа липкости; `[]` (явный пустой) → липкость ВЫКЛ (чистый round_robin) | `sticky_hash` компоненты: `process`, `domain`, `source_ip`, `dest_ip`, `dest_port`. **RPC `GetPool` (libbox, подтверждено javap rc.14 и rc.15):** ``` CommandClient.getPool(group_tag) → PoolSlotIterator PoolSlot { int getSlot(); String getTag(); int getDelay(); } // delay мс, 0 = мёртвая/не измерена ``` - unary snapshot (не стрим). Не-round_robin группа → **пустой список** (не ошибка). `delay` живой ноды всегда ≥1 (ядро клампит 0→1), `0`=мёртвая. Доп. факты ядра (для подсказок UI, не для логики): - `tolerance` (апстрим, корень urltest) при `round_robin` **игнорируется** ядром → ядро шлёт варн. В round_robin за гистерезис отвечает `balancer.pool_tolerance`. **Решение юзера: в Load balance поле Tolerance гасим** (disabled). - **`interval` дефолт ядра 3m**; для round_robin SPEC рекомендует **15m** (на больших пулах частый дотест избыточен). Нас не ломает (эмитим явно), но UI-hint в Load balance подскажет «15m recommended for large pools». - **`interval ≤ idle_timeout`** — апстрим-требование (`urltest.go:221`), иначе ошибка старта ядра (оба режима). Сейчас редактор не проверяет. Добавим дешёвую advisory-проверку (см. «Валидация редактора»). - `Now()` round_robin = `lastSelected` (прыгает с ротацией, by design). Следствие: пинг auto-ноды (`URLTestOutbound`) в round_robin тоже «шумит» — цифра скачет. Не ломается. Просмотр пула (GetPool) даёт честную картину. ## Скоуп (согласовано с юзером 28.06.2026) 1. **Модель** `ChannelAuto` — поля `mode`, `pool`, `poolTolerance`, `stickyHash` (+ enum, JSON, copyWith, дефолты, кламп). 2. **Билдер** `build_config.dart` — при `mode == round_robin` дописывать `mode` + `balancer{}` в urltest-объект. 3. **Редактор** `channel_edit_screen.dart` — SegmentedButton «Mode» (Fastest / Load balance) + (под Load balance) Pool size / Pool tolerance + чипы sticky_hash; гашение Tolerance; hint 15m; advisory interval≤idle. 4. **GetPool RPC** (полный путь, согласовано — правая кнопка → попап): - Kotlin `BoxCommandClient.getPool(tag)` + `serializePoolSlot` (1:1 с `getGroups`-паттерном). - VpnPlugin handler `ccGetPool` (unary, `Dispatchers.IO`, как `ccUrlTestOutbound`). - Dart `CcChannel.getPool(tag)` + модель `CcPoolSlot`. - UI: пункт **«View pool»** в контекстном меню node_row (long-press, только для round_robin auto-ноды) → попап-диалог со слотами. 5. **Тесты** — модель (JSON, дефолты, кламп, enum-wire), билдер (эмиссия balancer, leastTest=без полей), `CcPoolSlot.fromMap`. НЕ трогаем: главный экран «Auto → сервер» (Now() прыгает — оставляем как есть, пул смотрят через попап), Conns/Profiler, реакцию ping-цифры на ротацию. ## UI-дизайн A — редактор канала (блок «Include auto») Между Idle timeout и Interrupt-галкой вставляем секцию режима. Логика: сначала «как тестируем» (url/interval/idle), потом «как выбираем» (mode + balancer), потом interrupt. ``` ┌─ Include auto (urltest) ────────────────────[✓]─┐ │ latency-tested twin of this channel │ │ Test URL [ https://cp.cloudflare… ] │ │ Interval [ 5m ] Tolerance (ms) [ 50 ] │ ← Tolerance ГАСИМ в Load balance │ Idle timeout [ 30m ] │ │ Mode ┌─ Fastest ─┬─ Load balance ─┐ │ ← SegmentedButton (2 сегмента) │ └───────────┴────────────────┘ │ │ ── если Load balance: ────────────────────── │ │ ⓘ 15m interval recommended for large pools │ │ Pool size [ 3 ] Pool tolerance (ms) [ 0 ] │ │ Sticky session by: │ │ [process ✓][domain ✓][source ip][dest ip] │ ← FilterChip multi-select │ [dest port] │ │ ⓘ none selected → no stickiness (pure rotation)│ │ [✓] Interrupt connections on switch │ └──────────────────────────────────────────────────┘ ``` **Решения по UI:** - **`mode` → SegmentedButton «Mode»**: `Fastest` (least_test, «single best server») / `Load balance` (round_robin, «spread across a pool»). Человеческие лейблы, не ядровый жаргон (нейминг «Load balance» согласован). - **Tolerance ГАСИМ** при Load balance (`enabled: false`, hint «used in Fastest mode»). За гистерезис в пуле отвечает Pool tolerance. - **Pool size** — number, дефолт 3. Клиентский кламп при сохранении: `< 1 → 1`. Опц. hint «of N nodes» из `allNodeTags.length`. Реальный `min(pool,len)` — ядро. - **Pool tolerance (ms)** — number, дефолт 0. Hint «0 = keep pool full». - **`sticky_hash` → ряд из 5 FilterChip** (multi-select). Дефолт для нового round_robin = `{process, domain}`. 0 чипов → липкость выкл. Подсказка под чипами. - **Sentinel `["none"]` (ядро rc.15, §210):** модель — `List` (пустой = выкл). Билдер маппит пустой набор в `sticky_hash:["none"]`, НЕ `[]` — ядро ре-маршалит конфиг и схлопывает `[]`→nil («опущено»→дефолтит липкость). Sentinel живёт ТОЛЬКО в билдере; UI/storage про него не знают. **Видимость:** секция Mode — только при `_autoEnabled`. balancer-поля — только при `Load balance`. Tolerance существует всегда, но disabled в Load balance. ### Валидация редактора (interval ≤ idle_timeout) Дешёвая advisory-проверка (не hard-gate): парсим обе duration-строки (`5m`/`30m`/`1h`/`90s`) в секунды простым парсером; при `interval > idle` показываем `errorText` под Interval «must be ≤ idle timeout». Сохранение не блокируем жёстко (парсер неполон / юзер знает лучше). Если в проекте есть duration-парсер — переиспользуем, иначе минимальный helper (s/m/h). ## UI-дизайн B — просмотр пула (long-press auto-ноды → попап) В контекстном меню node_row ([node_row.dart:206](../../app/lib/widgets/node_row.dart#L206), рядом с §203 «Select server») добавляем пункт **«View pool»** (иконка `Icons.hub_outlined` / `Icons.lan_outlined`), гейт `onViewPool != null` — прокидывается только для auto-ноды в **round_robin**-канале. При тапе → `cc.getPool(autoTag)` → попап-диалог (`showDialog`, `AlertDialog`/простой sheet) со списком слотов: ``` ┌─ Pool · VPN ① auto ───────────────┐ │ slot 0 🇩🇪 node-de-1 12 ms │ ← delay цветной (зелёный/жёлтый/красный) │ slot 1 🇳🇱 node-nl-3 34 ms │ │ slot 2 🇫🇮 node-fi-2 — ms │ ← delay 0 → «—» (мёртвая/не измерена) │ [ Close ] │ └────────────────────────────────────┘ ``` - Слоты в порядке `slot` (фиксированный). `delay==0` → «—» (мёртвая). - Пустой список (туннель down / не round_robin / пул не готов) → «Pool not available». Не ошибка. - Снапшот (unary). Можно добавить pull-to-refresh / кнопку Refresh — опц. (пул меняется раз в interval, статичный снимок достаточен). - Кто round_robin: канал из storage по autoTag → `channel.auto.mode == roundRobin`. Прокидывается в node_list как `onViewPool`. ## Модель `ChannelAuto` ```dart enum UrltestMode { leastTest, roundRobin } // wire: 'least_test' | 'round_robin' enum StickyHashKey { process, domain, sourceIp, destIp, destPort } // wire: 'process','domain','source_ip','dest_ip','dest_port' ``` Новые поля (с дефолтами — обратная совместимость со старым JSON): | поле | тип | дефолт | wire | |---|---|---|---| | `mode` | `UrltestMode` | `leastTest` | `mode` | | `pool` | `int` | `3` | `balancer.pool` | | `poolTolerance` | `int` | `0` | `balancer.pool_tolerance` | | `stickyHash` | `List` | `[process, domain]` | `balancer.sticky_hash` | Старый канал без новых ключей → `leastTest` + дефолты. `toJson` пишет новые поля всегда (storage +4 ключа, чтение не ломается). Кламп: `pool→max(1,v)`, `poolTolerance→_clampTolerance` (uint16, reuse §161). ## Билдер `build_config.dart` (urltest-двойник, стр. 519-531) ```dart final m = { 'tag': c.autoTag, 'type': 'urltest', 'outbounds': nodes, 'url': a.url, 'interval': a.interval, 'tolerance': a.tolerance, 'idle_timeout': a.idleTimeout, 'interrupt_exist_connections': a.interruptExistConnections, }; if (a.mode == UrltestMode.roundRobin) { m['mode'] = 'round_robin'; // §210: пустой набор → sentinel ["none"] (выкл), НЕ [] — ядро схлопывает []→nil. final sticky = a.stickyHash.isEmpty ? const ['none'] : a.stickyHash.map((k) => k.wire).toList(); m['balancer'] = { 'pool': a.pool, 'pool_tolerance': a.poolTolerance, 'sticky_hash': sticky, }; } result.add(m); ``` **leastTest** → `mode`/`balancer` НЕ пишем (бит-в-бит как сейчас → нулевой diff для существующих конфигов, не триггерит balancer+wrong-mode ошибку). `tolerance` оставляем всегда (ядро игнорит в round_robin, в least_test работает). ## Native (Kotlin) — GetPool (1:1 с getGroups/urlTestOutbound) **`BoxCommandClient.kt`:** ```kotlin fun getPool(tag: String): List> { val client = anyClient() ?: return emptyList() return runCatching { val out = ArrayList>() val it = client.getPool(tag) while (it.hasNext()) { val s = it.next() out.add(mapOf("slot" to s.slot, "tag" to s.tag, "delay" to s.delay)) } out }.getOrElse { Log.d(TAG, "getPool unavailable: ${it.message}"); emptyList() } } ``` (использует `anyClient()` — снапшот, как getGroups; НЕ pingClient.) **`VpnPlugin.kt`** handler `ccGetPool` (рядом с `ccGetGroups`), на `Dispatchers.IO` (как `ccUrlTestOutbound` — RPC может блокировать): ```kotlin "ccGetPool" -> { val tag = call.argument("tag") ?: "" scope.launch { val r = withContext(Dispatchers.IO) { cc.getPool(tag) } result.success(r) } } ``` ## Dart — `CcChannel.getPool` + `CcPoolSlot` ```dart class CcPoolSlot { const CcPoolSlot({required this.slot, required this.tag, required this.delay}); final int slot; final String tag; final int delay; // мс, 0 = мёртвая/не измерена factory CcPoolSlot.fromMap(Map m) => CcPoolSlot( slot: (m['slot'] as num?)?.toInt() ?? 0, tag: m['tag'] as String? ?? '', delay: (m['delay'] as num?)?.toInt() ?? 0, ); } Future> getPool(String tag) async { final r = await _methods.invokeMethod>('ccGetPool', {'tag': tag}); return (r ?? const []).map((m) => CcPoolSlot.fromMap(_asMap(m))).toList(); } ``` ## Тесты - **`channel_test.dart`**: `ChannelAuto` JSON round-trip с новыми полями; старый JSON без полей → дефолты; кламп pool `0→1`, poolTolerance отриц→0; enum wire-мэппинг обе стороны (2 mode + 5 sticky). - **builder-тест**: канал `auto.mode==roundRobin` → `mode:'round_robin'` + `balancer{pool,pool_tolerance,sticky_hash}`; пустой `stickyHash` → `sticky_hash:["none"]` (§210 sentinel, не `[]`); `leastTest` → НЕТ `mode`/`balancer` (бит-в-бит старый объект). - **`CcPoolSlot.fromMap`** — микротест (slot/tag/delay, дефолты, delay 0). - Widget-тест попапа — опц. ## §208a — бамп пина ядра rc.12 → rc.14 > Историческая запись. **Текущий пин — rc.15** (дальнейший бамп rc.14→rc.15 + > sticky-контракт `["none"]` вынесен в [§210](210-libbox-rc15-sticky-none.md)). Предусловие (на момент §208 пин был `v1.14.0-lx.1-rc.14`, AAR, SHA256 OK): - **rc.13** SPEC 019 (пул/sticky/GetPool RPC); **rc.14** фикс валидации `balancer.pool: 0`, «no behaviour change». - **javap rc.14:** базовый CommandClient API не менялся (`getGroups`/`urlTestOutbound`/`OutboundGroup.getSelected/getTag/getType`); **добавлены** `getPool(String)→PoolSlotIterator`, `PoolSlot{getSlot/getTag/ getDelay}` — используем (UI-дизайн B). ## Не в скоупе (следующие таски) - Главный экран «Auto → X» в round_robin прыгает (`Now()=lastSelected`). Возможная индикация «balanced / N серверов» вместо одного — отдельно. - Стрим пула (живой) вместо unary snapshot — если понадобится «живой» пул. - `weighted_round_robin` / веса — ядро отложило. ## Связанные - ядро SPEC 019 (`sing-box-lx/SPECS/019-URLTEST_MODE_STICKY/SPEC.md`). - [§125 configurable-channels](../features/125%20configurable-channels/spec.md) — auto-двойник = urltest-группа. - [§203 select-server](203-select-server-on-auto.md) — контекстное меню auto-ноды (рядом «View pool»). - [§205 rc.12](205-libbox-rc12-cold-urltest.md) — предыдущий бамп ядра; этот = rc.12→rc.14. - §161 — uint16-кламп tolerance (reuse для pool_tolerance).