Files
ewsdr/WebUtils.pas
T
ew8bakandClaude Opus 5 4165cbe9a5 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>
2026-08-17 22:53:32 +03:00

379 lines
14 KiB
ObjectPascal

unit WebUtils;
{
WebUtils.pas — Вспомогательные функции для WebServer:
- Кросс-платформенные обёртки сокетов (Windows/Linux)
- SHA-1 (минимальная реализация для WebSocket handshake)
- Base64 (кодирование)
- JSON helpers (JsonGetStr, JsonGetFloat, JsonGetInt, JsonGetBool)
ИСПРАВЛЕНИЯ:
- (Windows build fix) Порядок uses: стандартные RTL-юниты первыми,
платформенные (WinSock2) последними — исключает конфликт
идентификатора Create в режиме {$MODE Delphi} под Windows.
- (Linux shutdown fix) Добавлена SockShutdown вызов shutdown(SHUT_RDWR)
перед close, что немедленно прерывает заблокированные fpAccept/fpRecv
в других потоках и предотвращает зависание при закрытии программы.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
SysUtils, Math
{$IFDEF WINDOWS}, Windows, WinSock2{$ELSE}, BaseUnix, Sockets{$ENDIF};
{ ── Кросс-платформенные константы сокетов ─────────────────────────────────── }
{$IFDEF WINDOWS}
const
SOCK_INVALID = INVALID_SOCKET;
SOCK_ERR = SOCKET_ERROR;
{$ELSE}
const
SOCK_INVALID = TSocket(-1);
SOCK_ERR = -1;
INVALID_SOCKET = TSocket(-1);
{$ENDIF}
{ ── Обёртки системных вызовов сокетов ─────────────────────────────────────── }
function SockClose(S: TSocket): Integer; inline;
{ SockShutdown — прерывает все блокирующие recv/accept на сокете в других
потоках. На Linux необходимо вызывать ДО SockClose, иначе потоки не
разблокируются и программа зависнет при завершении.
На Windows работает через SD_BOTH. }
procedure SockShutdown(S: TSocket);
function SockRecv(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer; inline;
function SockSend(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer; inline;
procedure SockSetNonBlock(S: TSocket; NB: Boolean);
{ SockSetSndTimeout — ограничивает время блокирующего SockSend.
Без таймаута SockSend может зависнуть на минуты (TCP retransmit timeout)
если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
{ SockSetRcvTimeout — ограничивает время блокирующего SockRecv. Нужен клиентам,
которым между пакетами надо просыпаться самим (проверить Terminated, отдать
накопившиеся команды): recv возвращает -1 по таймауту, соединение живо. }
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
{ SockRecvTimedOut — последняя ошибка SockRecv означает «данных пока нет»
(истёк SO_RCVTIMEO или сигнал), а не разрыв. Без этой проверки поток,
просыпающийся по таймауту, не отличит тишину от закрытого сокета. }
function SockRecvTimedOut: Boolean;
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── }
type
TSHA1Digest = array[0..19] of Byte;
TSHA1State = array[0..4] of LongWord;
procedure SHA1Transform(var S: TSHA1State; const Block: array of Byte);
function SHA1(const Data: string): TSHA1Digest;
{ ── Base64 ───────────────────────────────────────────────────────────────── }
function Base64EncodeBytes(const Data: array of Byte; Len: Integer): string;
function Base64EncodeStr(const S: string): string;
{ ── JSON helpers ─────────────────────────────────────────────────────────── }
function JsonGetStr(const Json, Key: string): string;
function JsonGetFloat(const Json, Key: string; Def: Double): Double;
function JsonGetInt(const Json, Key: string; Def: Integer): Integer;
function JsonGetBool(const Json, Key: string; Def: Boolean): Boolean;
implementation
{ ═══════════════════════════════════════════════════════════════════════════
Кросс-платформенные обёртки сокетов
═══════════════════════════════════════════════════════════════════════════ }
{$IFDEF WINDOWS}
function SockClose(S: TSocket): Integer;
begin
Result := closesocket(S);
end;
procedure SockShutdown(S: TSocket);
begin
// SD_BOTH = 2 — прерывает и recv и send, разблокирует accept/recv в других потоках
shutdown(S, SD_BOTH);
end;
function SockRecv(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer;
begin
Result := recv(S, Buf^, Len, Flags);
end;
function SockSend(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer;
begin
Result := send(S, Buf^, Len, Flags);
end;
procedure SockSetNonBlock(S: TSocket; NB: Boolean);
var Mode: LongWord;
begin
Mode := Ord(NB);
ioctlsocket(S, FIONBIO, @Mode);
end;
procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
var T: DWORD;
begin
T := Ms;
setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var T: DWORD;
begin
T := Ms;
setsockopt(S, SOL_SOCKET, SO_RCVTIMEO, @T, SizeOf(T));
end;
function SockRecvTimedOut: Boolean;
var E: Integer;
begin
E := WSAGetLastError;
Result := (E = WSAETIMEDOUT) or (E = WSAEWOULDBLOCK) or (E = WSAEINTR);
end;
{$ELSE}
function SockClose(S: TSocket): Integer;
begin
Result := fpClose(S);
end;
procedure SockShutdown(S: TSocket);
begin
// SHUT_RDWR = 2 — прерывает все блокирующие fpAccept/fpRecv в других потоках.
// На Linux одного fpClose недостаточно — он не прерывает системный вызов
// в чужом потоке. После shutdown поток получит 0 или ECONNRESET и выйдет.
fpShutdown(S, 2 {SHUT_RDWR});
end;
function SockRecv(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer;
begin
Result := fpRecv(S, Buf, Len, Flags);
end;
function SockSend(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer;
begin
// MSG_NOSIGNAL обязателен: запись в сокет, который клиент уже закрыл, иначе
// приходит SIGPIPE, а он по умолчанию убивает процесс целиком. С ним send
// просто возвращает -1/EPIPE, и вызывающий штатно выбрасывает клиента.
Result := fpSend(S, Buf, Len, Flags or MSG_NOSIGNAL);
end;
procedure SockSetNonBlock(S: TSocket; NB: Boolean);
var Flags: Integer;
begin
Flags := fpFcntl(S, F_GETFL, 0);
if NB then Flags := Flags or O_NONBLOCK
else Flags := Flags and (not O_NONBLOCK);
fpFcntl(S, F_SETFL, Flags);
end;
procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
var TV: TTimeVal;
begin
TV.tv_sec := Ms div 1000;
TV.tv_usec := (Ms mod 1000) * 1000;
fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var TV: TTimeVal;
begin
TV.tv_sec := Ms div 1000;
TV.tv_usec := (Ms mod 1000) * 1000;
fpSetSockOpt(S, SOL_SOCKET, SO_RCVTIMEO, @TV, SizeOf(TV));
end;
function SockRecvTimedOut: Boolean;
var E: Integer;
begin
E := fpgeterrno;
Result := (E = ESysEAGAIN) or (E = ESysEWOULDBLOCK) or (E = ESysEINTR);
end;
{$ENDIF}
{ ═══════════════════════════════════════════════════════════════════════════
SHA-1 (минимальная реализация для WebSocket handshake)
═══════════════════════════════════════════════════════════════════════════ }
procedure SHA1Transform(var S: TSHA1State; const Block: array of Byte);
var
W: array[0..79] of LongWord;
i: Integer;
a, b, c, d, e, t, f, k: LongWord;
begin
for i := 0 to 15 do
W[i] := (Block[i*4] shl 24) or (Block[i*4+1] shl 16) or
(Block[i*4+2] shl 8) or Block[i*4+3];
for i := 16 to 79 do
begin
t := W[i-3] xor W[i-8] xor W[i-14] xor W[i-16];
W[i] := (t shl 1) or (t shr 31);
end;
a := S[0]; b := S[1]; c := S[2]; d := S[3]; e := S[4];
for i := 0 to 79 do
begin
if i < 20 then begin f := (b and c) or ((not b) and d); k := $5A827999; end
else if i < 40 then begin f := b xor c xor d; k := $6ED9EBA1; end
else if i < 60 then begin f := (b and c) or (b and d) or (c and d); k := $8F1BBCDC; end
else begin f := b xor c xor d; k := $CA62C1D6; end;
t := ((a shl 5) or (a shr 27)) + f + e + k + W[i];
e := d; d := c; c := (b shl 30) or (b shr 2); b := a; a := t;
end;
Inc(S[0], a); Inc(S[1], b); Inc(S[2], c); Inc(S[3], d); Inc(S[4], e);
end;
function SHA1(const Data: string): TSHA1Digest;
var
S: TSHA1State;
Buf: array[0..63] of Byte;
Len, BitLen, i, Pad: Integer;
begin
S[0] := $67452301; S[1] := $EFCDAB89;
S[2] := $98BADCFE; S[3] := $10325476; S[4] := $C3D2E1F0;
Len := Length(Data);
BitLen := Len * 8;
i := 0;
while i + 64 <= Len do
begin
Move(Data[i+1], Buf[0], 64);
SHA1Transform(S, Buf);
Inc(i, 64);
end;
Pad := Len - i;
FillChar(Buf[0], 64, 0);
if Pad > 0 then Move(Data[i+1], Buf[0], Pad);
Buf[Pad] := $80;
if Pad >= 55 then
begin
SHA1Transform(S, Buf);
FillChar(Buf[0], 64, 0);
end;
Buf[63] := Byte(BitLen); Buf[62] := Byte(BitLen shr 8);
Buf[61] := Byte(BitLen shr 16); Buf[60] := Byte(BitLen shr 24);
SHA1Transform(S, Buf);
for i := 0 to 4 do
begin
Result[i*4] := Byte(S[i] shr 24); Result[i*4+1] := Byte(S[i] shr 16);
Result[i*4+2] := Byte(S[i] shr 8); Result[i*4+3] := Byte(S[i]);
end;
end;
{ ═══════════════════════════════════════════════════════════════════════════
Base64
═══════════════════════════════════════════════════════════════════════════ }
const
B64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function Base64EncodeBytes(const Data: array of Byte; Len: Integer): string;
var
i, j, n: Integer;
begin
Result := '';
i := 0;
while i < Len do
begin
n := Data[i] shl 16;
if i+1 < Len then n := n or (Data[i+1] shl 8);
if i+2 < Len then n := n or Data[i+2];
Result := Result
+ B64Chars[(n shr 18) and 63 + 1]
+ B64Chars[(n shr 12) and 63 + 1]
+ B64Chars[(n shr 6) and 63 + 1]
+ B64Chars[ n and 63 + 1];
Inc(i, 3);
end;
j := Len mod 3;
if j = 1 then begin Result[Length(Result)-1] := '='; Result[Length(Result)] := '='; end
else if j = 2 then Result[Length(Result)] := '=';
end;
function Base64EncodeStr(const S: string): string;
var
B: array of Byte;
i: Integer;
begin
SetLength(B, Length(S));
for i := 1 to Length(S) do B[i-1] := Ord(S[i]);
Result := Base64EncodeBytes(B, Length(S));
end;
{ ═══════════════════════════════════════════════════════════════════════════
JSON helpers (минимальный парсер без зависимостей)
═══════════════════════════════════════════════════════════════════════════ }
function JsonGetStr(const Json, Key: string): string;
var
P, P2: Integer;
K: string;
begin
Result := '';
// Ищем "key": — с двоеточием, чтобы не совпасть с "key" внутри значения
// Например в {"cmd":"mode","mode":1} поиск "mode": не найдёт "mode" как значение cmd
K := '"' + Key + '":';
P := System.Pos(K, Json);
if P = 0 then Exit;
Inc(P, Length(K));
// Пропускаем пробелы после двоеточия
while (P <= Length(Json)) and (Json[P] = ' ') do Inc(P);
if P > Length(Json) then Exit;
if Json[P] = '"' then
begin
Inc(P); P2 := P;
while (P2 <= Length(Json)) and (Json[P2] <> '"') do Inc(P2);
Result := Copy(Json, P, P2 - P);
end
else
begin
P2 := P;
while (P2 <= Length(Json)) and not (Json[P2] in [',', '}']) do Inc(P2);
Result := Trim(Copy(Json, P, P2 - P));
end;
end;
function JsonGetFloat(const Json, Key: string; Def: Double): Double;
var S: string;
begin
S := JsonGetStr(Json, Key);
if S = '' then
Result := Def
else
begin
val(S, Result);
if IsNaN(Result) then Result := Def;
end;
end;
function JsonGetInt(const Json, Key: string; Def: Integer): Integer;
begin
Result := Round(JsonGetFloat(Json, Key, Def));
end;
function JsonGetBool(const Json, Key: string; Def: Boolean): Boolean;
var S: string;
begin
S := JsonGetStr(Json, Key);
if S = '' then Result := Def
else Result := (S = 'true') or (S = '1');
end;
end.