mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 19:45:09 +00:00
Сеть и резолв (DXClusterClient): - Резолв ушёл в отдельный поток: системный резолвер блокирующий и не прерывается, а сокета в этот момент ещё нет — Stop из UI-потока висел на DNS-таймауте. Сессия ждёт квантами по 200 мс, просыпаясь на FStopEvent; Stop теперь отрабатывает за 100-200 мс в любой фазе. - Реестр запросов: по одному резолверу на имя, не больше DX_MAX_RESOLVERS. Не дождавшись, сессия оставляет запрос в реестре и на следующей попытке цепляется к нему же (иначе зависший резолвер плодил бы вечные потоки). Слотов несколько, чтобы смена адреса работала поверх зависшего прежнего; литеральный IP разбирается до реестра — ввод адреса руками обязан работать всегда. Запись живёт по счётчику ссылок, исключение в резолвере не оставляет слот занятым. - getaddrinfo вместо netdb.ResolveHostByName: тот ходит в DNS сам и /etc/hosts не читает вовсе (getent находит localhost, ResolveHostByName — нет), т.е. локальный алиас кластера не работал. Заодно реентерабельно. - WSAStartup перенесён в initialization, WSACleanup убран: Stop не ждёт резолвер, а тот может сидеть в gethostbyname. Логин (DXClusterClient): - Команды пользователя больше не уходят в незавершённый логин: очередь разбирается только после post-login, SendCommand говорит в лог, что команда ждёт. - ONLINE не по таймеру, а по существу: LoginSettled требует ответа сервера после учётки (с потолком молчания), пароль ждёт своего приглашения. Подтверждение ставится ПОСЛЕ разбора куска, а не на приход байтов — иначе исход логина зависел от границ TCP-пакетов. - Приглашения и отказы: строгий детектор (текст, заканчивающийся двоеточием) и для отправки пароля, и для вердикта — по вхождению слова пароль улетал командой от строки приветствия. Повтор приглашения пароля или позывного = отказ авторизации (dxsError, без реконнекта); опоздавшее приглашение после слепой отправки позывного отказом не считается. Эхо уже отвеченного приглашения гасится окном в одну строку. - Ошибка отправки post-login рвёт сессию, а не только цикл команд; неотправленная очередь возвращается на следующее соединение. UI и данные: - QSY по споту крутит активный VFO, а не всегда A (MainForm). - TTL спотов чистится тиком независимо от видимости оверлея (DXSpotStore .Purge + ServiceDXCluster). - Выделение в окне списка держится по позывному И частоте: один позывной живёт на разных диапазонах (DXClusterForm). - Кнопка DX правит и отложенную копию настроек, иначе debounce SETUP возвращал прежнее состояние подписей (MainForm). Проверено на фейковом кластере: приглашения с CRLF и без, границы TCP-пакетов, отказ по паролю и по позывному, опоздавшее приглашение, ловушки ложного срабатывания. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1507 lines
62 KiB
ObjectPascal
1507 lines
62 KiB
ObjectPascal
unit DXClusterClient;
|
||
|
||
{
|
||
DXClusterClient.pas — telnet-клиент DX-кластера (одно соединение).
|
||
|
||
Один рабочий поток: резолв (в отдельном одноразовом потоке — см. ниже) →
|
||
connect (неблокирующий + select, чтобы не висеть минуту на мёртвом хосте) →
|
||
логин позывным → чтение строк. Разобранные споты
|
||
кладутся ПРЯМО в TDXSpotStore (он потокобезопасен), лог соединения — во
|
||
внутреннее кольцо. В главный поток ничего не маршалим: UI сам замечает
|
||
изменения по Version стора и по LogVersion — как оверлеи замечают смену вида
|
||
по ключам кэша. Никаких LCL-зависимостей.
|
||
|
||
Разрыв → реконнект с backoff (RECONNECT_MIN..RECONNECT_MAX). Stop() делает
|
||
SockShutdown, что разблокирует recv в потоке (на Linux одного close мало —
|
||
см. WebUtils.SockShutdown).
|
||
|
||
★Резолв имени вынесен в ОТДЕЛЬНЫЙ поток. Системный резолвер (getaddrinfo на
|
||
unix, gethostbyname на Windows) блокирующий и не прерывается ничем: пока он
|
||
молчит, сокета ещё нет, рвать Stop()'у нечего, а Stop зовётся из UI-потока и
|
||
делает WaitFor — окно висело бы весь DNS-таймаут. Поэтому сессия ждёт результат
|
||
КВАНТАМИ, проверяя Terminated и FStopEvent.
|
||
★Реестр запросов GResolvePending: по одному резолверу НА ИМЯ и не больше
|
||
DX_MAX_RESOLVERS одновременно. Не дождавшись, сессия НЕ бросает запрос, а
|
||
оставляет его в реестре и на следующей попытке цепляется к тому же — иначе на
|
||
зависшем системном резолвере каждая попытка реконнекта плодила бы новый вечный
|
||
поток. Слотов несколько, а не один, чтобы смена адреса в SETUP давала попытку
|
||
по новому имени даже поверх зависшего прежнего; литеральный IP разбирается
|
||
вообще до реестра (TryLiteralIP) — ввод адреса руками обязан работать всегда.
|
||
Запись запроса живёт по счётчику ссылок (Refs: резолвер + ожидающие); кто
|
||
уронил счётчик в ноль, тот и Dispose.
|
||
★Имя разрешается через getaddrinfo, а НЕ через netdb.ResolveHostByName: тот
|
||
ходит в DNS напрямую и /etc/hosts не читает вовсе (проверено: getent находит
|
||
localhost, ResolveHostByName — нет), т.е. локальный алиас кластера не работал
|
||
бы; вдобавок он держит глобальное состояние, а getaddrinfo реентерабелен.
|
||
|
||
Формат спота (общий для DXSpider / AR-Cluster / CC Cluster):
|
||
DX de UA3XYZ: 14025.0 DL1ABC CW 599 tnx qso 1832Z
|
||
Всё, что не начинается с 'DX de ', в стор не идёт — только в лог.
|
||
}
|
||
|
||
{$IFDEF FPC}
|
||
{$MODE Delphi}
|
||
{$LONGSTRINGS ON}
|
||
{$ENDIF}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, SyncObjs, StrUtils, DateUtils,
|
||
WebUtils, // SockClose/SockShutdown/SockRecv/SockSend/SOCK_INVALID
|
||
DXSpotStore
|
||
// ★ Windows.pas здесь НЕТ намеренно: он объявляет TCriticalSection как
|
||
// ЗАПИСЬ (алиас TRTLCriticalSection) и, стоя в uses после SyncObjs,
|
||
// перекрывал класс — под win64 сборка падала на TCriticalSection.Create
|
||
// («identifier idents no member Create»). Всё, что нужно сокетам, даёт
|
||
// WinSock2; так же сделано в WsClient и HPSDRNetwork.
|
||
{$IFDEF MSWINDOWS}
|
||
, WinSock2
|
||
{$ELSE}
|
||
, BaseUnix, Sockets, netdb, ctypes
|
||
{$ENDIF};
|
||
|
||
const
|
||
DX_DEFAULT_HOST = 'cluster.dxfun.com';
|
||
DX_DEFAULT_PORT = 8000;
|
||
DX_CONNECT_MS = 8000; // потолок ожидания connect
|
||
DX_CONNECT_SLICE_MS = 200; // квант ожидания connect (чтобы Stop не ждал 8 с)
|
||
DX_DNS_MS = 10000; // потолок ожидания резолва имени
|
||
DX_DNS_SLICE_MS = 200; // квант ожидания резолва (чтобы Stop не ждал DNS)
|
||
DX_MAX_RESOLVERS = 4; // одновременных резолвер-потоков на процесс
|
||
DX_RECV_MS = 1000; // квант recv: просыпаемся проверить Terminated/очередь
|
||
DX_RECONNECT_MIN = 5; // с
|
||
DX_RECONNECT_MAX = 60; // с
|
||
DX_LOGIN_GRACE_S = 6; // не увидели приглашение — шлём позывной сами
|
||
DX_POSTLOGIN_S = 2; // пауза после последнего шага логина
|
||
DX_PASSWORD_WAIT_S = 8; // ждём приглашение пароля, потом считаем, что его не будет
|
||
DX_LOGIN_MAX_S = 20; // сервер молчит после позывного — идём дальше вслепую
|
||
DX_LOG_LINES = 300; // кольцо лога соединения
|
||
DX_SEND_RETRIES = 20; // повторов send по таймауту буфера, потом разрыв
|
||
|
||
type
|
||
TDXClusterState = (dxsOff, dxsConnecting, dxsLogin, dxsOnline, dxsRetry, dxsError);
|
||
|
||
TDXClusterClient = class
|
||
private
|
||
FThread: TThread;
|
||
FLock: TCriticalSection; // защищает конфиг, лог, очередь, состояние
|
||
FStopEvent: TEvent;
|
||
|
||
// конфиг (копия под FLock — поток читает её через GetConfig)
|
||
FHost: string;
|
||
FPort: Integer;
|
||
FLogin: string;
|
||
FPassword: string;
|
||
FPostLogin: string; // строки через LineEnding
|
||
|
||
FStore: TDXSpotStore; // не владеет
|
||
|
||
// Сокет живого соединения — зеркало под FLock. Нужен ровно для того, чтобы
|
||
// Stop() мог сделать SockShutdown и вывести поток из блокирующего recv:
|
||
// иначе выход из программы ждал бы до конца кванта recv (и до конца
|
||
// таймаута send, если кластер перестал забирать данные). Поток снимает
|
||
// зеркало ДО close, поэтому под локом хэндл гарантированно ещё открыт.
|
||
FActiveSock: TSocket;
|
||
|
||
FState: TDXClusterState;
|
||
FStatusMsg: string;
|
||
FSpotCount: Int64; // сколько спотов принято за сессию
|
||
|
||
FLog: array[0..DX_LOG_LINES-1] of string;
|
||
FLogHead: Integer; // индекс следующей записи
|
||
FLogCount: Integer;
|
||
FLogVer: Int64;
|
||
|
||
FOutQueue: TStringList; // строки на отправку в кластер
|
||
|
||
procedure SetState(St: TDXClusterState; const Msg: string);
|
||
procedure PublishSock(S: TSocket); // зеркало хэндла для Stop (см. FActiveSock)
|
||
public
|
||
constructor Create(AStore: TDXSpotStore);
|
||
destructor Destroy; override;
|
||
|
||
procedure Configure(const AHost: string; APort: Integer;
|
||
const ALogin, APassword, APostLogin: string);
|
||
procedure Start;
|
||
// Неблокирующая половина Stop: разбудить поток и порвать сокет, не дожидаясь
|
||
// его конца. Зовётся в начале выхода из программы, чтобы поток доживал
|
||
// ПАРАЛЛЕЛЬНО с разборкой радио, а join в Stop уже никого не ждал.
|
||
procedure RequestStop;
|
||
procedure Stop; // RequestStop + join
|
||
function Running: Boolean;
|
||
|
||
// Отправить сырую строку в кластер (окно списка: sh/dx, set/filter, …).
|
||
procedure SendCommand(const S: string);
|
||
|
||
// Лог соединения — снимок в порядке приёма (старые → новые).
|
||
procedure GetLog(Dst: TStrings);
|
||
function LogVersion: Int64;
|
||
procedure AddLog(const S: string);
|
||
|
||
function State: TDXClusterState;
|
||
function StateText: string;
|
||
function StatusMessage: string;
|
||
function SpotsReceived: Int64;
|
||
|
||
property Store: TDXSpotStore read FStore;
|
||
end;
|
||
|
||
// Разбор строки кластера. True — это спот (Spot заполнен).
|
||
function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
|
||
function DXStateName(St: TDXClusterState): string;
|
||
|
||
implementation
|
||
|
||
type
|
||
// Запрос на резолв, общий для сессии и одноразового резолвер-потока. Живёт в
|
||
// куче по счётчику ссылок: одна у резолвера, по одной у каждой ждущей сессии.
|
||
// Кто уронил Refs в ноль под GResolveLock, тот и делает Dispose — так
|
||
// брошенный DNS никого не переживает и ничего не течёт.
|
||
PDXResolveReq = ^TDXResolveReq;
|
||
TDXResolveReq = record
|
||
Host: string;
|
||
Addr: LongWord; // сетевой порядок байт
|
||
Ok: Boolean;
|
||
Done: Boolean; // резолвер отработал
|
||
Refs: Integer;
|
||
end;
|
||
|
||
// Чем кончилось ожидание резолва — от этого зависит, что писать в лог.
|
||
TDXResolveOutcome = (droOk, // имя разрешено
|
||
droFailed, // резолвер ответил «нет такого имени»
|
||
droTimeout, // не дождались (или нас остановили)
|
||
droBusy); // в полёте резолв ДРУГОГО имени
|
||
|
||
TDXResolverThread = class(TThread)
|
||
private
|
||
FReq: PDXResolveReq;
|
||
protected
|
||
procedure Execute; override;
|
||
public
|
||
constructor Create(AReq: PDXResolveReq);
|
||
end;
|
||
|
||
TDXClusterThread = class(TThread)
|
||
private
|
||
FOwner: TDXClusterClient;
|
||
FSocket: TSocket;
|
||
FRxBuf: string; // хвост неполной строки
|
||
|
||
// конфиг сессии (снимок на время соединения)
|
||
FHost: string;
|
||
FPort: Integer;
|
||
FLogin: string;
|
||
FPassword: string;
|
||
FPostLogin: string;
|
||
|
||
FLoginSent: Boolean;
|
||
FLoginByPrompt: Boolean; // позывной ушёл в ответ на приглашение, не вслепую
|
||
FPassSent: Boolean;
|
||
FPostSent: Boolean;
|
||
FLoginAt: TDateTime; // когда ушёл позывной
|
||
FLoginStepAt: TDateTime; // когда ушла последняя порция учётки (позывной/пароль)
|
||
FSawAfterLogin: Boolean; // после учётки сервер что-то прислал
|
||
FConnectAt: TDateTime;
|
||
FFatal: Boolean; // отказ авторизации — реконнект бессмыслен
|
||
// Приглашение, на которое мы только что ответили, разобрав НЕЗАВЕРШЁННЫЙ
|
||
// хвост. Оно остаётся в буфере и со следующим куском доедет целой строкой —
|
||
// окно ровно на одну строку, чтобы не счесть собственное эхо новым вопросом.
|
||
FAnsweredPrompt: string;
|
||
|
||
procedure FailAuth(const Reason: string);
|
||
function LoginSettled: Boolean;
|
||
function SendPostLogin: Boolean;
|
||
function Resolve(const Host: string; out Addr: LongWord): TDXResolveOutcome;
|
||
function ConnectSock: Boolean;
|
||
procedure CloseSock;
|
||
function SendAll(const Data: string): Boolean;
|
||
function SendLine(const S: string): Boolean;
|
||
procedure FlushOutQueue;
|
||
procedure HandleChunk(const Data: string);
|
||
procedure HandleLine(const Line: string);
|
||
procedure CheckPrompts(const Tail: string; IsTail: Boolean);
|
||
function Session: Boolean; // одно соединение; False — выйти совсем
|
||
protected
|
||
procedure Execute; override;
|
||
public
|
||
constructor Create(AOwner: TDXClusterClient);
|
||
end;
|
||
|
||
var
|
||
// Один на все запросы резолва: они редки (одна попытка на соединение), а общий
|
||
// лок избавляет запись запроса от собственной критической секции — иначе её
|
||
// пришлось бы освобождать ровно тому, кто освобождает саму запись.
|
||
GResolveLock: TCriticalSection;
|
||
// Запросы «в полёте», по одному на имя. Слот занят ⇒ по этому имени уже
|
||
// работает резолвер-поток, и второго по нему мы не заводим (см. шапку юнита).
|
||
GResolvePending: array[0..DX_MAX_RESOLVERS - 1] of PDXResolveReq;
|
||
{$IFDEF MSWINDOWS}
|
||
GWSAData: TWSAData;
|
||
{$ENDIF}
|
||
|
||
function DXStateName(St: TDXClusterState): string;
|
||
begin
|
||
case St of
|
||
dxsOff: Result := 'OFF';
|
||
dxsConnecting: Result := 'CONNECTING';
|
||
dxsLogin: Result := 'LOGIN';
|
||
dxsOnline: Result := 'ONLINE';
|
||
dxsRetry: Result := 'RETRY';
|
||
else Result := 'ERROR';
|
||
end;
|
||
end;
|
||
|
||
function SockWouldBlock: Boolean;
|
||
// Последняя ошибка сокета означает «данных пока нет / истёк SO_RCVTIMEO /
|
||
// прерван сигналом» — соединение живо. Всё остальное (ECONNRESET, ENOTCONN,
|
||
// EPIPE…) — разрыв: без этой проверки поток крутил бы recv в пустом цикле,
|
||
// вместо того чтобы уйти на реконнект.
|
||
var E: Integer;
|
||
begin
|
||
{$IFDEF MSWINDOWS}
|
||
E := WSAGetLastError;
|
||
Result := (E = WSAEWOULDBLOCK) or (E = WSAETIMEDOUT) or (E = WSAEINTR);
|
||
{$ELSE}
|
||
E := fpgeterrno;
|
||
Result := (E = ESysEAGAIN) or (E = ESysEWOULDBLOCK) or (E = ESysEINTR);
|
||
{$ENDIF}
|
||
end;
|
||
|
||
{ ── Разбор строки спота ──────────────────────────────────────────────────── }
|
||
|
||
function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
|
||
// 'DX de <spotter>: <кГц> <позывной> <комментарий> <HHMM>Z [грид]'
|
||
var
|
||
S, Rest, FreqStr, TimeTok: string;
|
||
P, i: Integer;
|
||
KHz: Double;
|
||
FS: TFormatSettings;
|
||
begin
|
||
Result := False;
|
||
FillChar(Spot, SizeOf(Spot), 0);
|
||
Spot.Call := ''; Spot.Spotter := ''; Spot.Comment := ''; Spot.TimeUTC := '';
|
||
|
||
S := Trim(Line);
|
||
if Length(S) < 12 then Exit;
|
||
if not SameText(Copy(S, 1, 6), 'DX de ') then Exit;
|
||
|
||
Rest := Copy(S, 7, MaxInt);
|
||
P := Pos(':', Rest);
|
||
if P <= 1 then Exit;
|
||
Spot.Spotter := Trim(Copy(Rest, 1, P - 1));
|
||
Rest := Trim(Copy(Rest, P + 1, MaxInt));
|
||
|
||
// частота (кГц) — первый токен
|
||
P := Pos(' ', Rest);
|
||
if P <= 1 then Exit;
|
||
FreqStr := Copy(Rest, 1, P - 1);
|
||
Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));
|
||
|
||
FS := DefaultFormatSettings;
|
||
FS.DecimalSeparator := '.';
|
||
FreqStr := StringReplace(FreqStr, ',', '.', [rfReplaceAll]);
|
||
if not TryStrToFloat(FreqStr, KHz, FS) then Exit;
|
||
if (KHz <= 0) or (KHz > 100000000.0) then Exit; // мусор/битая строка
|
||
Spot.FreqHz := KHz * 1000.0;
|
||
|
||
// позывной DX — второй токен
|
||
P := Pos(' ', Rest);
|
||
if P > 0 then
|
||
begin
|
||
Spot.Call := Copy(Rest, 1, P - 1);
|
||
Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));
|
||
end
|
||
else
|
||
begin
|
||
Spot.Call := Rest;
|
||
Rest := '';
|
||
end;
|
||
if Spot.Call = '' then Exit;
|
||
|
||
// Время: последний токен вида HHMMZ. Всё до него — комментарий, всё после
|
||
// (обычно грид спотера) приклеиваем к комментарию — терять жалко.
|
||
Rest := TrimRight(Rest);
|
||
P := 0;
|
||
for i := Length(Rest) - 4 downto 1 do
|
||
if (Rest[i] in ['0'..'9']) and (Rest[i+1] in ['0'..'9']) and
|
||
(Rest[i+2] in ['0'..'9']) and (Rest[i+3] in ['0'..'9']) and
|
||
(UpCase(Rest[i+4]) = 'Z') and
|
||
((i = 1) or (Rest[i-1] = ' ')) then
|
||
begin
|
||
P := i;
|
||
Break;
|
||
end;
|
||
if P > 0 then
|
||
begin
|
||
TimeTok := Copy(Rest, P, 4);
|
||
Spot.TimeUTC := TimeTok;
|
||
Spot.Comment := Trim(Copy(Rest, 1, P - 1));
|
||
Rest := Trim(Copy(Rest, P + 5, MaxInt));
|
||
if Rest <> '' then Spot.Comment := Trim(Spot.Comment + ' ' + Rest);
|
||
end
|
||
else
|
||
Spot.Comment := Trim(Rest);
|
||
|
||
Spot.Mode := DXModeFromComment(Spot.Comment);
|
||
// Комментарий молчит — берём моду с участка бэндплана: в CW и SSB её сплошь
|
||
// и рядом просто не пишут, а участок эти две моды разделяет надёжно.
|
||
// Помечаем такой спот: угаданное не должно выглядеть как сказанное спотером.
|
||
Spot.ModeGuessed := False;
|
||
if Spot.Mode = dxmUnknown then
|
||
begin
|
||
Spot.Mode := DXModeFromFreq(Spot.FreqHz);
|
||
Spot.ModeGuessed := Spot.Mode <> dxmUnknown;
|
||
end;
|
||
Spot.Stamp := Now;
|
||
Result := True;
|
||
end;
|
||
|
||
{ ── TDXClusterClient ─────────────────────────────────────────────────────── }
|
||
|
||
constructor TDXClusterClient.Create(AStore: TDXSpotStore);
|
||
begin
|
||
inherited Create;
|
||
FStore := AStore;
|
||
FLock := TCriticalSection.Create;
|
||
FStopEvent := TEvent.Create(nil, True, False, '');
|
||
FOutQueue := TStringList.Create;
|
||
FHost := DX_DEFAULT_HOST;
|
||
FPort := DX_DEFAULT_PORT;
|
||
FState := dxsOff;
|
||
FLogHead := 0;
|
||
FLogCount := 0;
|
||
FLogVer := 0;
|
||
FSpotCount := 0;
|
||
FActiveSock := SOCK_INVALID;
|
||
// WSAStartup/WSACleanup здесь БЫЛИ и убраны — см. initialization юнита.
|
||
end;
|
||
|
||
destructor TDXClusterClient.Destroy;
|
||
begin
|
||
Stop;
|
||
FOutQueue.Free;
|
||
FStopEvent.Free;
|
||
FLock.Free;
|
||
inherited Destroy;
|
||
end;
|
||
|
||
procedure TDXClusterClient.Configure(const AHost: string; APort: Integer;
|
||
const ALogin, APassword, APostLogin: string);
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
// Смена адреса/учётки — команды, набранные для ПРЕЖНЕГО кластера, теряют
|
||
// смысл (и ушли бы на новый). Чистим очередь.
|
||
if (FHost <> Trim(AHost)) or (FPort <> APort) or (FLogin <> Trim(ALogin)) then
|
||
FOutQueue.Clear;
|
||
FHost := Trim(AHost);
|
||
FPort := APort;
|
||
FLogin := Trim(ALogin);
|
||
FPassword := APassword;
|
||
FPostLogin := APostLogin;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterClient.Start;
|
||
begin
|
||
// Поток прошлой сессии мог завершиться сам (кластер отверг логин) — прибираем
|
||
// его, иначе Start молча ничего бы не сделал и кнопка CONNECT в окне
|
||
// кластера перестала бы работать до перезапуска программы.
|
||
if (FThread <> nil) and FThread.Finished then
|
||
begin
|
||
FThread.WaitFor; // уже завершён — не ждёт
|
||
FThread.Free;
|
||
FThread := nil;
|
||
end;
|
||
if FThread <> nil then Exit;
|
||
FLock.Enter;
|
||
try
|
||
if (FHost = '') or (FLogin = '') then
|
||
begin
|
||
FState := dxsError;
|
||
FStatusMsg := 'host or callsign not set';
|
||
Exit;
|
||
end;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
FStopEvent.ResetEvent;
|
||
SetState(dxsConnecting, '');
|
||
FThread := TDXClusterThread.Create(Self);
|
||
end;
|
||
|
||
procedure TDXClusterClient.RequestStop;
|
||
begin
|
||
// Всё, что не успели отправить, к следующему соединению уже неактуально.
|
||
FLock.Enter;
|
||
try
|
||
FOutQueue.Clear;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
if FThread = nil then Exit;
|
||
FThread.Terminate;
|
||
FStopEvent.SetEvent;
|
||
// Терминации мало: поток стоит в блокирующем recv и проснулся бы только по
|
||
// SO_RCVTIMEO (до DX_RECV_MS), а на застрявшем send — до SO_SNDTIMEO. Всё это
|
||
// время join держал бы главный поток, т.е. окно висело бы на выходе и на
|
||
// переподключении после правки настроек. SockShutdown рвёт обе стороны
|
||
// немедленно (на Linux одного close мало — см. WebUtils.SockShutdown).
|
||
// Закрывает сокет по-прежнему сам поток: закрыть чужой хэндл значило бы
|
||
// гонку с его же recv.
|
||
FLock.Enter;
|
||
try
|
||
if FActiveSock <> SOCK_INVALID then SockShutdown(FActiveSock);
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterClient.Stop;
|
||
var T: TThread;
|
||
begin
|
||
RequestStop;
|
||
T := FThread;
|
||
if T = nil then
|
||
begin
|
||
SetState(dxsOff, '');
|
||
Exit;
|
||
end;
|
||
FThread := nil;
|
||
T.WaitFor;
|
||
T.Free;
|
||
SetState(dxsOff, '');
|
||
end;
|
||
|
||
function TDXClusterClient.Running: Boolean;
|
||
// Завершившийся сам поток (отказ авторизации) — уже не «работаем»: иначе окно
|
||
// кластера показывало бы DISCONNECT у мёртвого соединения.
|
||
begin
|
||
Result := (FThread <> nil) and (not FThread.Finished);
|
||
end;
|
||
|
||
procedure TDXClusterClient.PublishSock(S: TSocket);
|
||
// Зеркало хэндла для Stop. Поток зовёт с валидным хэндлом сразу после connect
|
||
// и с SOCK_INVALID ПЕРЕД close — так Stop под локом видит либо живой сокет,
|
||
// либо ничего, но не закрытый.
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
FActiveSock := S;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterClient.SetState(St: TDXClusterState; const Msg: string);
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
FState := St;
|
||
FStatusMsg := Msg;
|
||
Inc(FLogVer); // UI перечитает и статус тоже
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterClient.State: TDXClusterState;
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
Result := FState;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterClient.StateText: string;
|
||
begin
|
||
Result := DXStateName(State);
|
||
end;
|
||
|
||
function TDXClusterClient.StatusMessage: string;
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
Result := FStatusMsg;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterClient.SpotsReceived: Int64;
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
Result := FSpotCount;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterClient.SendCommand(const S: string);
|
||
var Queued: Boolean;
|
||
begin
|
||
if Trim(S) = '' then Exit;
|
||
FLock.Enter;
|
||
try
|
||
FOutQueue.Add(S);
|
||
Queued := FState <> dxsOnline;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
// До конца логина поток очередь не разбирает (иначе команда ушла бы вместо
|
||
// позывного или пароля). Молча «проглоченная» команда выглядела бы как
|
||
// потеря — говорим в лог, что она ждёт.
|
||
if Queued then AddLog('*** queued until login completes: ' + S);
|
||
end;
|
||
|
||
procedure TDXClusterClient.AddLog(const S: string);
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
FLog[FLogHead] := S;
|
||
FLogHead := (FLogHead + 1) mod DX_LOG_LINES;
|
||
if FLogCount < DX_LOG_LINES then Inc(FLogCount);
|
||
Inc(FLogVer);
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterClient.GetLog(Dst: TStrings);
|
||
var i, Start: Integer;
|
||
begin
|
||
if Dst = nil then Exit;
|
||
FLock.Enter;
|
||
try
|
||
Start := (FLogHead - FLogCount + DX_LOG_LINES) mod DX_LOG_LINES;
|
||
for i := 0 to FLogCount - 1 do
|
||
Dst.Add(FLog[(Start + i) mod DX_LOG_LINES]);
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterClient.LogVersion: Int64;
|
||
begin
|
||
FLock.Enter;
|
||
try
|
||
Result := FLogVer;
|
||
finally
|
||
FLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
{ ── TDXClusterThread ─────────────────────────────────────────────────────── }
|
||
|
||
constructor TDXClusterThread.Create(AOwner: TDXClusterClient);
|
||
begin
|
||
FOwner := AOwner;
|
||
FSocket := SOCK_INVALID;
|
||
FreeOnTerminate := False;
|
||
inherited Create(False);
|
||
end;
|
||
|
||
{ ── Резолв имени ─────────────────────────────────────────────────────────── }
|
||
|
||
{$IFNDEF MSWINDOWS}
|
||
// getaddrinfo(3) объявляем сами: в RTL его нет (netdb использует его только
|
||
// под FPC_USE_LIBC, а этой сборки у нас нет), а тащить ради него устаревший
|
||
// пакет libc не хочется. Порядок полей структуры фиксирован ABI; BSD/macOS
|
||
// меняет местами ai_canonname и ai_addr — единственное расхождение.
|
||
{$PACKRECORDS C}
|
||
type
|
||
PCAddrInfo = ^TCAddrInfo;
|
||
TCAddrInfo = record
|
||
ai_flags: cint;
|
||
ai_family: cint;
|
||
ai_socktype: cint;
|
||
ai_protocol: cint;
|
||
ai_addrlen: cuint32;
|
||
{$IFDEF DARWIN}
|
||
ai_canonname: PChar;
|
||
ai_addr: Pointer;
|
||
{$ELSE}
|
||
ai_addr: Pointer;
|
||
ai_canonname: PChar;
|
||
{$ENDIF}
|
||
ai_next: PCAddrInfo;
|
||
end;
|
||
|
||
PSockAddrIn4 = ^TInetSockAddr;
|
||
{$PACKRECORDS DEFAULT}
|
||
|
||
function c_getaddrinfo(node, service: PChar; hints: PCAddrInfo;
|
||
out res: PCAddrInfo): cint; cdecl; external 'c' name 'getaddrinfo';
|
||
procedure c_freeaddrinfo(res: PCAddrInfo); cdecl; external 'c' name 'freeaddrinfo';
|
||
{$ENDIF}
|
||
|
||
function TryLiteralIP(const Host: string; out Addr: LongWord): Boolean;
|
||
// Литеральный адрес разбирается БЕЗ резолвера — строкой, мгновенно. Отдельной
|
||
// функцией он нужен для того, чтобы вводом IP всегда можно было выбраться из
|
||
// повисшего системного резолвера: ждать очереди в реестре запросов ради
|
||
// «127.0.0.1» было бы издевательством. Возвращает СЕТЕВОЙ порядок байт.
|
||
{$IFDEF MSWINDOWS}
|
||
var L: LongWord;
|
||
begin
|
||
Addr := 0;
|
||
L := inet_addr(PChar(Host));
|
||
Result := L <> INADDR_NONE; // inet_addr уже в сетевом порядке
|
||
if Result then Addr := L;
|
||
end;
|
||
{$ELSE}
|
||
var HA: THostAddr;
|
||
begin
|
||
Addr := 0;
|
||
// TryStrToHostAddr отдаёт ХОСТОВЫЙ порядок — разворачиваем.
|
||
Result := TryStrToHostAddr(Host, HA);
|
||
if Result then Addr := htonl(HA.s_addr);
|
||
end;
|
||
{$ENDIF}
|
||
|
||
function ResolveHostSync(const Host: string; out Addr: LongWord): Boolean;
|
||
// Блокирующий системный резолв. Возвращает адрес в СЕТЕВОМ порядке байт
|
||
// (готов для sin_addr). Зовётся ТОЛЬКО из TDXResolverThread — прервать его
|
||
// нечем, поэтому ждать его результат напрямую нельзя (см. шапку юнита).
|
||
{$IFNDEF MSWINDOWS}
|
||
var Hints: TCAddrInfo; Res, AI: PCAddrInfo;
|
||
{$ELSE}
|
||
var PH: PHostEnt;
|
||
{$ENDIF}
|
||
begin
|
||
Result := False;
|
||
Addr := 0;
|
||
if Host = '' then Exit;
|
||
if TryLiteralIP(Host, Addr) then Exit(True);
|
||
{$IFDEF MSWINDOWS}
|
||
PH := gethostbyname(PChar(Host));
|
||
if (PH = nil) or (PH^.h_addr_list = nil) or (PH^.h_addr_list^ = nil) then Exit;
|
||
Move(PH^.h_addr_list^^, Addr, 4); // hostent отдаёт сетевой порядок
|
||
Result := True;
|
||
{$ELSE}
|
||
// Системный путь: getaddrinfo идёт через NSS ⇒ видит /etc/hosts, mDNS,
|
||
// systemd-resolved — всё, что настроено у пользователя. Адрес в ai_addr уже
|
||
// в сетевом порядке, второй раз не вертим.
|
||
FillChar(Hints, SizeOf(Hints), 0);
|
||
Hints.ai_family := AF_INET; // сокет мы поднимаем IPv4
|
||
Hints.ai_socktype := SOCK_STREAM;
|
||
Res := nil;
|
||
if (c_getaddrinfo(PChar(Host), nil, @Hints, Res) <> 0) or (Res = nil) then Exit;
|
||
try
|
||
AI := Res;
|
||
while AI <> nil do
|
||
begin
|
||
if (AI^.ai_family = AF_INET) and (AI^.ai_addr <> nil) then
|
||
begin
|
||
Addr := PSockAddrIn4(AI^.ai_addr)^.sin_addr.s_addr;
|
||
Exit(True);
|
||
end;
|
||
AI := AI^.ai_next;
|
||
end;
|
||
finally
|
||
c_freeaddrinfo(Res);
|
||
end;
|
||
{$ENDIF}
|
||
end;
|
||
|
||
procedure ReleaseResolveLocked(Req: PDXResolveReq);
|
||
// Зовётся ТОЛЬКО под GResolveLock. Последний ушедший гасит свет.
|
||
begin
|
||
Dec(Req^.Refs);
|
||
if Req^.Refs <= 0 then Dispose(Req);
|
||
end;
|
||
|
||
procedure UnlistResolveLocked(Req: PDXResolveReq);
|
||
// Снять запрос с реестра «в полёте». Только под GResolveLock.
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to High(GResolvePending) do
|
||
if GResolvePending[i] = Req then GResolvePending[i] := nil;
|
||
end;
|
||
|
||
function AcquireResolve(const Host: string;
|
||
out Req: PDXResolveReq): TDXResolveOutcome;
|
||
// droOk — Req наш (ссылку обязательно вернуть через ReleaseResolveLocked).
|
||
// droBusy — все слоты заняты ЗАВИСШИМИ резолвами ЧУЖИХ имён.
|
||
// droFailed — поток не создался.
|
||
var i, Slot: Integer;
|
||
begin
|
||
Result := droBusy;
|
||
Req := nil;
|
||
GResolveLock.Enter;
|
||
try
|
||
Slot := -1;
|
||
for i := 0 to High(GResolvePending) do
|
||
if GResolvePending[i] = nil then
|
||
begin
|
||
if Slot < 0 then Slot := i;
|
||
end
|
||
else if GResolvePending[i]^.Host = Host then
|
||
begin
|
||
// Тот же хост — цепляемся к уже летящему запросу вместо второго потока.
|
||
// Именно это и держит число резолверов конечным, когда системный
|
||
// резолвер завис: КАЖДАЯ следующая попытка ждёт ТОТ ЖЕ запрос.
|
||
Req := GResolvePending[i];
|
||
Inc(Req^.Refs);
|
||
Exit(droOk);
|
||
end;
|
||
// Слотов несколько, а не один, ради простой вещи: сменив в SETUP адрес
|
||
// кластера, пользователь обязан получить попытку по НОВОМУ имени даже если
|
||
// предыдущее намертво зависло в системном резолвере. Потолок оставлен,
|
||
// чтобы такие «вечные» потоки не копились без счёта.
|
||
if Slot < 0 then Exit;
|
||
New(Req);
|
||
Req^.Host := Host;
|
||
Req^.Addr := 0;
|
||
Req^.Ok := False;
|
||
Req^.Done := False;
|
||
Req^.Refs := 2; // резолвер + мы
|
||
GResolvePending[Slot] := Req;
|
||
try
|
||
TDXResolverThread.Create(Req);
|
||
except
|
||
GResolvePending[Slot] := nil; // поток не родился — запись ничья
|
||
Dispose(Req);
|
||
Req := nil;
|
||
Exit(droFailed);
|
||
end;
|
||
Result := droOk;
|
||
finally
|
||
GResolveLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
constructor TDXResolverThread.Create(AReq: PDXResolveReq);
|
||
begin
|
||
FReq := AReq;
|
||
FreeOnTerminate := True; // сессия его не ждёт и не освобождает
|
||
inherited Create(False);
|
||
end;
|
||
|
||
procedure TDXResolverThread.Execute;
|
||
var
|
||
A: LongWord;
|
||
Ok: Boolean;
|
||
begin
|
||
A := 0;
|
||
Ok := False;
|
||
try
|
||
// Host записан до старта потока и больше никем не трогается — без лока.
|
||
Ok := ResolveHostSync(FReq^.Host, A);
|
||
except
|
||
// ★Любое исключение здесь обязано ЗАВЕРШИТЬСЯ снятием запроса с реестра.
|
||
// Иначе слот остался бы занят навсегда: то же имя давало бы вечные
|
||
// таймауты (Done никогда не выставится), а остальные — вечный DNS busy.
|
||
// TThread исключение проглатывает (кладёт в FatalException), так что без
|
||
// этого except мы бы даже не заметили.
|
||
Ok := False;
|
||
end;
|
||
GResolveLock.Enter;
|
||
try
|
||
FReq^.Addr := A;
|
||
FReq^.Ok := Ok;
|
||
FReq^.Done := True;
|
||
// Снимаемся с «в полёте»: следующая попытка начнёт свежий резолв, а этот
|
||
// результат достанется лишь тому, кто его уже ждёт.
|
||
UnlistResolveLocked(FReq);
|
||
ReleaseResolveLocked(FReq); // наша ссылка
|
||
finally
|
||
GResolveLock.Leave;
|
||
end;
|
||
FReq := nil;
|
||
end;
|
||
|
||
function TDXClusterThread.Resolve(const Host: string;
|
||
out Addr: LongWord): TDXResolveOutcome;
|
||
// Ждём резолвер квантами, чтобы Stop/Terminate не упирались в системный
|
||
// DNS-таймаут. Не дождались — ссылку отдаём, но сам запрос остаётся в реестре:
|
||
// следующая попытка подождёт его же, а не заведёт второй вечный поток.
|
||
var
|
||
Req: PDXResolveReq;
|
||
Waited: Integer;
|
||
Ready: Boolean;
|
||
begin
|
||
Addr := 0;
|
||
Result := droFailed;
|
||
if Host = '' then Exit;
|
||
// ★Литеральный адрес разбираем ЗДЕСЬ, до всякого реестра: иначе, когда
|
||
// системный резолвер завис, ввод IP руками — единственный способ выбраться —
|
||
// упирался бы в чужой занятый слот ровно так же, как имя.
|
||
if TryLiteralIP(Host, Addr) then Exit(droOk);
|
||
|
||
Result := AcquireResolve(Host, Req);
|
||
if Result <> droOk then Exit; // все слоты заняты / поток не создался
|
||
Result := droFailed; // дальше исход решает ожидание ниже
|
||
|
||
try
|
||
Waited := 0;
|
||
while not Terminated do
|
||
begin
|
||
GResolveLock.Enter;
|
||
try
|
||
Ready := Req^.Done;
|
||
finally
|
||
GResolveLock.Leave;
|
||
end;
|
||
if Ready or (Waited >= DX_DNS_MS) then Break;
|
||
// Ждём на FStopEvent, а не Sleep: Stop будит нас немедленно.
|
||
if FOwner.FStopEvent.WaitFor(DX_DNS_SLICE_MS) = wrSignaled then Break;
|
||
Inc(Waited, DX_DNS_SLICE_MS);
|
||
end;
|
||
finally
|
||
// Ссылку отдаём при любом исходе, включая исключение в ожидании: иначе
|
||
// запись зависла бы с лишней ссылкой и не освободилась никогда.
|
||
GResolveLock.Enter;
|
||
try
|
||
if not Req^.Done then
|
||
Result := droTimeout
|
||
else if Req^.Ok then
|
||
begin
|
||
Addr := Req^.Addr;
|
||
Result := droOk;
|
||
end
|
||
else
|
||
Result := droFailed;
|
||
ReleaseResolveLocked(Req);
|
||
finally
|
||
GResolveLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
if Terminated then Result := droTimeout; // уходим — адрес уже не нужен
|
||
end;
|
||
|
||
function TDXClusterThread.ConnectSock: Boolean;
|
||
// Неблокирующий connect + select: мёртвый хост не держит поток минуту на
|
||
// системном таймауте TCP, и Stop() отрабатывает быстро.
|
||
var
|
||
Addr: {$IFDEF MSWINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
|
||
IP: LongWord;
|
||
R, SoErr: Integer;
|
||
FDS: TFDSet;
|
||
TV: TTimeVal;
|
||
One, Waited: Integer;
|
||
{$IFDEF MSWINDOWS}ErrLen: Integer;{$ELSE}ErrLen: TSockLen;{$ENDIF}
|
||
begin
|
||
Result := False;
|
||
case Resolve(FHost, IP) of
|
||
droOk: ;
|
||
droBusy:
|
||
begin
|
||
FOwner.SetState(dxsRetry, 'DNS busy');
|
||
FOwner.AddLog('*** DNS: previous lookup still running — retrying later');
|
||
Exit;
|
||
end;
|
||
droTimeout:
|
||
begin
|
||
if Terminated then Exit; // это не отказ DNS, а наш же Stop
|
||
FOwner.SetState(dxsRetry, 'DNS timeout: ' + FHost);
|
||
FOwner.AddLog('*** DNS: no answer for ' + FHost);
|
||
Exit;
|
||
end;
|
||
else
|
||
if Terminated then Exit;
|
||
FOwner.SetState(dxsRetry, 'cannot resolve ' + FHost);
|
||
FOwner.AddLog('*** DNS: cannot resolve ' + FHost);
|
||
Exit;
|
||
end;
|
||
if Terminated then Exit;
|
||
|
||
{$IFDEF MSWINDOWS}
|
||
FSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||
{$ELSE}
|
||
FSocket := fpSocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||
{$ENDIF}
|
||
if FSocket = SOCK_INVALID then
|
||
begin
|
||
FOwner.SetState(dxsRetry, 'cannot create socket');
|
||
Exit;
|
||
end;
|
||
|
||
FillChar(Addr, SizeOf(Addr), 0);
|
||
Addr.sin_family := AF_INET;
|
||
Addr.sin_port := htons(FPort);
|
||
Addr.sin_addr.s_addr := IP;
|
||
|
||
SockSetNonBlock(FSocket, True);
|
||
{$IFDEF MSWINDOWS}
|
||
R := WinSock2.connect(FSocket, @Addr, SizeOf(Addr));
|
||
{$ELSE}
|
||
R := fpConnect(FSocket, @Addr, SizeOf(Addr));
|
||
{$ENDIF}
|
||
if R <> 0 then
|
||
begin
|
||
// ожидаемо: EINPROGRESS / WSAEWOULDBLOCK — ждём готовности на запись.
|
||
// Ждём НЕ одним select на 8 с, а квантами по DX_CONNECT_SLICE_MS: иначе
|
||
// Stop() (закрытие программы) висел бы до конца таймаута соединения.
|
||
Waited := 0;
|
||
R := 0;
|
||
while (Waited < DX_CONNECT_MS) and (not Terminated) do
|
||
begin
|
||
TV.tv_sec := DX_CONNECT_SLICE_MS div 1000;
|
||
TV.tv_usec := (DX_CONNECT_SLICE_MS mod 1000) * 1000;
|
||
{$IFDEF MSWINDOWS}
|
||
FD_ZERO(FDS);
|
||
FD_SET(FSocket, FDS);
|
||
R := select(0, nil, @FDS, nil, @TV);
|
||
{$ELSE}
|
||
fpFD_ZERO(FDS);
|
||
fpFD_SET(FSocket, FDS);
|
||
R := fpSelect(FSocket + 1, nil, @FDS, nil, @TV);
|
||
{$ENDIF}
|
||
if R <> 0 then Break; // готов или ошибка select
|
||
Inc(Waited, DX_CONNECT_SLICE_MS);
|
||
end;
|
||
if Terminated then
|
||
begin
|
||
CloseSock;
|
||
Exit;
|
||
end;
|
||
if R <= 0 then
|
||
begin
|
||
CloseSock;
|
||
FOwner.SetState(dxsRetry, 'connect timeout: ' + FHost);
|
||
FOwner.AddLog('*** no answer from ' + FHost + ':' + IntToStr(FPort));
|
||
Exit;
|
||
end;
|
||
SoErr := 0; ErrLen := SizeOf(SoErr);
|
||
{$IFDEF MSWINDOWS}
|
||
getsockopt(FSocket, SOL_SOCKET, SO_ERROR, PChar(@SoErr), ErrLen);
|
||
{$ELSE}
|
||
fpGetSockOpt(FSocket, SOL_SOCKET, SO_ERROR, @SoErr, @ErrLen);
|
||
{$ENDIF}
|
||
if SoErr <> 0 then
|
||
begin
|
||
CloseSock;
|
||
FOwner.SetState(dxsRetry, 'connection refused');
|
||
FOwner.AddLog('*** refused: ' + FHost + ':' + IntToStr(FPort));
|
||
Exit;
|
||
end;
|
||
end;
|
||
|
||
SockSetNonBlock(FSocket, False);
|
||
SockSetRcvTimeout(FSocket, DX_RECV_MS);
|
||
SockSetSndTimeout(FSocket, 5000);
|
||
One := 1;
|
||
{$IFDEF MSWINDOWS}
|
||
setsockopt(FSocket, SOL_SOCKET, SO_KEEPALIVE, PChar(@One), SizeOf(One));
|
||
{$ELSE}
|
||
fpSetSockOpt(FSocket, SOL_SOCKET, SO_KEEPALIVE, @One, SizeOf(One));
|
||
{$ENDIF}
|
||
FOwner.PublishSock(FSocket); // с этого момента Stop может рвать нас shutdown'ом
|
||
Result := True;
|
||
end;
|
||
|
||
procedure TDXClusterThread.CloseSock;
|
||
begin
|
||
if FSocket <> SOCK_INVALID then
|
||
begin
|
||
FOwner.PublishSock(SOCK_INVALID); // до close: Stop не должен увидеть мёртвый хэндл
|
||
SockShutdown(FSocket);
|
||
SockClose(FSocket);
|
||
FSocket := SOCK_INVALID;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterThread.SendAll(const Data: string): Boolean;
|
||
// TCP вправе отправить меньше запрошенного — дописываем остаток. Таймаут
|
||
// (SO_SNDTIMEO) и EINTR — не ошибка соединения, пробуем ещё, но не бесконечно.
|
||
var
|
||
Sent, N, Tries: Integer;
|
||
begin
|
||
Result := False;
|
||
if (FSocket = SOCK_INVALID) or (Data = '') then Exit;
|
||
Sent := 0;
|
||
Tries := 0;
|
||
while Sent < Length(Data) do
|
||
begin
|
||
if Terminated then Exit;
|
||
N := SockSend(FSocket, @Data[Sent + 1], Length(Data) - Sent, 0);
|
||
if N > 0 then
|
||
begin
|
||
Inc(Sent, N);
|
||
Tries := 0;
|
||
end
|
||
else
|
||
begin
|
||
if not SockWouldBlock then Exit; // настоящая ошибка сокета
|
||
Inc(Tries);
|
||
if Tries > DX_SEND_RETRIES then Exit; // не отдаёт буфер — считаем разрывом
|
||
end;
|
||
end;
|
||
Result := True;
|
||
end;
|
||
|
||
function TDXClusterThread.SendLine(const S: string): Boolean;
|
||
begin
|
||
Result := SendAll(S + #13#10);
|
||
if not Result then
|
||
FOwner.AddLog('*** send failed, dropping connection');
|
||
end;
|
||
|
||
procedure TDXClusterThread.FlushOutQueue;
|
||
var
|
||
Pending: TStringList;
|
||
i, j: Integer;
|
||
begin
|
||
Pending := nil;
|
||
FOwner.FLock.Enter;
|
||
try
|
||
if FOwner.FOutQueue.Count > 0 then
|
||
begin
|
||
Pending := TStringList.Create;
|
||
Pending.Assign(FOwner.FOutQueue);
|
||
FOwner.FOutQueue.Clear;
|
||
end;
|
||
finally
|
||
FOwner.FLock.Leave;
|
||
end;
|
||
if Pending = nil then Exit;
|
||
try
|
||
i := 0;
|
||
while i < Pending.Count do
|
||
begin
|
||
if not SendLine(Pending[i]) then Break; // сокет умер
|
||
FOwner.AddLog('> ' + Pending[i]);
|
||
Inc(i);
|
||
end;
|
||
// ★Не ушедшее (начиная с той самой команды) возвращаем в НАЧАЛО очереди:
|
||
// очередь выгребается целиком до отправки, и без возврата всё, до чего
|
||
// не дошли, просто пропадало бы — а обещано «отправим после реконнекта».
|
||
// В начало, потому что порядок для кластера значим (set/filter до sh/dx).
|
||
if i < Pending.Count then
|
||
begin
|
||
FOwner.FLock.Enter;
|
||
try
|
||
for j := Pending.Count - 1 downto i do
|
||
FOwner.FOutQueue.Insert(0, Pending[j]);
|
||
finally
|
||
FOwner.FLock.Leave;
|
||
end;
|
||
FOwner.AddLog(Format('*** %d command(s) held for the next connection',
|
||
[Pending.Count - i]));
|
||
end;
|
||
finally
|
||
Pending.Free;
|
||
end;
|
||
end;
|
||
|
||
function PromptEndsWith(const U, Key: string): Boolean;
|
||
// U (уже в нижнем регистре) заканчивается ПРИГЛАШЕНИЕМ вида '…password:'.
|
||
// Двоеточие в конце — то, что отличает вопрос кластера от упоминания слова в
|
||
// приветствии («you can set a password with set/password»): на таком упоминании
|
||
// вывод «пароль отвергнут» порвал бы живое соединение.
|
||
var S: string; P: Integer;
|
||
begin
|
||
Result := False;
|
||
S := TrimRight(U);
|
||
if S = '' then Exit;
|
||
if S[Length(S)] <> ':' then Exit;
|
||
// ★Ищем ПОСЛЕДНЕЕ вхождение: в 'invalid password, enter password:' первое
|
||
// стоит далеко от конца и по нему приглашение не опозналось бы вовсе.
|
||
P := RPos(Key, S);
|
||
// Ключ должен стоять у самого конца: '…password (again):' считаем тем же.
|
||
Result := (P > 0) and (Length(S) - (P + Length(Key) - 1) <= 10);
|
||
end;
|
||
|
||
function IsLoginPrompt(const U: string): Boolean;
|
||
// Строгая форма вопроса о позывном: 'login:', 'call:', 'callsign:', 'your
|
||
// call:'. Свободный поиск слова здесь не годится — по нему приветствие с
|
||
// упоминанием позывного сошло бы за повторный вопрос, т.е. за отказ.
|
||
begin
|
||
Result := PromptEndsWith(U, 'login') or PromptEndsWith(U, 'call');
|
||
end;
|
||
|
||
function LooksLikeAuthFailure(const U: string): Boolean;
|
||
// Типовые формулировки отказа. Список НАМЕРЕННО короткий и из точных фраз:
|
||
// проверяется он только в окне «учётка отправлена, логин ещё не завершён», но
|
||
// ложное срабатывание всё равно стоило бы рабочего соединения.
|
||
const
|
||
MARKS: array[0..6] of string = (
|
||
'invalid password', 'incorrect password', 'password incorrect',
|
||
'wrong password', 'login incorrect', 'access denied',
|
||
'authentication failed');
|
||
var i: Integer;
|
||
begin
|
||
Result := True;
|
||
for i := Low(MARKS) to High(MARKS) do
|
||
if Pos(MARKS[i], U) > 0 then Exit;
|
||
Result := False;
|
||
end;
|
||
|
||
procedure TDXClusterThread.FailAuth(const Reason: string);
|
||
// Отказ авторизации — не разрыв: реконнект с той же учёткой упрётся в то же
|
||
// самое. Гасим сессию совсем и говорим прямо; пользователь правит настройки, а
|
||
// это само поднимет клиент заново (ApplyDXConnection видит смену логина).
|
||
begin
|
||
if FFatal then Exit;
|
||
FFatal := True;
|
||
FOwner.AddLog('*** ' + Reason + ' — giving up, check callsign/password');
|
||
FOwner.SetState(dxsError, Reason);
|
||
end;
|
||
|
||
procedure TDXClusterThread.CheckPrompts(const Tail: string; IsTail: Boolean);
|
||
// Приглашения логина И пароля обычно приходят БЕЗ перевода строки, поэтому
|
||
// ищем их и в незавершённом хвосте буфера, а не только в целых строках.
|
||
// Флаги FLoginSent/FPassSent держат отправку однократной: хвост проверяется
|
||
// заново с каждым пришедшим куском.
|
||
// ★IsTail говорит лишь о том, ОТКУДА пришёл текст: незавершённый хвост или
|
||
// целая строка. Отвеченный хвост остаётся в буфере и со следующим куском доедет
|
||
// целой строкой — чтобы не счесть это эхо повторным вопросом (т.е. отказом),
|
||
// ответив по хвосту, мы запоминаем его в FAnsweredPrompt, и HandleLine гасит
|
||
// ровно ОДНУ следующую строку, если она совпала. Запрещать проверки для целых
|
||
// строк нельзя: приглашения вида 'Password:\r\n' — совершенно обычное дело.
|
||
var U: string;
|
||
begin
|
||
if (Tail = '') or FFatal then Exit;
|
||
U := LowerCase(Tail);
|
||
|
||
if not FLoginSent then
|
||
begin
|
||
if (Pos('login:', U) > 0) or (Pos('call:', U) > 0) or
|
||
(Pos('callsign', U) > 0) or (Pos('your call', U) > 0) or
|
||
(Pos('enter your', U) > 0) then
|
||
begin
|
||
if not SendLine(FLogin) then Exit;
|
||
FOwner.AddLog('> ' + FLogin);
|
||
if IsTail then FAnsweredPrompt := TrimRight(U);
|
||
FLoginSent := True;
|
||
FLoginByPrompt := True; // ответили на вопрос, а не выстрелили вслепую
|
||
FLoginAt := Now;
|
||
FLoginStepAt := FLoginAt;
|
||
FSawAfterLogin := False; // ждём ответ именно на позывной
|
||
FOwner.SetState(dxsLogin, '');
|
||
end;
|
||
Exit;
|
||
end;
|
||
|
||
// Кластер снова просит позывной. Если наш ушёл ПО ПРИГЛАШЕНИЮ — его не
|
||
// приняли, второй раз слать тот же нечего. Если же мы стреляли вслепую
|
||
// (приглашения не дождались за DX_LOGIN_GRACE_S, а кластер спросил только
|
||
// сейчас), то это не отказ, а опоздавший вопрос — отвечаем ещё раз, ровно
|
||
// один: после этого FLoginByPrompt уже True.
|
||
if IsLoginPrompt(U) then
|
||
begin
|
||
if FLoginByPrompt then
|
||
begin
|
||
FailAuth('callsign rejected');
|
||
Exit;
|
||
end;
|
||
if not SendLine(FLogin) then Exit;
|
||
FOwner.AddLog('> ' + FLogin + ' (prompt arrived late)');
|
||
if IsTail then FAnsweredPrompt := TrimRight(U);
|
||
FLoginByPrompt := True;
|
||
// ★FLoginAt тоже заново: от него считается ожидание приглашения ПАРОЛЯ, а
|
||
// ждать его надо от позывного, на который кластер реально отвечает. Оставив
|
||
// старую отметку (слепой выстрел), при поздно пришедшем приглашении мы бы
|
||
// пароля уже не ждали вовсе — и по первой же приветственной строке
|
||
// высыпали бы post-login прямо перед вопросом о пароле.
|
||
FLoginAt := Now;
|
||
FLoginStepAt := FLoginAt;
|
||
FSawAfterLogin := False;
|
||
Exit;
|
||
end;
|
||
|
||
// Приглашение пароля. И отправка, и вывод «не приняли» идут по ОДНОМУ И ТОМУ
|
||
// ЖЕ строгому детектору: по вхождению слова (как было) пароль улетел бы
|
||
// командой в кластер от одной лишь строки приветствия вида «you can set a
|
||
// password with set/password».
|
||
if PromptEndsWith(U, 'password') then
|
||
begin
|
||
// Спрашивают снова после того, как мы уже ответили, — не приняли.
|
||
if FPassSent then
|
||
begin
|
||
FailAuth('password rejected');
|
||
Exit;
|
||
end;
|
||
if FPassword = '' then
|
||
begin
|
||
FailAuth('cluster asks for a password, none configured');
|
||
Exit;
|
||
end;
|
||
if not SendLine(FPassword) then Exit;
|
||
FOwner.AddLog('> ********');
|
||
if IsTail then FAnsweredPrompt := TrimRight(U);
|
||
FPassSent := True;
|
||
FLoginStepAt := Now;
|
||
// Ответ на пароль ещё впереди: вопрос о пароле ответом не считается.
|
||
FSawAfterLogin := False;
|
||
end;
|
||
end;
|
||
|
||
function TDXClusterThread.LoginSettled: Boolean;
|
||
// Логин завершён, когда: позывной ушёл; пароль либо не нужен, либо отправлен,
|
||
// либо приглашения так и не было; сервер ОТВЕТИЛ хоть чем-то после нашей
|
||
// учётки (или молчит уже неприлично долго); и прошла пауза, за которую кластер
|
||
// успевает дожевать регистрацию. Раньше здесь стояли просто «две секунды после
|
||
// позывного» — при этом post-login команды и команды пользователя улетали в
|
||
// ещё не пройденный логин, ломая авторизацию (sh/dx вместо пароля).
|
||
var SinceLogin, SinceStep: Integer;
|
||
begin
|
||
Result := False;
|
||
if not FLoginSent then Exit;
|
||
SinceLogin := SecondsBetween(Now, FLoginAt);
|
||
SinceStep := SecondsBetween(Now, FLoginStepAt);
|
||
// Приглашение пароля ждём от отправки позывного…
|
||
if (FPassword <> '') and (not FPassSent) and (SinceLogin < DX_PASSWORD_WAIT_S) then Exit;
|
||
// …а молчание сервера считаем от ПОСЛЕДНЕЙ отправленной учётки: после пароля
|
||
// ждать ответ надо заново, иначе «принят ли пароль» никто не проверит.
|
||
if (not FSawAfterLogin) and (SinceStep < DX_LOGIN_MAX_S) then Exit;
|
||
Result := SinceStep >= DX_POSTLOGIN_S;
|
||
end;
|
||
|
||
function TDXClusterThread.SendPostLogin: Boolean;
|
||
// False — сокет умер на отправке. Это разрыв ВСЕЙ сессии: продолжать на мёртвом
|
||
// соединении значило бы объявить ONLINE и вычерпать очередь команд в никуда.
|
||
var
|
||
PL: TStringList;
|
||
i: Integer;
|
||
begin
|
||
Result := True;
|
||
if Trim(FPostLogin) = '' then Exit;
|
||
PL := TStringList.Create;
|
||
try
|
||
PL.Text := FPostLogin;
|
||
for i := 0 to PL.Count - 1 do
|
||
if Trim(PL[i]) <> '' then
|
||
begin
|
||
if not SendLine(Trim(PL[i])) then Exit(False);
|
||
FOwner.AddLog('> ' + Trim(PL[i]));
|
||
end;
|
||
finally
|
||
PL.Free;
|
||
end;
|
||
end;
|
||
|
||
procedure TDXClusterThread.HandleLine(const Line: string);
|
||
var
|
||
Spot: TDXSpot;
|
||
Echo: Boolean;
|
||
begin
|
||
if Line = '' then Exit;
|
||
FOwner.AddLog(Line);
|
||
|
||
// Эхо приглашения, на которое мы уже ответили по хвосту (см. CheckPrompts).
|
||
// Окно ровно на одну строку: следующая строка — уже настоящая новость, и
|
||
// такое же приглашение в ней будет означать, что нас переспрашивают.
|
||
if FAnsweredPrompt <> '' then
|
||
begin
|
||
Echo := TrimRight(LowerCase(Line)) = FAnsweredPrompt;
|
||
FAnsweredPrompt := '';
|
||
if Echo then Exit; // ответом сервера это НЕ считается
|
||
end;
|
||
|
||
// Отказ ищем до всего прочего и, главное, до отметки «сервер ответил»: иначе
|
||
// «Invalid password» само же и подтверждало бы успешный логин. Окно узкое —
|
||
// учётка ушла, логин не завершён; дальше в строках обычный трафик кластера,
|
||
// где такие слова бывают в чьём угодно комментарии к споту.
|
||
if FLoginSent and (not FPostSent) and LooksLikeAuthFailure(LowerCase(Line)) then
|
||
begin
|
||
FailAuth('cluster rejected login: ' + Trim(Line));
|
||
Exit;
|
||
end;
|
||
|
||
// Вот теперь это действительно ответ сервера по существу: не эхо нашего же
|
||
// приглашения и не отказ. Только такое и подтверждает, что логин продвинулся.
|
||
if FLoginSent then FSawAfterLogin := True;
|
||
|
||
if ParseDXSpot(Line, Spot) then
|
||
begin
|
||
FOwner.FStore.Add(Spot);
|
||
FOwner.FLock.Enter;
|
||
try
|
||
Inc(FOwner.FSpotCount);
|
||
finally
|
||
FOwner.FLock.Leave;
|
||
end;
|
||
if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
|
||
Exit;
|
||
end;
|
||
|
||
CheckPrompts(Line, False);
|
||
end;
|
||
|
||
procedure TDXClusterThread.HandleChunk(const Data: string);
|
||
var
|
||
P: Integer;
|
||
Line: string;
|
||
begin
|
||
FRxBuf := FRxBuf + Data;
|
||
while True do
|
||
begin
|
||
P := 1;
|
||
while (P <= Length(FRxBuf)) and not (FRxBuf[P] in [#10, #13]) do Inc(P);
|
||
if P > Length(FRxBuf) then Break; // перевода строки ещё нет
|
||
Line := Copy(FRxBuf, 1, P - 1);
|
||
// съедаем весь конец строки (CR, LF или CRLF)
|
||
while (P <= Length(FRxBuf)) and (FRxBuf[P] in [#10, #13]) do Inc(P);
|
||
Delete(FRxBuf, 1, P - 1);
|
||
HandleLine(TrimRight(Line));
|
||
end;
|
||
|
||
// Хвост без перевода строки — возможно, это приглашение логина или пароля…
|
||
if FRxBuf <> '' then
|
||
begin
|
||
// …а возможно, и сообщение об отказе: его тоже присылают без перевода
|
||
// строки, и без этой проверки оно ушло бы в «сервер ответил», т.е. в
|
||
// подтверждение логина.
|
||
if FLoginSent and (not FPostSent) and LooksLikeAuthFailure(LowerCase(FRxBuf)) then
|
||
FailAuth('cluster rejected login: ' + Trim(FRxBuf))
|
||
else
|
||
begin
|
||
CheckPrompts(FRxBuf, True);
|
||
// Хвост подтверждает логин, только если пережил разбор: не отказ, не
|
||
// приглашение, которое мы только что отвечали (там флаг сбрасывается), и
|
||
// не остаток уже отвеченного приглашения. Считать его ответом нужно —
|
||
// многие кластеры шлют приглашение сессии БЕЗ перевода строки, и без
|
||
// этого post-login ждал бы потолка молчания на ровном месте.
|
||
if FLoginSent and (not FFatal) and (FAnsweredPrompt = '') then
|
||
FSawAfterLogin := True;
|
||
end;
|
||
end;
|
||
// Защита от мусора без переводов строк.
|
||
if Length(FRxBuf) > 8192 then FRxBuf := '';
|
||
end;
|
||
|
||
function TDXClusterThread.Session: Boolean;
|
||
var
|
||
Buf: array[0..4095] of Byte;
|
||
N: Integer;
|
||
Chunk: string;
|
||
begin
|
||
Result := True;
|
||
FRxBuf := '';
|
||
FLoginSent := False;
|
||
FLoginByPrompt := False;
|
||
FPassSent := False;
|
||
FPostSent := False;
|
||
FSawAfterLogin := False;
|
||
FFatal := False;
|
||
FAnsweredPrompt := '';
|
||
FLoginAt := Now;
|
||
FLoginStepAt := Now;
|
||
FConnectAt := Now;
|
||
|
||
FOwner.SetState(dxsConnecting, FHost + ':' + IntToStr(FPort));
|
||
FOwner.AddLog('*** connecting to ' + FHost + ':' + IntToStr(FPort));
|
||
if not ConnectSock then Exit;
|
||
|
||
FOwner.AddLog('*** connected');
|
||
FOwner.SetState(dxsLogin, '');
|
||
|
||
while not Terminated do
|
||
begin
|
||
N := SockRecv(FSocket, @Buf[0], SizeOf(Buf), 0);
|
||
if N > 0 then
|
||
begin
|
||
SetLength(Chunk, N);
|
||
Move(Buf[0], Chunk[1], N);
|
||
// ★FSawAfterLogin здесь НЕ ставится. Приход байтов — факт транспортный, а
|
||
// не протокольный: отдельным пакетом может доехать один лишь '\r\n',
|
||
// закрывающий приглашение, на которое мы уже ответили, и «подтверждение
|
||
// логина» зависело бы от того, как TCP порезал поток. Флаг ставит
|
||
// HandleChunk/HandleLine — после разбора, по существу пришедшего.
|
||
HandleChunk(Chunk);
|
||
if FFatal then Break; // логин отвергнут — реконнект не поможет
|
||
end
|
||
else if N = 0 then
|
||
begin
|
||
FOwner.AddLog('*** connection closed by peer');
|
||
Break;
|
||
end
|
||
else if not SockWouldBlock then
|
||
begin
|
||
// Не таймаут кванта recv, а настоящая ошибка сокета — уходим на реконнект.
|
||
FOwner.AddLog('*** connection lost (socket error)');
|
||
Break;
|
||
end;
|
||
|
||
if Terminated then Break;
|
||
|
||
// Приглашения не дождались — шлём позывной сами (часть кластеров молчит).
|
||
if (not FLoginSent) and (SecondsBetween(Now, FConnectAt) >= DX_LOGIN_GRACE_S) then
|
||
begin
|
||
if not SendLine(FLogin) then Break; // сокет умер — на реконнект
|
||
FOwner.AddLog('> ' + FLogin + ' (no prompt seen)');
|
||
FLoginSent := True;
|
||
FLoginByPrompt := False; // вслепую: опоздавшее приглашение = не отказ
|
||
FLoginAt := Now;
|
||
FLoginStepAt := FLoginAt;
|
||
FSawAfterLogin := False; // ждём ответ именно на позывной
|
||
end;
|
||
|
||
// Post-login команды — один раз, когда логин действительно пройден.
|
||
if (not FPostSent) and LoginSettled then
|
||
begin
|
||
FPostSent := True;
|
||
if not SendPostLogin then Break; // сокет умер — вся сессия на реконнект
|
||
if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
|
||
end;
|
||
|
||
// Команды пользователя — только после логина: до него кластер ждёт позывной
|
||
// и пароль, и любая наша строка ушла бы вместо них. Очередь никуда не
|
||
// девается, отправим следом.
|
||
if FPostSent then FlushOutQueue;
|
||
end;
|
||
|
||
CloseSock;
|
||
Result := (not Terminated) and (not FFatal);
|
||
end;
|
||
|
||
procedure TDXClusterThread.Execute;
|
||
var
|
||
Backoff: Integer;
|
||
begin
|
||
Backoff := DX_RECONNECT_MIN;
|
||
// Снимок конфига на всю жизнь потока: Configure во время работы применяется
|
||
// следующим Start (так же ведёт себя веб-сервер при смене порта).
|
||
FOwner.FLock.Enter;
|
||
try
|
||
FHost := FOwner.FHost;
|
||
FPort := FOwner.FPort;
|
||
FLogin := FOwner.FLogin;
|
||
FPassword := FOwner.FPassword;
|
||
FPostLogin := FOwner.FPostLogin;
|
||
finally
|
||
FOwner.FLock.Leave;
|
||
end;
|
||
|
||
while not Terminated do
|
||
begin
|
||
if not Session then Break;
|
||
if Terminated then Break;
|
||
|
||
FOwner.SetState(dxsRetry, 'reconnecting in ' + IntToStr(Backoff) + ' s');
|
||
FOwner.AddLog('*** reconnecting in ' + IntToStr(Backoff) + ' s');
|
||
if FOwner.FStopEvent.WaitFor(Backoff * 1000) = wrSignaled then Break;
|
||
Backoff := Backoff * 2;
|
||
if Backoff > DX_RECONNECT_MAX then Backoff := DX_RECONNECT_MAX;
|
||
end;
|
||
CloseSock;
|
||
end;
|
||
|
||
initialization
|
||
GResolveLock := TCriticalSection.Create;
|
||
FillChar(GResolvePending, SizeOf(GResolvePending), 0);
|
||
{$IFDEF MSWINDOWS}
|
||
// ★Winsock поднимается ОДИН раз на процесс и НЕ выгружается. Раньше это
|
||
// делали конструктор и деструктор клиента — но Stop не ждёт (и не может
|
||
// ждать) резолвер-поток, а тот может в этот момент сидеть внутри
|
||
// gethostbyname: WSACleanup из-под него = обращение к выгруженному Winsock,
|
||
// то есть падение на выходе или при пересоздании клиента. Winsock освободит
|
||
// сам процесс.
|
||
WSAStartup($0202, GWSAData);
|
||
{$ENDIF}
|
||
|
||
// ★finalization НЕТ намеренно, по той же причине: брошенный резолвер может
|
||
// стоять в системном резолвере и после выхода из программы, а прервать его
|
||
// нечем. Освободив GResolveLock, мы дали бы ему упасть на Enter уже
|
||
// освобождённой секции; один живущий до конца процесса объект — не утечка.
|
||
|
||
end.
|