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
+424 -92
View File
@@ -16,14 +16,22 @@ unit TCIServer;
измерителей с индивидуальным для каждого клиента периодом.
Отправка НИКОГДА не блокирует того, кто зовёт Send/Broadcast: строка кладётся
в очередь клиента, а в сокет её пишет тик-поток (FlushClients). Иначе
медленный клиент останавливал бы UI-поток на секунду за раз — уведомления
рождаются в OnState, то есть внутри Changed() контроллера.
в очередь клиента, а в сокет её пишет ЕГО СОБСТВЕННЫЙ поток (Flush в цикле
HandleClient, recv просыпается каждые TCI_POLL_MS). Иначе медленный клиент
останавливал бы UI-поток на секунду за раз — уведомления рождаются в OnState,
то есть внутри Changed() контроллера, — а общий поток отправки задерживал бы
на его таймаут ещё и всех остальных клиентов.
Владение объектом клиента: создаёт accept-поток, освобождает ТОЛЬКО тик-поток
(ReapClients) и только после того, как клиентский поток честно вышел. Никто
больше клиентов не освобождает — поэтому указатель, взятый под FClientLock,
остаётся валидным, пока тик-поток не сделает следующий проход.
остаётся валидным, пока тик-поток не сделает следующий проход. На остановке
освобождает Stop, но лишь дождавшись выхода ВСЕХ клиентских потоков.
Два счёта соединений. Слот из TCI_MAX_CLIENTS занимает только клиент,
прошедший handshake (Up); сокет до handshake живёт в общем массиве
(TCI_MAX_SOCKETS) и убивается по таймауту TCI_HANDSHAKE_MS. Иначе восемь
молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
Бинарные фреймы (потоки IQ/аудио, §3.4) пока не обрабатываются: этап 2,
см. doc/TCI.md. Приходящие от клиента binary-фреймы молча отбрасываются.
@@ -34,6 +42,8 @@ unit TCIServer;
Авторизации у TCI нет by design. Порт слушается там, где сказано в
настройках; умолчание — 127.0.0.1, чтобы наружу он не торчал без спроса.
Отсюда же отказ браузерным клиентам (заголовок Origin): страница, открытая
в браузере, иначе дотянулась бы до петлевого порта и до передатчика.
}
{$IFDEF FPC}
@@ -50,14 +60,17 @@ uses
SyncObjs; // ← после платформенных юнитов (конфликт идентификатора Create)
const
TCI_MAX_CLIENTS = 8;
TCI_MAX_CLIENTS = 8; // прошедших handshake (слоты протокола)
TCI_MAX_SOCKETS = 32; // всего сокетов, включая ещё не поднявшиеся
TCI_TICK_MS = 20; // период OnTick (сенсоры троттлятся адаптером)
TCI_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
TCI_SEND_TIMEOUT = 300; // мс на SockSend, иначе клиент считается мёртвым
TCI_HANDSHAKE_MS = 5000; // мс на HTTP-запрос от подключившегося
TCI_POLL_MS = 20; // на столько recv клиента засыпает между кадрами
TCI_OUT_MAX = 4000; // потолок очереди отправки на клиента (строк)
TCI_OUT_CHUNK = 3800; // склейка очереди в один фрейм, символов
TCI_MSG_MAX = 65536; // потолок собираемого из фрагментов сообщения
TCI_STOP_WAIT_MS = 10000; // сколько ждём выхода клиентских потоков в Stop
TCI_STOP_KILL_MS = 500; // как часто добиваем клиентов, ожидая их выхода
type
TTCIServer = class;
@@ -65,12 +78,17 @@ type
{ Один подключённый клиент: WS-сокет, его личные подписки и очередь
отправки. Подписки на сенсоры в TCI индивидуальны (RX_SENSORS_ENABLE
«отправляется только клиентом»), поэтому живут здесь, а не в адаптере.
Параметры потоков (§4.3) — тоже клиентские, их держит адаптер по ссылке
на этот объект. }
Параметры потоков (§4.3) — тоже клиентские.
Подписки и параметры потоков пишет поток клиента, а читает тик-поток,
поэтому и те и другие ходят через FStateLock: набор «включено + период +
последняя отправка» обязан меняться и читаться целиком. }
TTCIClient = class
private
FWs: TWsClient;
FUp: Boolean; // handshake прошёл: клиент занимает слот
FReady: Boolean; // пачка инициализации отправлена
FStateLock: TCriticalSection;
FRxSensors: Boolean;
FRxSensorsMs: Integer;
FRxSensorsAt: QWord; // тик последней отправки
@@ -92,30 +110,46 @@ type
FDead: Boolean; // сокет уже не пишется — гасим соединение
FKilled: Boolean; // shutdown сокета уже сделан
FClosed: Boolean; // клиентский поток вышел (можно освобождать)
function GetReady: Boolean;
procedure SetReady(V: Boolean);
function GetIQRate: Integer; procedure SetIQRate(V: Integer);
function GetAudioRate: Integer; procedure SetAudioRate(V: Integer);
function GetAudioSamples: Integer; procedure SetAudioSamples(V: Integer);
function GetAudioChannels: Integer; procedure SetAudioChannels(V: Integer);
function GetAudioSampleType: string; procedure SetAudioSampleType(const V: string);
function GetTxBuffering: Integer; procedure SetTxBuffering(V: Integer);
public
constructor Create(AWs: TWsClient);
destructor Destroy; override;
{ Строку в очередь клиенту. False — соединение уже мертво. Не блокирует. }
function Send(const S: string): Boolean;
{ Слить очередь в сокет. Зовёт только тик-поток. False — клиент умер. }
{ Слить очередь в сокет. Зовёт ТОЛЬКО собственный поток клиента: запись
может ждать до TCI_SEND_TIMEOUT, и общий поток на этом задерживал бы
всех остальных. False — клиент умер. }
function Flush: Boolean;
{ Пометить мёртвым и разбудить его поток (shutdown сокета). }
procedure Kill;
{ Подписки на измерители — целиком под локом. }
procedure SetRxSensors(On_: Boolean);
procedure SetRxSensorsMs(Ms: Integer);
procedure SetTxSensors(On_: Boolean);
procedure SetTxSensorsMs(Ms: Integer);
{ Пора ли слать измеритель: проверка периода и отметка отправки — один
атомарный шаг, иначе тик-поток и клиентский расходятся в наборе. }
function DueRxSensors(Now_: QWord): Boolean;
function DueTxSensors(Now_: QWord): Boolean;
property Ws: TWsClient read FWs;
property Ready: Boolean read FReady write FReady;
property Up: Boolean read FUp;
property Ready: Boolean read GetReady write SetReady;
property Dead: Boolean read FDead;
property RxSensors: Boolean read FRxSensors write FRxSensors;
property RxSensorsMs: Integer read FRxSensorsMs write FRxSensorsMs;
property RxSensorsAt: QWord read FRxSensorsAt write FRxSensorsAt;
property TxSensors: Boolean read FTxSensors write FTxSensors;
property TxSensorsMs: Integer read FTxSensorsMs write FTxSensorsMs;
property TxSensorsAt: QWord read FTxSensorsAt write FTxSensorsAt;
property IQRate: Integer read FIQRate write FIQRate;
property AudioRate: Integer read FAudioRate write FAudioRate;
property AudioSamples: Integer read FAudioSamples write FAudioSamples;
property AudioChannels: Integer read FAudioChannels write FAudioChannels;
property AudioSampleType: string read FAudioSampleType write FAudioSampleType;
property TxBuffering: Integer read FTxBuffering write FTxBuffering;
property IQRate: Integer read GetIQRate write SetIQRate;
property AudioRate: Integer read GetAudioRate write SetAudioRate;
property AudioSamples: Integer read GetAudioSamples write SetAudioSamples;
property AudioChannels: Integer read GetAudioChannels write SetAudioChannels;
property AudioSampleType: string read GetAudioSampleType write SetAudioSampleType;
property TxBuffering: Integer read GetTxBuffering write SetTxBuffering;
end;
TTCIClientEvent = procedure(Client: TTCIClient) of object;
@@ -124,8 +158,9 @@ type
TTCIServer = class
private
FListenSock: TSocket;
FClients: array[0..TCI_MAX_CLIENTS-1] of TTCIClient;
FClientCount: Integer;
FClients: array[0..TCI_MAX_SOCKETS-1] of TTCIClient;
FClientCount: Integer; // всего сокетов в массиве (с не поднявшимися)
FUpCount: LongInt; // прошедших handshake (Interlocked*)
FClientLock: TCriticalSection;
FAcceptThread: TThread;
FTickThread: TThread;
@@ -140,11 +175,16 @@ type
FOnTick: TThreadMethod;
function InitListen: Boolean;
procedure ReapClients; // освободить клиентов, чьи потоки вышли
procedure FlushClients; // слить очереди в сокеты (вне FClientLock)
procedure KillAll;
procedure Disconnected(Client: TTCIClient); // OnDisconnect, единая точка
public
constructor Create;
destructor Destroy; override;
{ Проверка настроек без побочных эффектов: можно ли вообще открыть такой
слушатель. Зовётся ДО остановки работающего сервера. }
class function ValidSettings(APort: Word; const ABindIP: string): Boolean;
{ Настройка слушателя. Применяется при следующем Start.
False — адрес не разобран (порт не откроется). }
function Configure(APort: Word; const ABindIP: string): Boolean;
@@ -168,6 +208,7 @@ type
procedure AcceptLoop;
procedure TickLoop;
procedure HandleClient(Client: TTCIClient);
function Promote(Client: TTCIClient): Boolean; // handshake прошёл
procedure ThreadDone; // клиентский поток отработал
property Port: Word read FPort;
@@ -187,6 +228,16 @@ type
«ошибка разбора = слушаем всё» в протоколе без авторизации недопустима. }
function TCIParseIPv4(const S: string; out Addr: LongWord): Boolean;
{ Значение HTTP-заголовка (Name — в нижнем регистре, без ':'). Разбор
построчный: точное сравнение подстроки «upgrade: websocket» отвергало
валидные запросы с табуляцией или без пробела после двоеточия. }
function TCIHttpHeader(const Header, Name: string): string;
{ Проверка UTF-8: текстовые кадры WebSocket обязаны быть корректным UTF-8
(RFC 6455 §5.6). Отвергает и оборванные последовательности, и избыточно
длинные формы, и суррогаты, и всё выше U+10FFFF. }
function TCIValidUTF8(const S: string): Boolean;
implementation
type
@@ -251,7 +302,8 @@ begin
end;
finally
// Освобождать себя нельзя: объект переиспользуется рассылкой из чужих
// потоков. Помечаем «поток вышел» — освободит тик-поток (ReapClients).
// потоков. Помечаем «поток вышел» — освободит тик-поток (ReapClients)
// или Stop, который ждёт именно этого.
FClient.FClosed := True;
FServer.ThreadDone;
end;
@@ -290,6 +342,57 @@ begin
Result := True;
end;
function TCIHttpHeader(const Header, Name: string): string;
var
i, Start, P: Integer;
Line, LName: string;
begin
Result := '';
Start := 1;
for i := 1 to Length(Header) + 1 do
if (i > Length(Header)) or (Header[i] = #10) then
begin
Line := Trim(Copy(Header, Start, i - Start)); // Trim снимет и #13
Start := i + 1;
P := Pos(':', Line);
if P <= 1 then Continue;
LName := LowerCase(Trim(Copy(Line, 1, P - 1)));
if LName = Name then
Exit(Trim(Copy(Line, P + 1, MaxInt)));
end;
end;
function TCIValidUTF8(const S: string): Boolean;
var
i, k, N, Len: Integer;
B: Byte;
Cp: LongWord;
begin
i := 1;
Len := Length(S);
while i <= Len do
begin
B := Byte(S[i]);
if B < $80 then begin Inc(i); Continue; end
else if (B >= $C2) and (B <= $DF) then begin N := 1; Cp := B and $1F; end
else if (B >= $E0) and (B <= $EF) then begin N := 2; Cp := B and $0F; end
else if (B >= $F0) and (B <= $F4) then begin N := 3; Cp := B and $07; end
else Exit(False); // $80..$C1 и $F5.. началом последовательности не бывают
if i + N > Len then Exit(False); // оборвано на середине символа
for k := 1 to N do
begin
if (Byte(S[i + k]) and $C0) <> $80 then Exit(False);
Cp := (Cp shl 6) or (Byte(S[i + k]) and $3F);
end;
// Избыточно длинная форма, суррогатная пара и выход за U+10FFFF.
if ((N = 2) and (Cp < $800)) or ((N = 3) and (Cp < $10000)) or
((Cp >= $D800) and (Cp <= $DFFF)) or (Cp > $10FFFF) then Exit(False);
Inc(i, N + 1);
end;
Result := True;
end;
{ ═══════════════════════════════════════════════════════════════════════════
TTCIClient
═══════════════════════════════════════════════════════════════════════════ }
@@ -298,7 +401,9 @@ constructor TTCIClient.Create(AWs: TWsClient);
begin
inherited Create;
FWs := AWs;
FUp := False;
FReady := False;
FStateLock := TCriticalSection.Create;
FRxSensors := False;
FRxSensorsMs := 200;
FTxSensors := False;
@@ -318,9 +423,150 @@ end;
destructor TTCIClient.Destroy;
begin
FOutLock.Free;
FStateLock.Free;
inherited;
end;
function TTCIClient.GetReady: Boolean;
begin
FStateLock.Enter;
try Result := FReady; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetReady(V: Boolean);
begin
FStateLock.Enter;
try FReady := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetIQRate: Integer;
begin
FStateLock.Enter;
try Result := FIQRate; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetIQRate(V: Integer);
begin
FStateLock.Enter;
try FIQRate := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetAudioRate: Integer;
begin
FStateLock.Enter;
try Result := FAudioRate; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetAudioRate(V: Integer);
begin
FStateLock.Enter;
try FAudioRate := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetAudioSamples: Integer;
begin
FStateLock.Enter;
try Result := FAudioSamples; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetAudioSamples(V: Integer);
begin
FStateLock.Enter;
try FAudioSamples := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetAudioChannels: Integer;
begin
FStateLock.Enter;
try Result := FAudioChannels; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetAudioChannels(V: Integer);
begin
FStateLock.Enter;
try FAudioChannels := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetAudioSampleType: string;
begin
FStateLock.Enter;
try Result := FAudioSampleType; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetAudioSampleType(const V: string);
begin
FStateLock.Enter;
try FAudioSampleType := V; finally FStateLock.Leave; end;
end;
function TTCIClient.GetTxBuffering: Integer;
begin
FStateLock.Enter;
try Result := FTxBuffering; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetTxBuffering(V: Integer);
begin
FStateLock.Enter;
try FTxBuffering := V; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetRxSensors(On_: Boolean);
begin
FStateLock.Enter;
try
FRxSensors := On_;
if On_ then FRxSensorsAt := 0; // первую посылку не ждём период
finally
FStateLock.Leave;
end;
end;
procedure TTCIClient.SetRxSensorsMs(Ms: Integer);
begin
FStateLock.Enter;
try FRxSensorsMs := Ms; finally FStateLock.Leave; end;
end;
procedure TTCIClient.SetTxSensors(On_: Boolean);
begin
FStateLock.Enter;
try
FTxSensors := On_;
if On_ then FTxSensorsAt := 0;
finally
FStateLock.Leave;
end;
end;
procedure TTCIClient.SetTxSensorsMs(Ms: Integer);
begin
FStateLock.Enter;
try FTxSensorsMs := Ms; finally FStateLock.Leave; end;
end;
function TTCIClient.DueRxSensors(Now_: QWord): Boolean;
begin
FStateLock.Enter;
try
Result := FRxSensors and (Now_ - FRxSensorsAt >= QWord(FRxSensorsMs));
if Result then FRxSensorsAt := Now_;
finally
FStateLock.Leave;
end;
end;
function TTCIClient.DueTxSensors(Now_: QWord): Boolean;
begin
FStateLock.Enter;
try
Result := FTxSensors and (Now_ - FTxSensorsAt >= QWord(FTxSensorsMs));
if Result then FTxSensorsAt := Now_;
finally
FStateLock.Leave;
end;
end;
function TTCIClient.Send(const S: string): Boolean;
begin
Result := False;
@@ -423,6 +669,7 @@ begin
{$ENDIF}
FListenSock := SOCK_INVALID;
FClientCount := 0;
FUpCount := 0;
FClientLock := TCriticalSection.Create;
FPort := TCI_DEFAULT_PORT;
FBindIP := '127.0.0.1';
@@ -438,12 +685,17 @@ begin
inherited;
end;
function TTCIServer.Configure(APort: Word; const ABindIP: string): Boolean;
class function TTCIServer.ValidSettings(APort: Word; const ABindIP: string): Boolean;
var Dummy: LongWord;
begin
Result := (APort <> 0) and TCIParseIPv4(ABindIP, Dummy);
end;
function TTCIServer.Configure(APort: Word; const ABindIP: string): Boolean;
begin
FPort := APort;
FBindIP := ABindIP;
Result := (APort <> 0) and TCIParseIPv4(ABindIP, Dummy);
Result := ValidSettings(APort, ABindIP);
end;
function TTCIServer.Running: Boolean;
@@ -513,6 +765,18 @@ begin
Result := True;
end;
procedure TTCIServer.KillAll;
var i: Integer;
begin
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if FClients[i] <> nil then FClients[i].Kill;
finally
FClientLock.Leave;
end;
end;
procedure TTCIServer.Stop;
var i, Waited: Integer;
begin
@@ -529,42 +793,40 @@ begin
FListenSock := SOCK_INVALID;
end;
// Шаг 2: будим клиентские потоки, висящие в recv.
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if FClients[i] <> nil then FClients[i].Kill;
finally
FClientLock.Leave;
end;
// Шаг 3: свои потоки (клиентские — FreeOnTerminate, ждём их отдельно).
// Шаг 2: дожидаемся accept-потока — после него новых клиентов не появится.
if FAcceptThread <> nil then begin FAcceptThread.WaitFor; FreeAndNil(FAcceptThread); end;
if FTickThread <> nil then begin FTickThread.WaitFor; FreeAndNil(FTickThread); end;
// Шаг 4: ждём выхода клиентских потоков. Прокачивая очередь Synchronize:
// Stop зовёт поток контроллера (UI), а клиентский поток может как раз в нём
// висеть на FController.Invoke. Без прокачки это гарантированный взаимный
// клин, а по его истечении — освобождение объекта из-под живого потока.
// Шаг 3: будим клиентские потоки, висящие в recv, и останавливаем тик.
KillAll;
if FTickThread <> nil then begin FTickThread.WaitFor; FreeAndNil(FTickThread); end;
// Шаг 4: ждём выхода клиентских потоков — БЕЗ таймаута. Прокачивая очередь
// Synchronize: Stop зовёт поток контроллера (UI), а клиентский поток может
// как раз в нём висеть на FController.Invoke; без прокачки это взаимный
// клин. Выйти отсюда по таймауту нельзя: следом освобождаются и клиенты, и
// сам сервер с адаптером, а живой поток вернулся бы в эту память.
Waited := 0;
while (FThreadCount > 0) and (Waited < TCI_STOP_WAIT_MS) do
while FThreadCount > 0 do
begin
if GetCurrentThreadId = MainThreadID then CheckSynchronize(5) else Sleep(5);
Inc(Waited, 5);
// Повторный shutdown: клиент мог быть принят между шагом 2 и шагом 3
// (accept уже вернул сокет, поток стартовал позже) и Kill его не застал.
if (Waited mod TCI_STOP_KILL_MS) = 0 then KillAll;
end;
// Шаг 5: зачистка. Если поток всё же не вышел (не должно случаться: сокеты
// закрыты, очередь прокачана), объект НЕ освобождаем — утечка на выходе
// несравнимо дешевле обращения к освобождённой памяти из живого потока.
// Шаг 5: зачистка. Потоков больше нет — освобождать безопасно.
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and FClients[i].FClosed then
if FClients[i] <> nil then
begin
Disconnected(FClients[i]); // и на остановке тоже: захваты снимаются
FClients[i].Ws.Free;
FreeAndNil(FClients[i]);
end;
FClientCount := 0;
FUpCount := 0;
finally
FClientLock.Leave;
end;
@@ -603,12 +865,16 @@ begin
end;
SockSetSndTimeout(CSock, TCI_SEND_TIMEOUT);
// До конца handshake сокет не должен молчать вечно: иначе горстка пустых
// соединений держала бы место, ничего не сказав.
SockSetRcvTimeout(CSock, TCI_HANDSHAKE_MS);
Client := TTCIClient.Create(TWsClient.Create(CSock));
Full := False;
FClientLock.Enter;
try
// Слот берём под локом: место в массиве освобождает тик-поток.
if FClientCount >= TCI_MAX_CLIENTS then Full := True
// Место в массиве освобождает тик-поток; слот протокола (Up) клиент
// получит позже — после успешного Upgrade (см. Promote).
if FClientCount >= TCI_MAX_SOCKETS then Full := True
else
begin
FClients[FClientCount] := Client;
@@ -630,13 +896,34 @@ begin
end;
end;
function TTCIServer.Promote(Client: TTCIClient): Boolean;
// Слот протокола выдаётся ТОЛЬКО тут — после разбора HTTP-запроса и до ответа
// 101. Считаем поднявшихся: молчащие сокеты слотов не занимают.
var i, N: Integer;
begin
Result := False;
if not FRunning then Exit;
FClientLock.Enter;
try
N := 0;
for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and FClients[i].FUp then Inc(N);
if N >= TCI_MAX_CLIENTS then Exit;
Client.FUp := True;
InterLockedIncrement(FUpCount);
Result := True;
finally
FClientLock.Leave;
end;
end;
procedure TTCIServer.ReapClients;
// Освобождение клиентов — единственное место во всей программе. Зовёт только
// Освобождение клиентов — единственное место, кроме Stop. Зовёт только
// тик-поток, поэтому указатель, взятый кем угодно под FClientLock, живёт до
// следующего прохода тика (а вне лока указателей никто не держит).
var
i, j, N: Integer;
Doomed: array[0..TCI_MAX_CLIENTS-1] of TTCIClient;
Doomed: array[0..TCI_MAX_SOCKETS-1] of TTCIClient;
begin
N := 0;
FClientLock.Enter;
@@ -645,6 +932,7 @@ begin
while i < FClientCount do
if (FClients[i] <> nil) and FClients[i].FClosed then
begin
if FClients[i].FUp then InterLockedDecrement(FUpCount);
Doomed[N] := FClients[i];
Inc(N);
for j := i to FClientCount - 2 do FClients[j] := FClients[j + 1];
@@ -659,33 +947,18 @@ begin
for i := 0 to N - 1 do
begin
if Assigned(FOnDisconnect) then FOnDisconnect(Doomed[i]);
Disconnected(Doomed[i]);
Doomed[i].Ws.Free; // закрывает сокет
Doomed[i].Free;
end;
end;
procedure TTCIServer.FlushClients;
// Запись в сокеты — вне FClientLock: медленный клиент не должен держать лок,
// иначе Broadcast из потока контроллера снова начнёт ждать сеть.
var
Snap: array[0..TCI_MAX_CLIENTS-1] of TTCIClient;
i, N: Integer;
procedure TTCIServer.Disconnected(Client: TTCIClient);
// Единственное место, где наверх уходит «клиент ушёл»: и обычное отключение
// (ReapClients), и остановка сервера. Иначе после Stop у адаптера оставались
// висеть захваты параметров ушедших клиентов (§3.5).
begin
N := 0;
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and not FClients[i].FClosed then
begin
Snap[N] := FClients[i];
Inc(N);
end;
finally
FClientLock.Leave;
end;
for i := 0 to N - 1 do
Snap[i].Flush;
if (Client <> nil) and Assigned(FOnDisconnect) then FOnDisconnect(Client);
end;
{ ═══════════════════════════════════════════════════════════════════════════
@@ -696,12 +969,12 @@ procedure TTCIServer.HandleClient(Client: TTCIClient);
var
Ws: TWsClient;
R, HeaderEnd: Integer;
Header, HeaderLC, Key, AcceptKey, Response, Text: string;
Header, Key, AcceptKey, Response, Text: string;
Raw: array[0..4095] of Byte;
RawLen, Rest: Integer;
B0, B1: Byte;
Masked, Fin, Pending: Boolean;
PayLen, Need, i, j, Consumed, KPos, KEnd: Integer;
PayLen, Need, i, j, Consumed: Integer;
Hi32: LongWord;
Mask: array[0..3] of Byte;
Payload: array of Byte;
@@ -717,6 +990,8 @@ begin
HeaderEnd := 0;
repeat
R := SockRecv(Ws.Socket, @Raw[RawLen], SizeOf(Raw) - RawLen, 0);
// R <= 0 здесь — это и разрыв, и истёкший TCI_HANDSHAKE_MS: молчащее
// соединение уходит само, не занимая место.
if R <= 0 then begin Ws.State := wsClosed; Break; end;
Inc(RawLen, R);
SetLength(Header, RawLen);
@@ -728,10 +1003,10 @@ begin
Consumed := HeaderEnd + 3; // длина заголовков вместе с CRLFCRLF
Header := Copy(Header, 1, Consumed);
HeaderLC := LowerCase(Header);
// Путь не проверяем: клиенты ходят на '/', но протокол его не оговаривает.
if System.Pos('upgrade: websocket', HeaderLC) = 0 then
if (Pos('websocket', LowerCase(TCIHttpHeader(Header, 'upgrade'))) = 0) or
(Pos('upgrade', LowerCase(TCIHttpHeader(Header, 'connection'))) = 0) then
begin
Response := 'HTTP/1.1 426 Upgrade Required'#13#10 +
'Content-Length: 0'#13#10'Connection: close'#13#10#13#10;
@@ -739,15 +1014,20 @@ begin
Exit;
end;
Key := '';
KPos := System.Pos('sec-websocket-key: ', HeaderLC);
if KPos > 0 then
// Браузерный клиент. Origin шлют только браузеры, и он — единственный
// признак, отличающий страницу от нативной программы. Авторизации в TCI
// нет: без этой проверки открытая вкладка с чужого сайта дотянулась бы по
// ws://127.0.0.1:40001 до TRX/TUNE/VFO. Своим web-страницам нужен явный
// прокси, а не дыра по умолчанию.
if TCIHttpHeader(Header, 'origin') <> '' then
begin
Key := Copy(Header, KPos + 19, 100);
KEnd := System.Pos(#13, Key);
if KEnd > 0 then Key := Copy(Key, 1, KEnd - 1);
Key := Trim(Key);
Response := 'HTTP/1.1 403 Forbidden'#13#10 +
'Content-Length: 0'#13#10'Connection: close'#13#10#13#10;
Ws.SendRaw(Response[1], Length(Response));
Exit;
end;
Key := TCIHttpHeader(Header, 'sec-websocket-key');
// Пустой ключ = не WebSocket-клиент (или сломанный): Accept без ключа
// формально считается валидным, и такое «соединение» потом молча висит.
if Key = '' then
@@ -758,6 +1038,16 @@ begin
Exit;
end;
// Слот протокола — до ответа 101: отказать после «Switching Protocols» уже
// некрасиво, клиент считал бы себя подключённым.
if not Promote(Client) then
begin
Response := 'HTTP/1.1 503 Service Unavailable'#13#10 +
'Content-Length: 0'#13#10'Connection: close'#13#10#13#10;
Ws.SendRaw(Response[1], Length(Response));
Exit;
end;
AcceptKey := Base64EncodeBytes(SHA1(Key + TCI_WS_GUID), 20);
Response := 'HTTP/1.1 101 Switching Protocols'#13#10 +
'Upgrade: websocket'#13#10 +
@@ -765,6 +1055,9 @@ begin
'Sec-WebSocket-Accept: ' + AcceptKey + #13#10#13#10;
if not Ws.SendRaw(Response[1], Length(Response)) then Exit;
Ws.State := wsOpen;
// Дальше клиент вправе молчать сколько угодно, но просыпаться нам надо:
// на этом же потоке уходит его очередь отправки (Flush).
SockSetRcvTimeout(Ws.Socket, TCI_POLL_MS);
// Хвост первого пакета: клиент вправе прислать первый WS-фрейм в том же
// сегменте, что и заголовки. Выбросить его — потерять первую команду.
@@ -772,8 +1065,22 @@ begin
if Rest > 0 then Move(Raw[Consumed], Ws.BufData[0], Rest);
Ws.BufLen := Rest;
// Пачка инициализации + текущее состояние (§3.1) — дело адаптера.
if Assigned(FOnConnect) then FOnConnect(Client);
// Пачка инициализации + текущее состояние (§3.1) — дело адаптера. Под
// FClientLock: пока она набирается, рассылка обязана ждать. Иначе изменение,
// случившееся после строки снимка, но до Ready=True, пропадало навсегда —
// Broadcast пропускает не-Ready клиента, и тот оставался со старым значением,
// считая инициализацию завершённой. Лок держится только на укладку строк в
// очередь (сеть тут не пишется), но обработчик OnConnect по этой же причине
// НЕ имеет права звать Invoke в поток контроллера: тот может ждать этот лок.
if Assigned(FOnConnect) then
begin
FClientLock.Enter;
try
FOnConnect(Client);
finally
FClientLock.Leave;
end;
end;
// ── Цикл WS-сообщений ────────────────────────────────────────────────────
Cmds := TStringList.Create;
@@ -786,7 +1093,9 @@ begin
if not Pending then
begin
R := Ws.Recv;
if R <= 0 then Break;
// R <= 0 — либо разрыв, либо просто истёк TCI_POLL_MS. Второе штатно:
// просыпаемся, чтобы отдать накопившуюся очередь.
if (R <= 0) and not SockRecvTimedOut then Break;
end;
Pending := False;
@@ -902,6 +1211,14 @@ begin
if Fin then
begin
// Текстовое сообщение обязано быть валидным UTF-8 (§5.6);
// битую последовательность RFC велит закрывать, а не молча
// скармливать разбору команд.
if (MsgOp = $01) and not TCIValidUTF8(Frag) then
begin
Ws.State := wsClosed;
Break;
end;
if (MsgOp = $01) and Assigned(FOnCommand) and (Frag <> '') then
begin
TCISplit(Frag, Cmds);
@@ -912,8 +1229,15 @@ begin
Frag := '';
end;
end;
$08: // close
$08: // close: RFC 6455 §5.5.1 требует ответить своим close-кадром
begin
// Полезная нагрузка close — либо пустая, либо код (2 байта) плюс
// причина. Ровно один байт невалиден: отвечать на такое нечем.
if PayLen = 1 then begin Ws.State := wsClosed; Break; end;
// В ответе — только код: причину повторять не обязаны (§5.5.1),
// а чужой текст мы наружу не пересылаем.
if PayLen >= 2 then Ws.SendWsFrame($08, Payload[0], 2)
else Ws.SendWsFrame($08, PayLen, 0);
Ws.State := wsClosed;
Break;
end;
@@ -927,6 +1251,11 @@ begin
Break;
end;
end;
// Очередь — в сокет здесь же, на потоке этого клиента: ответы на только
// что разобранные команды уходят сразу, а медленный клиент задерживает
// только себя (тик-поток очереди лишь наполняет).
if not Client.Flush then Break;
end;
finally
Cmds.Free;
@@ -960,7 +1289,8 @@ begin
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and not FClients[i].FClosed then Proc(FClients[i]);
if (FClients[i] <> nil) and FClients[i].FUp and not FClients[i].FClosed then
Proc(FClients[i]);
finally
FClientLock.Leave;
end;
@@ -973,7 +1303,7 @@ end;
function TTCIServer.ClientCount: Integer;
begin
Result := FClientCount;
Result := FUpCount;
end;
procedure TTCIServer.TickLoop;
@@ -983,8 +1313,10 @@ begin
Sleep(TCI_TICK_MS);
if not FRunning then Break;
ReapClients; // отключившиеся — освобождаем только здесь
if (FClientCount > 0) and Assigned(FOnTick) then FOnTick;
FlushClients; // очереди → сокеты
if (FUpCount > 0) and Assigned(FOnTick) then FOnTick;
// В сокеты пишет каждый клиент сам, на своём потоке (см. HandleClient):
// общий поток отправки означал бы, что один медленный клиент задерживает
// очередь всех остальных на свой таймаут записи.
end;
end;