fix(tci): ревизия — потоки, валидация, арбитраж и синхронизация клиентов

Разбор семи проходов ревью ветки. Ниже — по сути, а не по списку.

Потоки. Сетевые потоки больше не читают модель контроллера напрямую. Слайсы
снимаются в потоке контроллера (RefreshSlices → FSliceSnap, на событиях
rfSliceFreq/rfSliceState/rfDevice/…), железо — тоже (RefreshDev → TTCIDevSnap:
имя платы, границы, число панов, HasTX). Копия TCtrlSlice из чужого потока
портила счётчик ссылок managed-строк, а BackendCaps и BoardDisplayName смотрят
в FNetwork, который UI освобождает на смене устройства. По той же причине
ActiveTXFreqHz переведён на GetSliceView. Sync-методы читают живую таблицу: они
уже в потоке контроллера.

Жизненный цикл. Stop ждёт выхода клиентских потоков БЕЗ таймаута, прокачивая
очередь Synchronize: выйти по таймауту нельзя — следом освобождаются и клиенты,
и сам сервер. OnDisconnect зовётся и при остановке (иначе захваты параметров
ушедших клиентов доживали до следующего запуска). Отправка переехала на поток
самого клиента (recv с TCI_POLL_MS): общий поток задерживал всех на таймаут
записи в один медленный сокет. WebUtils.SockSend шлёт с MSG_NOSIGNAL — SIGPIPE
убивал headless-процесс.

Транспорт. Слот протокола выдаётся только после Upgrade, а сокет до него живёт
по таймауту handshake: восемь молчащих соединений закрывали дверь настоящим
клиентам. Handshake с заголовком Origin получает 403 — авторизации в TCI нет, и
без этого открытая вкладка браузера дотягивалась до TRX и VFO. Заголовки
разбираются построчно, текстовые кадры проверяются на UTF-8, close длиной один
байт отвергается, на close отвечаем close.

Валидация. Все установки ходят через TCITryArg* — «vfo^0~0~abc» больше не
превращается в честный ноль. Частота проверяется дважды: в потоке клиента по
снимку и в SyncSetVfo/SyncSetCenter по живым границам (устройство успевают
сменить между разбором и исполнением). Границы теперь из ОДНОГО источника
(FreqLimits поверх VisibleFreqBounds) — тот же, что уходит в VFO_LIMITS; сами
VFO_LIMITS переобъявляются при смене железа, и их кэш ведётся независимо от
того, подключён ли кто-то. Слайс двигается только TuneSliceInBand, как у CAT:
прямой SetSliceTarget уводил TX-слайс в DUC на чужой диапазон без антенн и
фильтров. Параметры потоков сверяются со списками спецификации, а IQ_START и
прочие запуски честно отвечают ошибкой вместо молчания.

Синхронизация клиентов (§3.5). Появился захват параметра на 200 мс: два логгера
больше не перетягивают частоту. Пачка инициализации уходит под FClientLock —
изменение между строкой снимка и READY терялось навсегда. Глобальные величины
(tune_drive, cw_macros_*, split_enable, mon_volume) рассылаются всем, а правки
оператора приходят событиями: rfTXProfile, rfActiveVfo, rfMonVolume и новый
rfCWSettings. Создание и удаление слайса рассылается по rfDevice (сравнение
расстановки), у живого пана без слайсов канал A показывает центр — иначе клиент
навсегда оставался с частотой удалённого слайса.

Прочее. SliceFreqChanged переехал внутрь SetSliceTarget — один путь для мыши,
CAT и TCI (перетаскивание флага мимо клиентов проходило молча). VOLUME и
MON_VOLUME развели: SetVolume правит АКТИВНУЮ громкость, поэтому команда на
DUP-передаче уезжала в монитор — добавлен адресный SetRxVolume. Настройки
сохраняются только после успешного применения, при отказе поднимается прежний
слушатель. Время спота — UTC. Подписки на измерители читаются и пишутся под
локом клиента.

