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 -2
View File
@@ -5168,9 +5168,16 @@ begin
if FDXPending and (MilliSecondsBetween(Now, FDXPendingAt) >= DX_SETTINGS_QUIET_MS) then
CommitDXClusterSettings;
if FDXSpotOverlay = nil then Exit;
if SecondsBetween(Now, FDXAgeTickAt) < DX_AGE_TICK_SEC then Exit;
FDXAgeTickAt := Now;
// TTL — свойство базы, а не картинки. Стор чистится только внутри Add и
// снимков, поэтому с выключенными подписями и молчащим (или отключённым)
// кластером просроченные споты не выбрасывал бы никто: новых Add нет, версия
// стора не меняется, окно списка снимок не берёт, тик оверлея выключен.
if FDXStore <> nil then FDXStore.Purge;
if FDXSpotOverlay = nil then Exit;
if not FDXSpotOverlay.Active then Exit;
// Тик получают оверлеи всех панов: возраст спота от пана не зависит.
for i := 0 to MAX_PANS - 1 do
@@ -5198,6 +5205,10 @@ begin
FDXCfg.ShowSpots := not FDXCfg.ShowSpots;
ApplyDXClusterSettings(FDXCfg);
FController.FSettings.SaveDXClusterSettings(FDXCfg);
// Правки SETUP ждут тишины в СВОЕЙ копии конфига. Не поправив её, отложенный
// коммит записал бы старый FDXPendingCfg целиком и молча вернул прежнее
// состояние тумблера — на экране новое, в файле старое.
if FDXPending then FDXPendingCfg.ShowSpots := FDXCfg.ShowSpots;
// Открытый SETUP держит СВОЮ копию конфига: без синхронизации галка «Show
// spots» осталась бы старой, и первая же правка любого поля на этой странице
// вернула бы подписи обратно.
@@ -5240,10 +5251,15 @@ end;
procedure TMainForm.OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
// QSY по споту: клик по подписи на главном пане или двойной клик в окне списка.
// Крутим ТОТ VFO, который сейчас активен, — ровно как обычный клик по спектру
// (DoSpectrumClick): иначе при активном B спот уводил бы неактивный A.
var NewMode: Integer;
begin
if FreqHz <= 0 then Exit;
ApplyVfoA(Round(FreqHz));
if FController.FActiveVfo = 0 then
ApplyVfoA(Round(FreqHz))
else
FController.SetVfoB(Round(FreqHz)); // рендер через OnControllerState(rfVfoB)
NewMode := DXSpotRadioMode(FreqHz, Mode);
if (NewMode >= 0) and (NewMode <> FController.FMode) then
FController.SetMode(NewMode);