fix(tci): бухгалтерия пейсинга переезжала из прошлой передачи в следующую

Посреди передачи из MSHV рядом с сигналом вставала вторая несущая — и не
изредка, а с первых миллисекунд посылки. Это голая несущая гетеродина DUC:
очередь пересыхает, модуляции нет.

Цепочка. Конец посылки клиент делает командой trx:N,false. SetMOX(False)
снимает TCIMicActive внутри Invoke, а FTxClient обнуляется ПОСЛЕ возврата из
него, в HandleTrx. Опустить FTxRunning умел только тик планировщика — и только
пока видел живого FTxClient с уже снятым TCIMicActive. Окно = хвост SetMOX,
пара миллисекунд против шага тика в полкванта: тик попадал в него через раз.

Промах означал, что следующая передача идёт МИМО TxResetAccounting и наследует
FTxOwed, FTxInFlight, FTxSatSinceUs и оценку задержки. Долг сразу у потолка,
в полёте чужие кредиты прошлой посылки, условие выдачи не выполняется — маркеры
не идут вовсе, пока не сработает сторож (выдержка до 250 мс). Нулевой pre-roll
кончается раньше, очередь DUC сохнет, радио отдаёт несущую.

Лечение — инвариант «нет клиента, нет и идущей передачи», как в ClearTxClient
(он его соблюдал, а HandleTrx нет):
* FTxRunning := False рядом с FTxClient := Client — первый тик передачи всегда
  начинает с TxResetAccounting, каким бы ни был исход гонки;
* то же в обоих местах, где источник снимается;
* то же в выходе PushTxChrono по C = nil — страховка от будущих путей.

Аванс (FTxLeadKeep, FTxGapUs) не трогаем: он намеренно переживает передачу,
на нём стоит pre-roll следующего PTT.

Стенд: новая фаза «повторы» в TestTxPacing — шесть циклов PTT на одном клиенте,
проверка флага сразу по подтверждению trx:0,false и счёт маркеров в первые
200 мс каждой передачи. Негативный контроль на старом коде: флаг протухал 6/6,
маркеров 4 вместо 22. Стенд 262/262, test/cat 50/50, GUI и демон собираются.

★Отдельной тестовой процедурой это сделать нельзя: третий TRadioController в
процессе даёт AV в FreeEngines (состояние WDSP), стенд падает до проверок.
Фаза живёт внутри TestTxPacing и стартует после потерь ответов — то есть с
заведомо отравленной бухгалтерией.