Проверено стендом (сырой WS-клиент + живой TRadioController без железа):
73 проверки, включая изоляцию медленного клиента, остановку под Synchronize,
арбитраж до и после 200 мс, отбраковку по живым границам и переобъявление
VFO_LIMITS. На реальном железе и с реальным клиентом по-прежнему не гонялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:53:32 +03:00
co-authored by Claude Opus 5
parent 84c9e60b93
commit 4165cbe9a5
7 changed files with 1738 additions and 418 deletions
+36 -25
View File
@@ -5779,9 +5779,10 @@ begin
FController.CapturedSpan(LoHz, HiHz);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
// Перезалив флага и раскладку несёт rfSliceFreq из SetSliceTarget —
// одним путём для мыши, CAT и TCI (иначе внешние клиенты о перетаскивании
// не узнавали).
FController.SetSliceTarget(FSliceDragId, NewHz);
FPan.PushSliceFlagState(FSliceDragId); // обновить частоту во флаге
FPan.LayoutFlags;
FSpectrumDirty := True; // кадр — по таймеру (FPS-гейт, как при drag VFO)
end;
Exit;
@@ -6583,18 +6584,37 @@ end;
procedure TMainForm.ApplyTCISettings(Enabled: Boolean; Port: Integer;
const BindAddr: string);
// Сначала применяем, и только потом сохраняем. Обратный порядок означал бы:
// занятый порт или опечатка в адресе — и в файле навсегда осталась нерабочая
// конфигурация, с которой программа стартует и в следующий раз.
var Prev, Cfg: TTCISettings;
begin
FTCICfg.Enabled := Enabled;
FTCICfg.Port := Port;
FTCICfg.BindAddr := BindAddr;
FController.FSettings.SaveTCISettings(FTCICfg);
if FTCIAdapter = nil then Exit;
// Отказ (порт занят, адрес не разобран) показываем сразу: иначе оператор
// останется с галкой «включено» и мёртвым сервером.
if not FTCIAdapter.ApplySettings(FTCICfg) then
ShowMessage('TCI server failed to start on ' + BindAddr + ':' +
IntToStr(Port) + '.' + LineEnding +
'Port busy or address invalid.');
Prev := FTCICfg;
Cfg.Enabled := Enabled;
Cfg.Port := Port;
Cfg.BindAddr := BindAddr;
if FTCIAdapter = nil then
begin
FTCICfg := Cfg;
FController.FSettings.SaveTCISettings(FTCICfg);
Exit;
end;
if FTCIAdapter.ApplySettings(Cfg) then
begin
FTCICfg := Cfg;
FController.FSettings.SaveTCISettings(FTCICfg);
Exit;
end;
// Отказ: адаптер откатился на прежний слушатель — вернём туда же и настройки
// с их отражением в окне, иначе оператор остаётся с галкой «включено» при
// мёртвом сервере.
FTCICfg := Prev;
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
TSettingsForm(FSettingsForm).LoadTCISettings(FTCICfg.Enabled, FTCICfg.Port,
FTCICfg.BindAddr);
ShowMessage('TCI server failed to start on ' + BindAddr + ':' +
IntToStr(Port) + '.' + LineEnding +
'Port busy or address invalid — settings not saved.');
end;
// SET_IN_FOCUS от TCI-клиента: логгер просит поднять окно программы.
@@ -7429,11 +7449,8 @@ begin
AddSliceAtFreqPan(P, Hz);
Exit;
end;
FController.SetSliceTarget(Id, Hz);
FController.SetSliceTarget(Id, Hz); // флаг и раскладку несёт rfSliceFreq
FActiveSliceId := Id;
P.PushSliceFlagState(Id);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.ApplyPanHeaderTheme(P: TPanafallPanel);
@@ -8062,10 +8079,7 @@ begin
FController.CapturedSpan(LoHz, HiHz, P.PanId);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
FController.SetSliceTarget(FPanSliceDragId, NewHz);
P.PushSliceFlagState(FPanSliceDragId);
P.LayoutFlags;
MarkPanDirty(P);
FController.SetSliceTarget(FPanSliceDragId, NewHz); // флаг — rfSliceFreq
end;
Exit;
end;
@@ -9270,10 +9284,7 @@ begin
FController.CapturedSpan(SLo, SHi, Sl.PanId); // полоса ЕГО пана
if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew);
SlicePan.PushSliceFlagState(SliceId);
SlicePan.LayoutFlags;
MarkPanDirty(SlicePan);
FController.SetSliceTarget(SliceId, SNew); // флаг — rfSliceFreq
FActiveSliceId := SliceId;
Handled := True;
Exit;