feat(tx): тихий детектор голодания очереди TX

Всплеск, рождённый нашим трактом, невозможен без одного события: очередь DUC
простаивает дольше подушки отправителя (DUC_FIFO_THROTTLE = 2000 отсчётов при
192 кГц = 10.42 мс). Съём 2026-08-21 это показал прямо: шесть всплесков — шесть
простоев 10.3-23.5 мс, а паузы 8 мс и короче не дали ни одного. Значит счётчик
таких простоев — полный детектор «наш/не наш», и оператору больше не нужно
называть интервал на глаз.

Новый юнит TxHealth.pas. Включён всегда: переменной окружения нет намеренно —
прибор, который надо не забыть включить, к моменту дефекта выключен.

* поток отправителя DUC мерит каждый простой очереди на передаче — одно чтение
  монотонных часов на ПЕРЕХОД, ни файлов, ни локов (обе ветки, win и unix);
* планировщик TCI публикует рядом состояние пейсинга (аванс, долг, в полёте,
  квант, шаг, период пачек клиента) — простыми записями, без лока: числа
  диагностические, а лок в потоке отправителя DUC недопустим для тайминга;
* сетевой поток считает бит HPS_FIFO_EMPTY из HP-статуса радио — поле ГЛУБИНЫ
  FIFO на этой прошивке статично (256) и веры ему нет, а бит мы не читали вовсе;
* ★журнал пишет ТОЛЬКО потребитель — таймер UI (100 мс) и цикл демона через
  TxHealthService. В тракте передачи диска нет по построению.

Последнее — правило, оплаченное регрессом: прошлый прибор (TxTrace) сбрасывал
дамп синхронно в хвосте SetMOX, и эти десятки миллисекунд закрывали гонку в
пейсинге TCI (лечение — fa38fa7). Инструмент чинил дефект собой.

Журнал ~/.config/ewsdr/txhealth.log, строка на передачу: «чисто» либо «ОПАСНО
(первое на N мс)» плюс подробности с контекстом пейсинга. В статусной строке
UI — «·сухо N» за сеанс, чтобы не помнить, какая посылка была плохой.

