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
+28 -3
View File
@@ -475,6 +475,11 @@ type
FChkTCIEnabled: TFlatCheckBox;
FEdTCIPort: TFlatSpinEdit;
FEdTCIBind: TFlatEdit;
// Последнее применённое: обработчик висит на уходе фокуса и на Close,
// а перезапускать сервер без правки нельзя (порвёт клиентов).
FTCILastEnabled: Boolean;
FTCILastPort: Integer;
FTCILastBind: string;
// ---- Close button ----
FBtnClose: TFlatButton;
@@ -3478,7 +3483,9 @@ begin
Spin.MinValue := 1;
Spin.MaxValue := 65535;
Spin.Value := 40001;
Spin.OnChange := OnTCIAnyChange;
// Применяем по уходу фокуса, а не на каждое нажатие: набирая «40001», через
// OnChange мы бы подряд перезапустили сервер на портах 4, 40, 400, 4000.
Spin.OnExit := OnTCIAnyChange;
FEdTCIPort := Spin;
Y := Y + ROW_H;
@@ -3490,7 +3497,9 @@ begin
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.Text := '127.0.0.1';
Ed.OnChange := OnTCIAnyChange;
// Тем более адрес: промежуточное «127.0.0.» — не адрес, и раньше это молча
// означало «слушать на всех интерфейсах». Применяем по уходу фокуса.
Ed.OnExit := OnTCIAnyChange;
FEdTCIBind := Ed;
// Авторизации в протоколе нет: открытый наружу порт = полный доступ к трансиверу.
@@ -3749,7 +3758,16 @@ procedure TSettingsForm.OnTCIAnyChange(Sender: TObject);
begin
if FLoading then Exit;
if not Assigned(FOnTCISettingsChange) then Exit;
FOnTCISettingsChange(FChkTCIEnabled.Checked, FEdTCIPort.Value, FEdTCIBind.Text);
// Применение перезапускает сервер и рвёт соединения клиентов, поэтому зовём
// только на РЕАЛЬНОЕ изменение: обработчик висит и на уходе фокуса, и на
// кнопке Close, а они срабатывают и без правок.
if (FChkTCIEnabled.Checked = FTCILastEnabled) and
(FEdTCIPort.Value = FTCILastPort) and
(FEdTCIBind.Text = FTCILastBind) then Exit;
FTCILastEnabled := FChkTCIEnabled.Checked;
FTCILastPort := FEdTCIPort.Value;
FTCILastBind := FEdTCIBind.Text;
FOnTCISettingsChange(FTCILastEnabled, FTCILastPort, FTCILastBind);
end;
procedure TSettingsForm.LoadTCISettings(Enabled: Boolean; Port: Integer;
@@ -3760,6 +3778,9 @@ begin
FChkTCIEnabled.Checked := Enabled;
FEdTCIPort.Value := EnsureRange(Port, 1, 65535);
FEdTCIBind.Text := BindAddr;
FTCILastEnabled := FChkTCIEnabled.Checked;
FTCILastPort := FEdTCIPort.Value;
FTCILastBind := FEdTCIBind.Text;
finally
FLoading := False;
end;
@@ -4594,6 +4615,10 @@ end;
procedure TSettingsForm.BtnCloseClick(Sender: TObject);
begin
// Порт и адрес TCI применяются по уходу фокуса (см. BuildAdvancedTab), а из
// поля, в котором стоит курсор, при закрытии окна фокус может и не уйти —
// дожимаем правку здесь, иначе она молча пропадёт.
OnTCIAnyChange(nil);
Close;
end;