★Почему дефект вылез только сейчас: TraceDump('tx') из снятых зондов стоял в
конце SetMOX(False) и писал до 16 тысяч строк — десятки миллисекунд ровно в
этом окне, и тик гарантированно успевал. Прогоны шли с EWSDR_TXTRACE=1, так
что инструмент собой же и прикрывал гонку.

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 18:45:55 +03:00
co-authored by Claude Opus 5
parent 166fc65fc8
commit fa38fa7f75
2 changed files with 74 additions and 4 deletions
+19
View File
@@ -2185,6 +2185,17 @@ begin
begin begin
FTxClient := Client; FTxClient := Client;
FTxRx := Rx; FTxRx := Rx;
// ★Новая передача — новая бухгалтерия, и опустить флаг обязаны
// ЗДЕСЬ. Сбрасывает его тик, но только пока видит ЖИВОГО FTxClient
// и уже снятый TCIMicActive (см. PushTxChrono) — а это окно длиной
// в хвост SetMOX, пара миллисекунд против шага тика в полкванта.
// Тик в него попадал через раз, и промах означал, что следующая
// передача идёт МИМО TxResetAccounting: с долгом у потолка и
// чужими кредитами в полёте, то есть с заблокированной выдачей
// маркеров на всю выдержку сторожа. В эфире это голая несущая
// гетеродина DUC поверх пересохшей очереди с первых же
// миллисекунд посылки.
FTxRunning := False;
// ★Будим планировщик немедленно. Иначе первый маркер уходил на // ★Будим планировщик немедленно. Иначе первый маркер уходил на
// 10-20 мс позже фронта PTT (дремотный шаг), и ровно этих // 10-20 мс позже фронта PTT (дремотный шаг), и ровно этих
// миллисекунд не хватало нулевому pre-roll, чтобы дожить до первого // миллисекунд не хватало нулевому pre-roll, чтобы дожить до первого
@@ -2196,6 +2207,7 @@ begin
begin begin
FTxClient := nil; FTxClient := nil;
FTxRx := 0; FTxRx := 0;
FTxRunning := False;
end; end;
end end
// Сам просит выключить — источник ему больше не нужен в любом случае. // Сам просит выключить — источник ему больше не нужен в любом случае.
@@ -2203,6 +2215,10 @@ begin
begin begin
FTxClient := nil; FTxClient := nil;
FTxRx := 0; FTxRx := 0;
// ★Тот же инвариант, что и в ClearTxClient: нет клиента — нет и
// идущей передачи. Раньше эти две строки расходились, и штатный
// конец посылки (trx:N,false у MSHV) оставлял FTxRunning поднятым.
FTxRunning := False;
end; end;
finally FTxLock.Leave; end; finally FTxLock.Leave; end;
end; end;
@@ -3820,6 +3836,9 @@ begin
try try
C := FTxClient; C := FTxClient;
Rx := FTxRx; Rx := FTxRx;
// Хозяина нет — считать нечего и не про кого. Флаг снимаем под тем же
// локом: он живёт ровно столько, сколько живёт FTxClient.
if C = nil then FTxRunning := False;
finally finally
FTxLock.Leave; FTxLock.Leave;
end; end;
+51
View File
@@ -1994,6 +1994,7 @@ const
PORT = 40099; PORT = 40099;
RATE = 48000; RATE = 48000;
TCI_TX_LEAD_MAX_MS_CHK = 120; // потолок аванса, копия TCI_TX_LEAD_MAX_MS TCI_TX_LEAD_MAX_MS_CHK = 120; // потолок аванса, копия TCI_TX_LEAD_MAX_MS
RESTART_CYCLES = 6; // повторных передач в последней фазе
var var
Ctrl: TRadioController; Ctrl: TRadioController;
Ad: TTCIAdapter; Ad: TTCIAdapter;
@@ -2001,6 +2002,7 @@ var
Cfg: TTCISettings; Cfg: TTCISettings;
C: TRawClient; C: TRawClient;
Markers, Answered, Quantum, LongGaps, MaxGapMs, MinReserve: Integer; Markers, Answered, Quantum, LongGaps, MaxGapMs, MinReserve: Integer;
N, MinStart, SumStart, StaleRuns: Integer;
DbgOwed, DbgFlight: Double; DbgOwed, DbgFlight: Double;
DbgWin, DbgQ, DbgLead, SeedLead: Integer; DbgWin, DbgQ, DbgLead, SeedLead: Integer;
begin begin
@@ -2146,6 +2148,55 @@ begin
Check('пейсинг: защёлки нет, маркеры идут дальше', Check('пейсинг: защёлки нет, маркеры идут дальше',
Markers > 40, IntToStr(Markers)); Markers > 40, IntToStr(Markers));
// ── Повторные передачи: бухгалтерия не переезжает в следующую ──
// ★Регресс с железа. Штатный конец посылки (trx:N,false — так её и кончает
// MSHV каждый цикл FT8) обнулял FTxClient, но НЕ FTxRunning, а сбрасывать
// флаг умел только тик планировщика — и только пока видел ЖИВОГО FTxClient
// с уже снятым TCIMicActive. Окно этой гонки — хвост SetMOX, пара
// миллисекунд против шага тика в полкванта, так что промах выпадал через
// раз. Промах означал, что СЛЕДУЮЩАЯ передача идёт мимо TxResetAccounting:
// долг сразу у потолка, в полёте чужие кредиты прошлой посылки, выдача
// маркеров заблокирована до срабатывания сторожа (выдержка до 250 мс). В
// эфире это пересохшая очередь DUC с первых миллисекунд — голая несущая
// гетеродина DUC рядом с сигналом.
// Одиночный промах случаен, поэтому гоняем пачкой: до правки хоть один из
// шести циклов ловится практически всегда.
MinStart := MaxInt;
SumStart := 0;
StaleRuns := 0;
for N := 1 to RESTART_CYCLES do
begin
C.SendText('trx:0,false;');
C.WaitText('trx:0,false', 1500);
// ★Инвариант проверяем сразу по подтверждению команды, не дожидаясь тика:
// флаг обязан опускаться под тем же FTxLock, что и FTxClient, то есть до
// ответа клиенту. В этом и смысл правки.
if Ad.TxDbgState(DbgOwed, DbgFlight, DbgWin, DbgQ, DbgLead) then
Inc(StaleRuns);
C.SendText('trx:0,true,tci;');
// Отвечаем СРАЗУ, не дожидаясь текстового подтверждения: WaitText спит по
// 50 мс и выбрасывает бинарные кадры, а брошенный без ответа маркер — это
// зависший кредит, который сам придушит выдачу и смажет измерение старта.
ServeChrono(C, 200, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
MinReserve);
if Markers < MinStart then MinStart := Markers;
Inc(SumStart, Markers);
// Тело передачи: к её концу часть кредитов остаётся в полёте — именно их
// и наследовала следующая посылка.
ServeChrono(C, 150, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
MinReserve);
end;
WriteLn(Format(' .. повторы: %d передач подряд, маркеров в первые 200 мс ' +
'минимум %d, в среднем %d; флаг протухал %d раз',
[RESTART_CYCLES, MinStart, SumStart div RESTART_CYCLES,
StaleRuns]));
Check('повторы: конец передачи опускает флаг идущей передачи',
StaleRuns = 0, Format('%d из %d', [StaleRuns, RESTART_CYCLES]));
// 200 мс / 10.67 мс = около 18 маркеров в здоровом старте; отравленный
// старт не даёт ни одного, пока не сработает сторож.
Check('повторы: маркеры идут с первых миллисекунд КАЖДОЙ передачи',
MinStart >= 8, Format('минимум %d маркеров за 200 мс', [MinStart]));
C.SendText('trx:0,false;'); C.SendText('trx:0,false;');
C.Pump(300); C.Pump(300);
finally finally