chore(tx): детектор голодания снят — всплесков не видно, вернём при нужде

Прибор из b173b7f отработал своё на этом заходе: всплески не появляются, и
держать его в тракте нет причин. Снято ровно то, что тот коммит поставил:
юнит TxHealth.pas, замеры простоев в отправителе DUC (обе ветки), публикация
контекста пейсинга из планировщика TCI, счёт бита HPS_FIFO_EMPTY, вызовы
TxHealthService в таймере UI и цикле демона, индикатор «·сухо N» в статусной
строке и секция G стенда.

Вернуть — реверсом этого коммита; опора детектора и правило его устройства
(в тракте передачи ничего, кроме записи в память) записаны в
test/hpsdr/README.md, чтобы не восстанавливать рассуждение заново.

Проверено: test/tci 262/262, test/cat 50/50, GUI (--ws=qt6) и демон собираются.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bkwwyj7xVRrqnSVEseTRfV
This commit is contained in:
2026-08-24 19:25:57 +03:00
co-authored by Claude Opus 5
parent b173b7f542
commit 867da5921e
8 changed files with 30 additions and 553 deletions
+20 -43
View File
@@ -105,54 +105,31 @@ TX голодала, в эфире дыры. Тут же диапазон глу
* `capture.sh` — кольцевой съём tcpdump + лог потерь ядра.
* `pcap_tx_scan.py` — разбор pcap, чистый Python без зависимостей (~40 МБ/с).
## Тихий детектор голодания очереди TX (включён всегда)
## Тихий детектор голодания очереди TX (снят, вернуть из `b173b7f`)
Всплеск, рождённый НАШИМ трактом, невозможен без одного события: очередь DUC
простаивает дольше подушки отправителя (`DUC_FIFO_THROTTLE` = 2000 отсчётов при
192 кГц = **10.42 мс**). Это доказано съёмом 2026-08-21: шесть всплесков шесть
простоев 10.3–23.5 мс, паузы 8 мс и короче не дали ни одного. Поэтому счётчик
таких простоев **полный** детектор, и он теперь живёт в самой программе
(`TxHealth.pas`), без переменных окружения: прибор, который надо не забыть
включить, к моменту дефекта выключен.
Опора детектора: всплеск, рождённый НАШИМ трактом, невозможен без простоя
очереди DUC длиннее подушки отправителя (`DUC_FIFO_THROTTLE` = 2000 отсчётов при
192 кГц = **10.42 мс**) съём 2026-08-21 дал шесть всплесков на шесть простоев
10.323.5 мс, а паузы 8 мс и короче не дали ни одного. Значит счётчик таких
простоев отвечает на вопрос «наш всплеск или не наш» полностью.
Что делает:
Прибор (`TxHealth.pas`) был включён всегда, без переменных окружения: поток
отправителя DUC мерил простои, планировщик TCI публиковал рядом контекст
пейсинга (аванс, долг, в полёте, квант, шаг, период пачек клиента), сетевой
поток считал бит `HPS_FIFO_EMPTY`. ★Журнал (`~/.config/ewsdr/txhealth.log`,
строка на передачу: «чисто» / «ОПАСНО (первое на N мс)» плюс подробности) писал
ТОЛЬКО потребитель — таймер UI и цикл демона; в тракте передачи диска не было по
построению. В статусной строке показывался счётчик `·сухо N` за сеанс.
* поток отправителя DUC мерит каждый простой очереди на передаче — одно чтение
монотонных часов на переход, ни файлов, ни локов;
* планировщик TCI публикует рядом состояние пейсинга (аванс, долг, в полёте,
квант, шаг, период пачек клиента) — простыми записями;
* сетевой поток считает биты «DUC FIFO пуст» из HP-статуса радио (поле ГЛУБИНЫ
на этой прошивке статично и веры ему нет, а бит раньше не читался вовсе);
* журнал пишет **потребитель** — таймер UI (100 мс) или цикл демона, никогда не
тракт передачи.
Вернуть целиком:
★Последнее — правило, оплаченное регрессом: прошлый прибор (`TxTrace`) сбрасывал
дамп синхронно в хвосте `SetMOX`, и эти десятки миллисекунд закрывали гонку в
пейсинге TCI. С зондами всплесков не было, без них они возвращались —
инструмент чинил дефект собой (лечение: коммит `fa38fa7`).
git revert <коммит снятия> # он же и есть реверс b173b7f
Журнал: `~/.config/ewsdr/txhealth.log`, по строке на передачу.
```
2026-08-24 19:08:03 ОПАСНО (первое на 100 мс) передача 12.4 с, источник TCI: осушений 17, опасных 1, самое длинное 18.0 мс, FIFO-EMPTY 0
опасный #1: на 100 мс от PTT, 18.0 мс, очередь 0, аванс 3584, долг 2200, в полёте 1024, квант 512, шаг 5333 мкс, пачка клиента 44.1 мс
```
Как читать:
* **`чисто`** — за эту передачу очередь ни разу не пересыхала дольше подушки.
Значит увиденный всплеск родился НЕ в нашем тракте: смотреть приёмный тракт
(дыра в DDC — на экране есть, в эфире нет), PA, реле, спуры.
* **`ОПАСНО`** — простой был, и подробная строка называет виновника по контексту:
пачка клиента длиннее аванса (`пачка клиента` > `аванс`/48 мс) — зернистость
MSHV; `долг` у потолка при малом `в полёте` — планировщик не успевает;
`шаг` заметно больше половины кванта — проспал поток планировщика.
* Строка статуса в UI показывает `·сухо N` — сколько опасных простоев набралось
за сеанс, чтобы не приходилось помнить, какая посылка была плохой.
Правило разбора то же, что и с pcap: **дальше сопоставлять**. Детектор говорит
«очередь сохла в такой-то момент», `pcap_tx_scan.py` по тому же куску времени
скажет, ушёл ли всплеск в эфир или это артефакт приёма.
★Правило, ради которого прибор так и построен: предыдущий инструмент
(`TxTrace`) сбрасывал дамп синхронно в хвосте `SetMOX` и этими десятками
миллисекунд закрывал гонку в пейсинге TCI — с зондами всплесков не было, без них
они возвращались (лечение гонки — `fa38fa7`). Инструмент не имеет права ничего
делать в тракте передачи.
## Приборы внутри процесса: кольцевая трассировка TX-тракта
+1 -79
View File
@@ -28,7 +28,7 @@ program tcitest;
uses
cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix,
TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController,
WsClient, WebUtils, WDSPEngine, Settings, CWMorse, TxHealth, PlatformUtils;
WsClient, WebUtils, WDSPEngine, Settings, CWMorse;
var
Passed, Failed: Integer;
@@ -2207,83 +2207,6 @@ begin
end;
end;
procedure TestTxHealth;
// Детектор голодания очереди TX (TxHealth.pas). Стенд проверяет его отдельно от
// сети: порог «опасного» простоя, сводку по передаче, контекст пейсинга рядом с
// событием и то, что вне передачи прибор молчит.
// ★Диска здесь нет намеренно: TxHealthService писал бы в НАСТОЯЩИЙ журнал
// пользователя, поэтому стенд берёт сводки через TxHealthTake.
var
Base: Int64;
S: TTxSession;
D: TTxDrought;
Was: Integer;
begin
WriteLn('G. Детектор голодания очереди TX');
// Сводки, накопленные прошлыми частями стенда (они гоняют SetMOX), забираем,
// чтобы мерить своё.
while TxHealthTake(S) do ;
Was := TxHealthDangerTotal;
// Вне передачи прибор обязан молчать: простой на приёме — это норма, DUC-поток
// тогда не шлёт вообще ничего.
Base := MonotonicUs;
TxHealthDrought(Base, Base + 50000, 0);
TxHealthFifoEmpty;
Check('детектор: вне передачи простой не считается',
TxHealthDangerTotal = Was, IntToStr(TxHealthDangerTotal - Was));
TxHealthPtt(True, txhSrcTCI);
TxHealthPublishTci(3584, 2200, 1024, 512, 5333, 44100);
Base := MonotonicUs;
// Короткий простой: подушка отправителя его покрывает, в эфир он не выходит.
TxHealthDrought(Base, Base + 8000, 7);
Check('детектор: простой короче подушки не опасен',
TxHealthDangerTotal = Was, IntToStr(TxHealthDangerTotal - Was));
// Длинный: FIFO радио сохнет, в эфире голая несущая.
TxHealthDrought(Base + 100000, Base + 118000, 0);
TxHealthFifoEmpty;
Check('детектор: простой длиннее подушки опасен',
TxHealthDangerTotal = Was + 1, IntToStr(TxHealthDangerTotal - Was));
TxHealthPtt(False, 0);
Check('детектор: сводка передачи снялась', TxHealthTake(S));
Check('детектор: простоев посчитано верно', S.Droughts = 2, IntToStr(S.Droughts));
Check('детектор: опасных посчитано верно', S.Dangerous = 1, IntToStr(S.Dangerous));
Check('детектор: самое длинное — длиннее подушки',
S.LongestUs = 18000, IntToStr(S.LongestUs));
Check('детектор: момент первого опасного — от фронта PTT',
(S.FirstDangerMs >= 90) and (S.FirstDangerMs <= 130),
IntToStr(S.FirstDangerMs));
Check('детектор: источник модуляции в сводке', S.Source = txhSrcTCI);
Check('детектор: жалоба радио (FIFO EMPTY) считается только на передаче',
S.FifoEmpty = 1, IntToStr(S.FifoEmpty));
// ★Ради этого прибор и делался: рядом с опасным простоем лежит состояние
// пейсинга — по нему видно, чей простой (аванса, зернистости клиента или
// планировщика), и оператору не нужно называть интервал на глаз.
Check('детектор: контекст пейсинга лежит рядом с событием',
TxHealthDetail(S.Id, 0, D));
Check('детектор: в контексте аванс и пачка клиента',
(D.Lead = 3584) and (D.GapUs = 44100) and (D.Quantum = 512) and
(D.LenUs = 18000),
Format('аванс %d, пачка %d, квант %d, простой %d',
[D.Lead, D.GapUs, D.Quantum, D.LenUs]));
Check('детектор: второго опасного в этой передаче нет',
not TxHealthDetail(S.Id, 1, D));
// Чистая передача: сводка обязана прийти с честным «опасных 0» — именно она и
// снимает подозрение с нашего тракта.
TxHealthPtt(True, txhSrcCard);
Base := MonotonicUs;
TxHealthDrought(Base, Base + 3000, 9);
TxHealthPtt(False, 0);
Check('детектор: чистая передача помечена как чистая',
TxHealthTake(S) and (S.Dangerous = 0) and (S.Droughts = 1) and
(S.FirstDangerMs < 0), Format('опасных %d', [S.Dangerous]));
end;
procedure TestEndToEnd;
const
PORT = 40098;
@@ -2734,7 +2657,6 @@ begin
TestServer;
TestEndToEnd;
TestTxPacing;
TestTxHealth;
WriteLn;
WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed]));
if Failed > 0 then Halt(1);