fix(dxcluster): резолв имени, гейт логина, TTL и мелочи UI по итогам ревизии

Сеть и резолв (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>
This commit is contained in:
2026-08-16 22:00:41 +03:00
co-authored by Claude Opus 5
parent eb4af61fad
commit 6ed1be3dc7
4 changed files with 692 additions and 85 deletions
+18 -6
View File
@@ -283,12 +283,22 @@ procedure TDXClusterForm.RefreshSpots;
var
i, j, Keep: Integer;
T: TDXSpot;
Sel: string;
SelCall: string;
SelFreq: Double;
begin
if FStore = nil then Exit;
// Что выделено — запоминаем позывным И частотой: стор намеренно держит один
// позывной на разных диапазонах, и по одному позывному выделение после
// обновления перескочило бы на первый совпавший, а Enter/двойной клик увёл бы
// радио не на тот диапазон.
Keep := FList.ItemIndex;
Sel := '';
if (Keep >= 0) and (Keep < Length(FSpots)) then Sel := FSpots[Keep].Call;
SelCall := '';
SelFreq := 0;
if (Keep >= 0) and (Keep < Length(FSpots)) then
begin
SelCall := FSpots[Keep].Call;
SelFreq := FSpots[Keep].FreqHz;
end;
FStore.Snapshot(FSpots); // приходит отсортированным по частоте
if not FSortFreq then
@@ -314,10 +324,12 @@ begin
FList.Items.EndUpdate;
end;
// Держим выделение на том же позывном, если он ещё в списке.
if Sel <> '' then
// Держим выделение на том же споте, если он ещё в списке. Допуск по частоте —
// тот же, что у дедупа стора: спот того же позывного мог чуть подвинуться.
if SelCall <> '' then
for i := 0 to High(FSpots) do
if SameText(FSpots[i].Call, Sel) then
if SameText(FSpots[i].Call, SelCall) and
(Abs(FSpots[i].FreqHz - SelFreq) <= DX_DEDUP_HZ) then
begin
FList.ItemIndex := i;
Break;