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
+22 -1
View File
@@ -19,7 +19,7 @@ uses
{$ELSE} {$ELSE}
Sockets, BaseUnix, Sockets, BaseUnix,
{$ENDIF} {$ENDIF}
HPSDRProtocol, SyncObjs, Settings, RadioBackend; HPSDRProtocol, SyncObjs, Settings, RadioBackend, TxHealth, PlatformUtils;
const const
DUC_TX_QUEUE_SIZE = 256; // power of two, bounded latency on TX underrun/overrun DUC_TX_QUEUE_SIZE = 256; // power of two, bounded latency on TX underrun/overrun
@@ -497,6 +497,7 @@ const
DUC_WIN_SPIN_US = 350; // final part of 1.25 ms interval: avoid Sleep jitter DUC_WIN_SPIN_US = 350; // final part of 1.25 ms interval: avoid Sleep jitter
var var
Pkt: TDUCIQPacket; Pkt: TDUCIQPacket;
DryFrom: Int64; // детектор: начало простоя очереди, мкс (см. TxHealth)
{$IFDEF WINDOWS} {$IFDEF WINDOWS}
QPCFreq: Int64; QPCFreq: Int64;
QPCValue: Int64; QPCValue: Int64;
@@ -551,11 +552,15 @@ begin
if not QueryPerformanceFrequency(QPCFreq) or (QPCFreq <= 0) then if not QueryPerformanceFrequency(QPCFreq) or (QPCFreq <= 0) then
QPCFreq := 1000; QPCFreq := 1000;
NextDueUs := ClockUs; NextDueUs := ClockUs;
DryFrom := 0;
while not Terminated do while not Terminated do
begin begin
if not FNet.DequeueDUCIQ(Pkt) then if not FNet.DequeueDUCIQ(Pkt) then
begin begin
// Детектор голодания: очередь пуста на передаче. Стоимость — одно чтение
// монотонных часов на ПЕРЕХОД, ни файлов, ни локов (см. TxHealth.pas).
if FNet.FIsTransmitting and (DryFrom = 0) then DryFrom := MonotonicUs;
RTLEventWaitFor(FNet.FDUCIQSem, 20); RTLEventWaitFor(FNet.FDUCIQSem, 20);
NowUs := ClockUs; NowUs := ClockUs;
if NextDueUs < NowUs - DUC_PACKET_INTERVAL_US then if NextDueUs < NowUs - DUC_PACKET_INTERVAL_US then
@@ -568,6 +573,12 @@ begin
if not FNet.FIsTransmitting then if not FNet.FIsTransmitting then
Continue; Continue;
if DryFrom <> 0 then
begin
TxHealthDrought(DryFrom, MonotonicUs, FNet.FDUCIQCount);
DryFrom := 0;
end;
FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ)); FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ));
FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress, FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress,
FNet.FPortDUCIQ); FNet.FPortDUCIQ);
@@ -580,12 +591,16 @@ begin
{$ELSE} {$ELSE}
VirtualSamples := 0; VirtualSamples := 0;
LastTick := GetTickCount64; LastTick := GetTickCount64;
DryFrom := 0;
while not Terminated do while not Terminated do
begin begin
if not FNet.DequeueDUCIQ(Pkt) then if not FNet.DequeueDUCIQ(Pkt) then
begin begin
DrainVirtualFIFO; DrainVirtualFIFO;
// Детектор голодания: очередь пуста на передаче. Стоимость — одно чтение
// монотонных часов на ПЕРЕХОД, ни файлов, ни локов (см. TxHealth.pas).
if FNet.FIsTransmitting and (DryFrom = 0) then DryFrom := MonotonicUs;
// Очередь пуста. НЕ инжектируем нулевые пакеты — 1.25мс тишины прямо // Очередь пуста. НЕ инжектируем нулевые пакеты — 1.25мс тишины прямо
// в поток сэмплов = разрыв данных = всплеск на водопаде/в эфире. Как // в поток сэмплов = разрыв данных = всплеск на водопаде/в эфире. Как
// dl1ycf pihpsdr (txiq_thread: пустая очередь → просто sem_wait): ждём // dl1ycf pihpsdr (txiq_thread: пустая очередь → просто sem_wait): ждём
@@ -604,6 +619,12 @@ begin
if not FNet.FIsTransmitting then if not FNet.FIsTransmitting then
Continue; Continue;
if DryFrom <> 0 then
begin
TxHealthDrought(DryFrom, MonotonicUs, FNet.FDUCIQCount);
DryFrom := 0;
end;
FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ)); FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ));
FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress, FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress,
FNet.FPortDUCIQ); FNet.FPortDUCIQ);
+17 -5
View File
@@ -40,7 +40,7 @@ uses
PanZoomBar, PanafallPanel, PanDisplayPopup, PureSignalPopup, FlatPopupMenu, PanZoomBar, PanafallPanel, PanDisplayPopup, PureSignalPopup, FlatPopupMenu,
WidebandView, WidebandView,
StatusBar, StatusBar,
PlatformUtils, PlatformUtils, TxHealth,
RadioModes, RadioModes,
WinFirewall, WinFirewall,
BoardUtils, WisdomBuilder, UISync, BoardUtils, WisdomBuilder, UISync,
@@ -3822,9 +3822,14 @@ var
PeakTarget, MinTarget: Double; PeakTarget, MinTarget: Double;
PeakDiff, MinDiff: Double; PeakDiff, MinDiff: Double;
PeakAlpha, MinAlpha: Double; PeakAlpha, MinAlpha: Double;
TxhSuffix: string;
begin begin
// Затухание спотов не зависит от того, крутится ли приём. // Затухание спотов не зависит от того, крутится ли приём.
ServiceDXCluster; ServiceDXCluster;
// Детектор голодания очереди TX: сводки за прошедшие передачи → журнал.
// ★Это ЕДИНСТВЕННОЕ место, где прибор трогает диск, и оно намеренно вынесено
// из тракта передачи в таймер UI (см. TxHealth.pas).
TxHealthService;
if not FController.FRunning then if not FController.FRunning then
begin begin
@@ -3877,17 +3882,24 @@ begin
if FController.FWDSPReady then if FController.FWDSPReady then
begin begin
// Опасные простои очереди TX за сеанс: столько раз очередь пустела дольше
// подушки отправителя, то есть столько раз в эфир могла уйти голая несущая.
// Ноль — значит всплеск, если он был виден, родился НЕ в нашем тракте.
if TxHealthDangerTotal > 0 then
TxhSuffix := Format(' ·сухо %d', [TxHealthDangerTotal])
else
TxhSuffix := '';
if FController.FTransmitting then if FController.FTransmitting then
begin begin
case FController.FDSPEngine.TXMicSource of case FController.FDSPEngine.TXMicSource of
txmsSoundCard: SetStatusText(5, 'TX sound card'); txmsSoundCard: SetStatusText(5, 'TX sound card' + TxhSuffix);
txmsWeb: SetStatusText(5, 'TX web mic'); txmsWeb: SetStatusText(5, 'TX web mic' + TxhSuffix);
else else
SetStatusText(5, 'TX radio mic'); SetStatusText(5, 'TX radio mic' + TxhSuffix);
end; end;
end end
else else
SetStatusText(5, 'TX idle'); SetStatusText(5, 'TX idle' + TxhSuffix);
end; end;
end; end;
+23 -1
View File
@@ -51,7 +51,7 @@ uses
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
CWDecoder; CWDecoder, TxHealth;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -1864,6 +1864,11 @@ begin
FHPpPLL := (Status.StatusBits and HPS_PLL_LOCKED) <> 0; FHPpPLL := (Status.StatusBits and HPS_PLL_LOCKED) <> 0;
FHPpHWPTT := (Status.StatusBits and HPS_PTT) <> 0; FHPpHWPTT := (Status.StatusBits and HPS_PTT) <> 0;
FHPpOverload := Status.ADCOverload; FHPpOverload := Status.ADCOverload;
// ★Жалоба самого радио: бит «DUC FIFO пуст». Поле ГЛУБИНЫ FIFO (байты 35-36)
// на этой прошивке статично и веры ему нет, а бит мы не читали вовсе —
// если прошивка его заполняет, это независимый свидетель голодания.
if FTransmitting and ((Status.StatusBits and HPS_FIFO_EMPTY) <> 0) then
TxHealthFifoEmpty;
// Состояние ключа/манипулятора в разъёме трансивера (сетевой поток). // Состояние ключа/манипулятора в разъёме трансивера (сетевой поток).
// Касание обрывает программную передачу — как в Thetis/pihpsdr («key hit» // Касание обрывает программную передачу — как в Thetis/pihpsdr («key hit»
// останавливает CAT CW), иначе оператор не может вклиниться. ★Обрываем по // останавливает CAT CW), иначе оператор не может вклиниться. ★Обрываем по
@@ -6292,6 +6297,7 @@ end;
procedure TRadioController.SetMOX(On_: Boolean); procedure TRadioController.SetMOX(On_: Boolean);
var var
WasTransmitting: Boolean; WasTransmitting: Boolean;
TxhSrc: Integer;
begin begin
// DMR support is receive-only. This guard also covers CAT, web and hardware // DMR support is receive-only. This guard also covers CAT, web and hardware
// PTT. FM RAW is a flat external-modem transmit path. // PTT. FM RAW is a flat external-modem transmit path.
@@ -6426,6 +6432,19 @@ begin
if FTransmitting and FDisplayDuplex then if FTransmitting and FDisplayDuplex then
FDSPEngine.SetDisplaySourceTX(False); FDSPEngine.SetDisplaySourceTX(False);
end; end;
// Детектор голодания очереди TX: фронт «в эфир» открывает передачу для
// счётчиков. Стоит ПОСЛЕ блока движка — источник модуляции решается там.
// Ввода-вывода тут нет и быть не может: журнал пишет потребитель (таймер UI /
// цикл демона), см. TxHealth.pas.
if FTransmitting and (not WasTransmitting) then
begin
if FTCIMicActive then TxhSrc := txhSrcTCI
else if FWebMicActive then TxhSrc := txhSrcWeb
else if Assigned(FDSPEngine) and (FDSPEngine.TXMicSource = txmsSoundCard) then
TxhSrc := txhSrcCard
else TxhSrc := txhSrcRadio;
TxHealthPtt(True, TxhSrc);
end;
// Сетевая PTT-последовательность TX-off: сначала снимаем PTT, затем реле RX. // Сетевая PTT-последовательность TX-off: сначала снимаем PTT, затем реле RX.
if (not FTransmitting) and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then if (not FTransmitting) and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin begin
@@ -6443,6 +6462,9 @@ begin
// (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой. // (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
SyncCWKeyer; SyncCWKeyer;
Changed(rfTransmitting); Changed(rfTransmitting);
// Передача кончилась — складываем сводку в память. Диск не трогаем (★прошлый
// прибор писал дамп ровно здесь и тем самым прикрывал гонку в пейсинге).
if WasTransmitting and (not FTransmitting) then TxHealthPtt(False, 0);
end; end;
procedure TRadioController.SetTune(On_: Boolean); procedure TRadioController.SetTune(On_: Boolean);
+7 -1
View File
@@ -44,7 +44,7 @@ interface
uses uses
Classes, SysUtils, DateUtils, Math, SyncObjs, Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils, RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
DXSpotStore, TCIProtocol, TCIServer, TCIStreams; DXSpotStore, TCIProtocol, TCIServer, TCIStreams, TxHealth;
const const
// ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА // ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА
@@ -3997,6 +3997,12 @@ begin
TxNoteRequest(NowUs); TxNoteRequest(NowUs);
Inc(Sent); Inc(Sent);
end; end;
// Снимок пейсинга для детектора голодания: поток отправителя DUC приложит
// его к очередному опасному простою и тем ответит, чей это простой —
// аванса, зернистости клиента или планировщика. Простые записи, без лока и
// без ввода-вывода (см. TxHealth.pas).
TxHealthPublishTci(FTxLead, Round(FTxOwed), Round(FTxInFlight), FTxQuantum,
Integer(PeriodUs), Round(FTxGapUs));
finally finally
FTxLock.Leave; FTxLock.Leave;
end; end;
+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.
+4 -1
View File
@@ -28,7 +28,7 @@ uses
{$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF} {$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
Classes, SysUtils, Math, Classes, SysUtils, Math,
RadioController, WebServer, WebAdapter, WDSPEngine, RadioController, WebServer, WebAdapter, WDSPEngine,
HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils; HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils, TxHealth;
const const
PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер) PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер)
@@ -470,6 +470,9 @@ begin
// Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет вовсе, и // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет вовсе, и
// отпустить реле T/R после выдержки больше некому). // отпустить реле T/R после выдержки больше некому).
FCtrl.ServiceCWKeyed; FCtrl.ServiceCWKeyed;
// Детектор голодания очереди TX: сводки за прошедшие передачи → журнал.
// Единственное место, где прибор трогает диск (см. TxHealth.pas).
TxHealthService;
lastBeacon := nowMs; lastBeacon := nowMs;
end; end;
if nowMs - lastPush >= PUSH_INTERVAL_MS then if nowMs - lastPush >= PUSH_INTERVAL_MS then
+49
View File
@@ -105,6 +105,55 @@ TX голодала, в эфире дыры. Тут же диапазон глу
* `capture.sh` — кольцевой съём tcpdump + лог потерь ядра. * `capture.sh` — кольцевой съём tcpdump + лог потерь ядра.
* `pcap_tx_scan.py` — разбор pcap, чистый Python без зависимостей (~40 МБ/с). * `pcap_tx_scan.py` — разбор pcap, чистый Python без зависимостей (~40 МБ/с).
## Тихий детектор голодания очереди TX (включён всегда)
Всплеск, рождённый НАШИМ трактом, невозможен без одного события: очередь DUC
простаивает дольше подушки отправителя (`DUC_FIFO_THROTTLE` = 2000 отсчётов при
192 кГц = **10.42 мс**). Это доказано съёмом 2026-08-21: шесть всплесков — шесть
простоев 10.3–23.5 мс, паузы 8 мс и короче не дали ни одного. Поэтому счётчик
таких простоев — **полный** детектор, и он теперь живёт в самой программе
(`TxHealth.pas`), без переменных окружения: прибор, который надо не забыть
включить, к моменту дефекта выключен.
Что делает:
* поток отправителя DUC мерит каждый простой очереди на передаче — одно чтение
монотонных часов на переход, ни файлов, ни локов;
* планировщик TCI публикует рядом состояние пейсинга (аванс, долг, в полёте,
квант, шаг, период пачек клиента) — простыми записями;
* сетевой поток считает биты «DUC FIFO пуст» из HP-статуса радио (поле ГЛУБИНЫ
на этой прошивке статично и веры ему нет, а бит раньше не читался вовсе);
* журнал пишет **потребитель** — таймер UI (100 мс) или цикл демона, никогда не
тракт передачи.
★Последнее — правило, оплаченное регрессом: прошлый прибор (`TxTrace`) сбрасывал
дамп синхронно в хвосте `SetMOX`, и эти десятки миллисекунд закрывали гонку в
пейсинге TCI. С зондами всплесков не было, без них они возвращались —
инструмент чинил дефект собой (лечение: коммит `fa38fa7`).
Журнал: `~/.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` по тому же куску времени
скажет, ушёл ли всплеск в эфир или это артефакт приёма.
## Приборы внутри процесса: кольцевая трассировка TX-тракта ## Приборы внутри процесса: кольцевая трассировка TX-тракта
★**Сняты из кода 2026-08-24** — пейсинг TX проверен на железе, всплесков нет, и ★**Сняты из кода 2026-08-24** — пейсинг TX проверен на железе, всплесков нет, и
+79 -1
View File
@@ -28,7 +28,7 @@ program tcitest;
uses uses
cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix, cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix,
TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController, TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController,
WsClient, WebUtils, WDSPEngine, Settings, CWMorse; WsClient, WebUtils, WDSPEngine, Settings, CWMorse, TxHealth, PlatformUtils;
var var
Passed, Failed: Integer; Passed, Failed: Integer;
@@ -2207,6 +2207,83 @@ begin
end; end;
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; procedure TestEndToEnd;
const const
PORT = 40098; PORT = 40098;
@@ -2657,6 +2734,7 @@ begin
TestServer; TestServer;
TestEndToEnd; TestEndToEnd;
TestTxPacing; TestTxPacing;
TestTxHealth;
WriteLn; WriteLn;
WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed])); WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed]));
if Failed > 0 then Halt(1); if Failed > 0 then Halt(1);