fix(tci): жизненный цикл, синхронизация слайсов и WebSocket по RFC

Разбор ревью ветки. Критичное — четыре отказа жизненного цикла и один
пробел синхронизации.

Use-after-free стора спотов: FTCIAdapter освобождается ДО FDXStore.
Команда SPOT/SPOT_DELETE, пришедшая между их гибелью, обращалась к
освобождённой памяти.

Bind-адрес: TCIParseIPv4 стал строгим (out + Boolean, ровно четыре
октета 0..255). Кривой адрес — отказ поднимать сокет, а не молчаливый
INADDR_ANY: авторизации в TCI нет. В UI порт и адрес применяются по
уходу фокуса и по Close, а не на каждую букву — набор «127.0.0.1» по
дороге проходил через «127.0.0.» и открывал порт наружу.

Остановка при висящем Synchronize: флаг Stopping (адаптер не начинает
новых Invoke), прокачка CheckSynchronize в цикле ожидания Stop и запрет
освобождать клиента, чей поток не вышел. Владение переделано: клиента
освобождает только тик-поток (ReapClients), клиентский лишь помечает
себя закрытым.

Отправка больше не блокирует вызывающего: Send/Broadcast кладут строку
в очередь клиента, в сокет пишет тик-поток вне общего лока, склеивая
очередь в общие кадры. Медленный клиент морозил UI на таймаут отправки
за каждое движение ручки VFO; теперь он просто вылетает.

Слайсы: в контроллере появилось rfSliceState (нагрузка — FSliceFreqId),
его шлют сами сеттеры слайса; SyncSetVfo зовёт SliceFreqChanged, как
CAT. Адаптер разворачивает Id в пару (приёмник, канал) и рассылает
состояние именно этого канала, а не канала 0 каждого пана.

WebSocket по RFC 6455: маска обязательна, FIN/continuation собираются,
RSV и незнакомые opcode рвут соединение, control-кадры ≤125 и только
целиком, 64-битная длина не сворачивается в отрицательный Integer,
пустой Sec-WebSocket-Key получает 400. Хвост пакета handshake больше не
выбрасывается — первая команда не теряется. Клиент после исключения в
разборе не остаётся висеть в массиве.

Ещё: DSP и squelch доп. приёмников читаются и пишутся из TCtrlSlice
(парные сеттеры сохраняли соседние поля значениями главного тракта);
параметры потоков — в TTCIClient, они клиентские по спецификации; эхо
под своим локом; ApplySettings возвращает результат, отказ старта виден
оператору; инициализация объявляет только существующие каналы;
SET_IN_FOCUS реализован через OnFocusRequest.

Осознанно не сделано и записано в doc/TCI.md §3.1: AGC_GAIN для
приёмников >0 (AGC-T один на тракт), цвет спота, KEYER, TX_FOOTSWITCH,
арбитраж нескольких клиентов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 16:49:31 +03:00
co-authored by Claude Opus 5
parent 82f0e4f771
commit 84c9e60b93
6 changed files with 960 additions and 276 deletions
+20 -1
View File
@@ -88,7 +88,8 @@ type
rfDevice, // подключённое устройство сменилось
rfDeviceList, // список discovered устройств обновился
rfPanFreq, // центр доп. пана уехал (ретюн DDC извне)
rfSliceFreq // слайс перестроен извне (CAT); Id — FSliceFreqId
rfSliceFreq, // слайс перестроен извне (CAT); Id — FSliceFreqId
rfSliceState // у слайса сменились мода/фильтр/АРУ/DSP/громкость; Id — FSliceFreqId
);
TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object;
@@ -907,6 +908,7 @@ type
// Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры +
// кромки фильтра), позиция флага и так живая.
procedure SliceFreqChanged(Id: Integer);
procedure SliceStateChanged(Id: Integer);
// ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
// Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ
@@ -2018,6 +2020,16 @@ begin
Changed(rfSliceFreq);
end;
procedure TRadioController.SliceStateChanged(Id: Integer);
// У слайса сменилось что-то кроме частоты: режим, фильтр, АРУ, DSP, громкость,
// мьют, шумоподавитель. Зовут сами сеттеры — слайс правят и UI, и CAT, и TCI, а
// узнать об этом должны все фронтенды сразу (у главного тракта для этого есть
// свои поля rfMode/rfFilter/…, у слайсов не было ничего).
begin
FSliceFreqId := Id;
Changed(rfSliceState);
end;
function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого
// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить
@@ -2548,6 +2560,7 @@ begin
end;
end;
if FTxSliceId = Id then SyncCWKeyer;
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
@@ -2558,6 +2571,7 @@ begin
FSlices[idx].FilterLo := Low;
FSlices[idx].FilterHi := High;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
@@ -2567,6 +2581,7 @@ begin
if idx < 0 then Exit;
FSlices[idx].AGC := AGC;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
@@ -2576,6 +2591,7 @@ begin
if idx < 0 then Exit;
FSlices[idx].Volume := Vol;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
@@ -2585,6 +2601,7 @@ begin
if idx < 0 then Exit;
FSlices[idx].Muted := Mute;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceRxMuteOnTx(Id: Integer; On_: Boolean);
@@ -2639,6 +2656,7 @@ begin
FDSPEngine.SetSliceSNB(Id, SNBOn);
FDSPEngine.SetSliceANF(Id, ANFOn);
end;
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
@@ -2650,6 +2668,7 @@ begin
FSlices[idx].FMSQLevel := Level;
if Assigned(FDSPEngine) then
FDSPEngine.SetSliceFMSquelch(Id, On_, Level);
SliceStateChanged(Id);
end;
function TRadioController.SliceSMeter(Id: Integer): Double;