Стенд: секция G в test/tci (порог подушки, сводка, момент от фронта PTT,
контекст рядом с событием, молчание вне передачи) — 276/276. Диск стенд не
трогает намеренно: берёт сводки через TxHealthTake/TxHealthDetail, мимо
TxHealthService, иначе писал бы в настоящий журнал пользователя.

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:23:13 +03:00
co-authored by Claude Opus 5
parent fa38fa7f75
commit b173b7f542
8 changed files with 559 additions and 10 deletions
+358
View File
@@ -0,0 +1,358 @@
unit TxHealth;
{
TxHealth.pas — тихий детектор голодания очереди TX.
ЗАЧЕМ. Всплеск своего сигнала, рождённый НАШИМ трактом, физически невозможен
без одного события: очередь DUC простаивает дольше подушки отправителя
(DUC_FIFO_THROTTLE = 2000 отсчётов при 192 кГц = 10.42 мс). Доказано pcap-
съёмом: шесть всплесков в дампе — шесть простоев 10.3-23.5 мс, а паузы 8 мс и
короче не дали ни одного. Значит счётчик таких простоев — ПОЛНЫЙ детектор:
молчит он — всплеск не наш (смотреть приёмный тракт, PA, реле, спуры),
сработал — наш, и момент известен с точностью до микросекунд.
★ГЛАВНОЕ ПРАВИЛО, оплаченное регрессом. Прошлый прибор (TxTrace) писал дамп
СИНХРОННО в хвосте SetMOX — десятки миллисекунд ровно в том окне, где жила
гонка FTxRunning, и тем самым ЧИНИЛ дефект собой: с зондами всплесков не было,
без них они возвращались. Поэтому здесь в тракте передачи не делается НИЧЕГО,
кроме записи в память:
* потоки передачи (отправитель DUC, сетевой поток статуса, планировщик TCI)
только пишут в кольцо и в счётчики — ни файлов, ни локов, ни форматирования;
* ВСЮ работу с диском делает ПОТРЕБИТЕЛЬ (таймер UI 100 мс / цикл демона),
вызывая TxHealthService. Своих потоков юнит не заводит.
Детектор включён ВСЕГДА: переменной окружения нет намеренно — прибор, который
надо не забыть включить, к моменту дефекта выключен.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
const
// Подушка отправителя: DUC_FIFO_THROTTLE = 2000 отсчётов при DUC_SAMPLE_RATE
// = 192000 (копия из HPSDRNetwork.pas — юнит намеренно ни от чего не зависит).
// Простой ДЛИННЕЕ неё гарантированно осушает FIFO радио: в эфире остаётся
// голая несущая гетеродина DUC.
TXH_CUSHION_US = 10417;
// Источник модуляции в сводке (что именно кормило передатчик).
txhSrcRadio = 0;
txhSrcCard = 1;
txhSrcWeb = 2;
txhSrcTCI = 3;
type
// Один опасный простой очереди — с контекстом пейсинга TCI на тот момент.
TTxDrought = record
Session: LongInt; // к какой передаче относится
AtMs: Integer; // от фронта PTT
LenUs: Integer; // длительность простоя
Queue: Integer; // глубина очереди DUC на выходе из простоя
Lead: Integer; // аванс, кадров (0 — модуляция не из TCI)
Owed: Integer; // долг, кадров
InFlight: Integer; // в полёте, кадров
Quantum: Integer; // квант запроса, кадров
StepUs: Integer; // шаг планировщика
GapUs: Integer; // период между пачками клиента
end;
// Сводка по одной передаче.
TTxSession = record
Id: LongInt;
StartedAt: TDateTime;
LenMs: Integer;
Source: Integer; // txhSrc*
Droughts: Integer; // простоев всего
Dangerous: Integer; // из них длиннее подушки
LongestUs: Integer;
FirstDangerMs: Integer; // -1 — опасных не было
FifoEmpty: Integer; // HP-статусов с битом «DUC FIFO пуст»
end;
// ── Пишут потоки передачи (только память) ───────────────────────────────────
procedure TxHealthPtt(On_: Boolean; Source: Integer);
procedure TxHealthDrought(FromUs, ToUs: Int64; QueueDepth: Integer);
procedure TxHealthFifoEmpty;
procedure TxHealthPublishTci(Lead, Owed, InFlight, Quantum, StepUs,
GapUs: Integer);
// ── Зовёт потребитель (UI-таймер / цикл демона) ─────────────────────────────
{ Забрать готовые сводки и дописать их в ~/.config/ewsdr/txhealth.log.
Возвращает число записанных передач. Работа с диском — только здесь. }
function TxHealthService: Integer;
{ Снять следующую сводку, не трогая диск (Service стоит на этих двух; стенду и
любому иному потребителю — тоже). }
function TxHealthTake(out S: TTxSession): Boolean;
{ Index-й опасный простой этой передачи, пока он ещё жив в кольце. }
function TxHealthDetail(Id: LongInt; Index: Integer; out D: TTxDrought): Boolean;
{ Опасных простоев за весь сеанс — для индикации в UI. }
function TxHealthDangerTotal: Integer;
{ Последняя записанная сводка (для UI/стенда), False — сводок ещё не было. }
function TxHealthLastSession(out S: TTxSession): Boolean;
{ Путь журнала. }
function TxHealthLogPath: string;
implementation
uses
SysUtils, Math, PlatformUtils;
const
RING_SIZE = 256; // опасных простоев в памяти (хвост важнее)
RING_MASK = RING_SIZE - 1;
SESS_SIZE = 32; // сводок, ждущих потребителя
SESS_MASK = SESS_SIZE - 1;
MAX_DETAIL = 24; // строк подробностей на передачу в журнале
var
// Кольцо опасных простоев: пишет поток отправителя DUC, читает потребитель.
Ring: array[0..RING_SIZE - 1] of TTxDrought;
RingSeq: LongInt = 0;
// Кольцо сводок: пишет поток, снимающий PTT, читает потребитель.
Sess: array[0..SESS_SIZE - 1] of TTxSession;
SessSeq: LongInt = 0; // сколько сводок сложено
SessRead: LongInt = 0; // сколько забрал потребитель
// Текущая передача.
CurId: LongInt = 0;
CurStartUs: Int64 = 0;
CurStartAt: TDateTime = 0;
CurSource: Integer = txhSrcRadio;
CurOn: Boolean = False;
CurDroughts, CurDanger, CurLongest, CurFirstMs, CurFifoEmpty: Integer;
// Снимок пейсинга TCI — публикует планировщик, читает поток отправителя.
// Простые записи без лока: числа диагностические, рвань в них безвредна, а
// лок в потоке отправителя DUC — недопустимый риск для тайминга.
PubLead, PubOwed, PubFlight, PubQuantum, PubStep, PubGap: Integer;
DangerTotal: Integer = 0;
LastSess: TTxSession;
HaveLast: Boolean = False;
procedure TxHealthPtt(On_: Boolean; Source: Integer);
// Фронт PTT. Только память: несколько записей, никакого ввода-вывода.
var
S: TTxSession;
Idx: LongInt;
begin
if On_ then
begin
if CurOn then Exit;
InterlockedIncrement(CurId);
CurStartUs := MonotonicUs;
CurStartAt := Now;
CurSource := Source;
CurDroughts := 0;
CurDanger := 0;
CurLongest := 0;
CurFirstMs := -1;
CurFifoEmpty := 0;
CurOn := True;
Exit;
end;
if not CurOn then Exit;
CurOn := False;
S.Id := CurId;
S.StartedAt := CurStartAt;
S.LenMs := Integer((MonotonicUs - CurStartUs) div 1000);
S.Source := CurSource;
S.Droughts := CurDroughts;
S.Dangerous := CurDanger;
S.LongestUs := CurLongest;
S.FirstDangerMs := CurFirstMs;
S.FifoEmpty := CurFifoEmpty;
Idx := InterlockedIncrement(SessSeq) - 1;
Sess[Idx and SESS_MASK] := S;
end;
procedure TxHealthDrought(FromUs, ToUs: Int64; QueueDepth: Integer);
// Поток отправителя DUC, на выходе очереди из простоя. Дороже всего здесь одно
// вычитание: время простоя измерил вызывающий, он же держит его границы.
var
LenUs, AtMs: Integer;
Idx: LongInt;
P: ^TTxDrought;
begin
if not CurOn then Exit;
LenUs := Integer(ToUs - FromUs);
if LenUs < 0 then Exit;
Inc(CurDroughts);
if LenUs > CurLongest then CurLongest := LenUs;
if LenUs <= TXH_CUSHION_US then Exit;
Inc(CurDanger);
InterlockedIncrement(DangerTotal);
AtMs := Integer((FromUs - CurStartUs) div 1000);
if CurFirstMs < 0 then CurFirstMs := AtMs;
Idx := InterlockedIncrement(RingSeq) - 1;
P := @Ring[Idx and RING_MASK];
P^.Session := CurId;
P^.AtMs := AtMs;
P^.LenUs := LenUs;
P^.Queue := QueueDepth;
P^.Lead := PubLead;
P^.Owed := PubOwed;
P^.InFlight := PubFlight;
P^.Quantum := PubQuantum;
P^.StepUs := PubStep;
P^.GapUs := PubGap;
end;
procedure TxHealthFifoEmpty;
// Сетевой поток, разбор HP-статуса: радио говорит, что его DUC FIFO пуст.
// ★На этой прошивке поле ГЛУБИНЫ FIFO статично (всегда 256) и веры ему нет —
// а вот бит в байте статуса мы раньше не читали вовсе. Если прошивка его
// заполняет, получаем жалобу самого железа как независимого свидетеля.
begin
if CurOn then Inc(CurFifoEmpty);
end;
procedure TxHealthPublishTci(Lead, Owed, InFlight, Quantum, StepUs,
GapUs: Integer);
// Планировщик TCI. Публикация состояния пейсинга для контекста простоев.
begin
PubLead := Lead;
PubOwed := Owed;
PubFlight := InFlight;
PubQuantum := Quantum;
PubStep := StepUs;
PubGap := GapUs;
end;
function TxHealthDangerTotal: Integer;
begin
Result := DangerTotal;
end;
function TxHealthLastSession(out S: TTxSession): Boolean;
begin
Result := HaveLast;
if Result then S := LastSess;
end;
function TxHealthLogPath: string;
begin
Result := GetAppCfgDir + 'txhealth.log';
end;
function SourceName(Src: Integer): string;
begin
case Src of
txhSrcTCI: Result := 'TCI';
txhSrcWeb: Result := 'web';
txhSrcCard: Result := 'звуковая карта';
else
Result := 'микрофон радио';
end;
end;
function TxHealthTake(out S: TTxSession): Boolean;
var Have: LongInt;
begin
Have := SessSeq;
if Have <= SessRead then Exit(False);
// Сводки, которые кольцо успело затереть, пропускаем: журнал честно отражает
// хвост, а отстать на SESS_SIZE передач потребитель может лишь простояв
// секунды — чего с таймером 100 мс не бывает.
if Have - SessRead > SESS_SIZE then SessRead := Have - SESS_SIZE;
S := Sess[SessRead and SESS_MASK];
Inc(SessRead);
LastSess := S;
HaveLast := True;
Result := True;
end;
function TxHealthDetail(Id: LongInt; Index: Integer; out D: TTxDrought): Boolean;
var
i, Seen, Total: LongInt;
begin
Result := False;
Total := RingSeq;
Seen := 0;
for i := Max(0, Total - RING_SIZE) to Total - 1 do
begin
if Ring[i and RING_MASK].Session <> Id then Continue;
if Seen = Index then
begin
D := Ring[i and RING_MASK];
Exit(True);
end;
Inc(Seen);
end;
end;
procedure WriteSession(var F: TextFile; const S: TTxSession);
// Одна передача: строка сводки плюс подробности по опасным простоям.
var
Shown: Integer;
R: TTxDrought;
Verdict: string;
begin
if S.Dangerous > 0 then
Verdict := 'ОПАСНО'
else
Verdict := 'чисто';
if S.FirstDangerMs >= 0 then
Verdict := Verdict + Format(' (первое на %d мс)', [S.FirstDangerMs]);
WriteLn(F, Format('%s %s передача %.1f с, источник %s: осушений %d, ' +
'опасных %d, самое длинное %.1f мс, FIFO-EMPTY %d',
[FormatDateTime('yyyy-mm-dd hh:nn:ss', S.StartedAt), Verdict,
S.LenMs / 1000.0, SourceName(S.Source), S.Droughts, S.Dangerous,
S.LongestUs / 1000.0, S.FifoEmpty]));
if S.Dangerous <= 0 then Exit;
Shown := 0;
while TxHealthDetail(S.Id, Shown, R) do
begin
Inc(Shown);
if Shown > MAX_DETAIL then
begin
WriteLn(F, ' … остальные опущены');
Break;
end;
WriteLn(F, Format(' опасный #%d: на %d мс от PTT, %.1f мс, очередь %d, ' +
'аванс %d, долг %d, в полёте %d, квант %d, шаг %d мкс, ' +
'пачка клиента %.1f мс',
[Shown, R.AtMs, R.LenUs / 1000.0, R.Queue, R.Lead, R.Owed,
R.InFlight, R.Quantum, R.StepUs, R.GapUs / 1000.0]));
end;
end;
function TxHealthService: Integer;
// ★Единственное место с диском, и зовут его только потребители (таймер UI,
// цикл демона) — никогда не поток передачи.
var
F: TextFile;
S: TTxSession;
begin
Result := 0;
if SessSeq <= SessRead then Exit;
AssignFile(F, TxHealthLogPath);
try
if FileExists(TxHealthLogPath) then Append(F) else Rewrite(F);
try
while TxHealthTake(S) do
begin
WriteSession(F, S);
Inc(Result);
end;
finally
CloseFile(F);
end;
except
// Журнал — вспомогательный: не пишется, так и ладно. Сводки при этом
// считаем забранными, иначе они копились бы и пытались писаться каждые
// 100 мс.
on E: Exception do SessRead := SessSeq;
end;
end;
end.