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
+29 -3
View File
@@ -844,6 +844,7 @@ type
// отдавать клавиатуру).
procedure TCIFormActivate(Sender: TObject);
procedure TCIFormDeactivate(Sender: TObject);
procedure TCIFocusRequest;
procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
@@ -1428,6 +1429,11 @@ begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
end;
// TCI гасим ПЕРЕД базой спотов: его клиенты кладут споты (SPOT/SPOT_DELETE/
// SPOT_CLEAR) прямо в FDXStore из своих потоков, и команда, пришедшая между
// гибелью стора и гибелью адаптера, обратилась бы к освобождённой памяти.
// Destroy останавливает сервер и дожидается его потоков.
FreeAndNil(FTCIAdapter);
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].DetachDXSpots;
FDXSpotOverlay := nil;
@@ -1456,7 +1462,7 @@ begin
FreeAndNil(FWebAdapter); // адаптер не владеет сервером — освобождаем после Stop
FWebServer.Free;
FreeAndNil(FCATAdapter); // его Destroy останавливает+освобождает CAT движок/транспорты
FreeAndNil(FTCIAdapter); // Destroy останавливает TCI-сервер и его потоки
// (TCI освобождён выше — до базы спотов)
// Ядро освобождаем последним — его Destroy закрывает и освобождает движки
// (FreeEngines) и FSettings.
FreeAndNil(FController);
@@ -2719,7 +2725,13 @@ begin
// InitDXCluster: споты от TCI-клиентов кладутся в тот же стор.
FController.FSettings.LoadTCISettings(FTCICfg);
FTCIAdapter := TTCIAdapter.Create(FController, FDXStore);
FTCIAdapter.ApplySettings(FTCICfg);
FTCIAdapter.OnFocusRequest := TCIFocusRequest;
// Порт может быть занят (второй экземпляр, чужая программа) — молчать об
// этом нельзя: галка стоит, а сервера нет.
if not FTCIAdapter.ApplySettings(FTCICfg) then
ShowMessage('TCI server failed to start on ' + FTCICfg.BindAddr + ':' +
IntToStr(FTCICfg.Port) + '.' + LineEnding +
'Port busy or address invalid — check Settings → Advanced.');
OnActivate := TCIFormActivate;
OnDeactivate := TCIFormDeactivate;
@@ -6576,7 +6588,21 @@ begin
FTCICfg.Port := Port;
FTCICfg.BindAddr := BindAddr;
FController.FSettings.SaveTCISettings(FTCICfg);
if FTCIAdapter <> nil then FTCIAdapter.ApplySettings(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.');
end;
// SET_IN_FOCUS от TCI-клиента: логгер просит поднять окно программы.
// Вызывается в UI-потоке (адаптер маршалит через FController.Invoke).
procedure TMainForm.TCIFocusRequest;
begin
if WindowState = wsMinimized then WindowState := wsNormal;
BringToFront;
end;
procedure TMainForm.TCIFormActivate(Sender: TObject);