From fe3d862d008ceb41694d3d62997d89205a32e2f3 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 24 Aug 2026 21:20:38 +0300 Subject: [PATCH] =?UTF-8?q?fix(dsp):=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20TX?= =?UTF-8?q?A=20=D0=B6=D0=B8=D0=BB=20=D0=BD=D0=B0=20=D0=B4=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TX-DSP поток зовёт fexchange0(TXA_CHAN) через буферы FTXIn/FTXOut, и ровно туда же на каждом T/R лезет SetTXRun из потока контроллера. Гейт FTXActive их не разделял: поток проверяет его СНАРУЖИ, а внутрь блока входит позже. Три дефекта одной природы: * деструктор звал SetTXRun(False), который поток НЕ останавливает — он лишь опускает гейт и тут же сам сливает канал своими fexchange0, то есть лезет в TXA одновременно с ещё живым потоком, прямо перед сносом объекта. Теперь Destroy сразу зовёт Close: тот опускает гейт, будит поток, дожидается WaitFor и лишь потом закрывает каналы. Комментарий «останавливаем TX поток» врал; * слив на отпускании PTT шёл без всякой синхронизации — окно гонки примерно 1-1.5% на каждый T/R (блок ~150 мкс против периода 10.7 мс). Появился FTXLock по идиоме RX-стороны (FMainGated + FMainLock): поток берёт лок вокруг ProcessTXBlock и ПЕРЕЧИТЫВАЕТ гейт под ним, оба слива в SetTXRun идут под тем же локом. Ждать контроллеру не дольше одного блока TXA; * ветка ЗАПУСКА оставалась открытой: SetMOX не отсекает MOX=True поверх уже идущей передачи (прислать повтор вправе CAT, web и TCI), а SetTXRun(True) обнулял индексы mic-кольца и дёргал SetChannelState мимо лока. ★Худший исход не теоретический: TX-поток дописывает свой локальный tail поверх обнулённого, кольцо выглядит почти полным СТАРЫХ сэмплов, и они уходят в эфир пачкой. Теперь `if Run and FTXActive then Exit` плюс весь старт одной транзакцией под FTXLock, FTXActive публикуется последним. ★Перенастройку TXA в ChangeSampleRate под лок НЕ берём: там SetChannelState с dmode=1 ждёт фейда, а фейд прокручивают fexchange0 самого потока — под локом это клин на ~106 мс (тот самый старый фриз TX→RX). Синхронизация там своя: остановка канала. Второй известный кросс-поточный доступ к TXA — pscc из сетевого потока (PureSignal), так же устроено в Thetis. Попутно закрыт use-after-free: TX-поток создаёт SetTXRun, которому открытый движок не нужен, а гасил его только код ЗА гейтом `if not FInitialized then Exit` в Close — на неоткрытом движке поток переживал Free объекта. Ловилось не там, где сделано: следующий TThread.Create в процессе падал с AV (в стенде — на секции часов после пейсинга, раньше — на третьем TRadioController в FreeEngines). Остановка потока перенесена в начало Close, до гейта. Стенд: порядок секций теперь намеренный — часы идут ПОСЛЕ тяжёлых частей и служат сторожем починки потока (упадёт снова — увидим там). Новая проверка повторного MOX с ★негативным контролем: без гейта «было 3000, стало 0». Для неё в движке появилось диагностическое свойство TXMicFill. 276/276, test/cat 50/50, GUI (--ws=qt6) и демон собираются. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bkwwyj7xVRrqnSVEseTRfV --- WDSPEngine.pas | 175 +++++++++++++++++++++++++++++-------------- test/tci/tcitest.pas | 16 +++- 2 files changed, 133 insertions(+), 58 deletions(-) diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 6e520a2..26c9209 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -329,6 +329,12 @@ type // установки гейта, гарантируя что fexchange0 на RXA_CHAN завершён. FMainGated: Boolean; FMainLock: TCriticalSection; + // ★Канал TXA принадлежит TX-потоку, но T/R трогает его же: SetTXRun сливает + // канал своими fexchange0 через ОБЩИЕ буферы FTXIn/FTXOut. Гейт FTXActive + // сам по себе не разделяет их — поток мог пройти проверку и войти в блок + // ровно тогда, когда T/R уже сливает. Идиома та же, что на RX (FMainGated + + // FMainLock): гейт + повторная проверка ПОД локом, а слив идёт под ним же. + FTXLock: TCriticalSection; FSpectrumPixels: array[0..SPECTRUM_PIXELS - 1] of Single; FWaterfallPixels: array[0..SPECTRUM_PIXELS - 1] of Single; @@ -617,6 +623,7 @@ type procedure ApplySliceFMSQ(Idx: Integer); // squelch слайса с гейтом по режиму procedure ApplyNoiseFilterState; procedure ProcessTXBlock; // вызывается из TTXDSPThread + function GetTXMicFill: Integer; procedure FeedTXDisplaySample(const I, Q: Double); procedure ApplyTXChainSettings; // прокидывает все FTX* поля в WDSP procedure PushTXEQProfile; // кривая EQ в WDSP (узлы + legacy 3 полосы) @@ -896,6 +903,9 @@ type property OnPanWaterfall: TOnPanPixels read FOnPanWaterfall write FOnPanWaterfall; property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ; property TXActive: Boolean read FTXActive; + // Сколько mic-сэмплов ждёт обработки. Диагностическое: по нему видно, что + // повторный MOX=True не сбрасывает кольцо на ходу (см. SetTXRun). + property TXMicFill: Integer read GetTXMicFill; // Включает full-duplex поведение RX-тракта: RXA не останавливается на TX, // RX-анализатор продолжает получать IQ. Должен ставиться UI ДО SetTXRun. property KeepRXDuringTX: Boolean read FKeepRXDuringTX @@ -1038,12 +1048,20 @@ begin // Обрабатываем накопившиеся блоки с реальными mic-данными (строго // mic-гейтед: сколько полных mic-блоков в ring — столько TX-блоков). repeat + if Terminated then Break; Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING) and (TX_MIC_RING - 1); - if (Avail >= FEngine.FAudioBufSize) and not Terminated then - FEngine.ProcessTXBlock - else - Break; + if Avail < FEngine.FAudioBufSize then Break; + FEngine.FTXLock.Enter; + try + // ★Повторная проверка гейта ПОД локом — как на RX (FMainGated). + // Снаружи она ничего не гарантирует: между ней и входом в блок T/R + // успевает опустить FTXActive и начать слив канала. + if not FEngine.FTXActive then Break; + FEngine.ProcessTXBlock; + finally + FEngine.FTXLock.Leave; + end; until False; if Terminated then Break; @@ -1512,6 +1530,7 @@ begin // Гейт главного тракта (хирургическая смена rate) FMainGated := False; FMainLock := TCriticalSection.Create; + FTXLock := TCriticalSection.Create; // Лупа маяка — analyzer создаётся по требованию UI (SetBeaconZoom) FBcnLock := TCriticalSection.Create; @@ -1620,12 +1639,17 @@ end; destructor TWDSPEngine.Destroy; begin - SetTXRun(False); // останавливаем TX поток если запущен + // ★Сразу Close, без SetTXRun(False): тот поток НЕ останавливает — он лишь + // опускает FTXActive и тут же сам сливает канал TXA своими fexchange0, то + // есть лезет в канал одновременно с ещё живым потоком, прямо перед сносом + // объекта. Close опускает гейт, будит поток, дожидается его WaitFor и лишь + // потом закрывает каналы — это и есть правильный порядок. Close; RTLEventDestroy(FQueueSem); RTLEventDestroy(FTXMicSem); FSliceLock.Free; FMainLock.Free; + FTXLock.Free; FBcnLock.Free; FIQTapLock.Free; inherited; @@ -2252,6 +2276,24 @@ procedure TWDSPEngine.Close; var i: Integer; begin + // ★TX-DSP поток живёт весь сеанс (не пересоздаётся на T/R) и гасится ЗДЕСЬ — + // ДО гейта FInitialized, а не после. Создаёт его SetTXRun, которому открытый + // движок не нужен, поэтому на неоткрытом движке гейт ниже выпускал нас + // раньше, чем поток останавливался: он переживал Free самого движка и + // продолжал ходить по освобождённой памяти. Ловится это не там, где сделано: + // следующий TThread.Create в процессе падал с AV (в стенде — на секции часов + // после части «пейсинг», а раньше на третьем TRadioController в FreeEngines). + // Остановка всё так же идёт прежде закрытия WDSP-каналов — поток зовёт + // fexchange0 на TXA_CHAN. + FTXActive := False; + if Assigned(FTXThread) then + begin + FTXThread.Terminate; + RTLEventSetEvent(FTXMicSem); + FTXThread.WaitFor; + FreeAndNil(FTXThread); + end; + if not FInitialized then Exit; // PureSignal: снять подачу feedback ДО закрытия TXA — pscc зовётся из @@ -2267,17 +2309,6 @@ begin FreeAndNil(FDSPThread); end; - // TX-DSP поток живёт весь сеанс (не пересоздаётся на T/R) — гасим его здесь, - // до закрытия WDSP-каналов (он зовёт fexchange0 на TXA_CHAN). - FTXActive := False; - if Assigned(FTXThread) then - begin - FTXThread.Terminate; - RTLEventSetEvent(FTXMicSem); - FTXThread.WaitFor; - FreeAndNil(FTXThread); - end; - // Закрываем WDSP-каналы слайсов (записи FSlices сохраняем — переоткроются в Open). FSliceLock.Enter; try @@ -3807,48 +3838,64 @@ procedure TWDSPEngine.SetTXRun(Run: Boolean); var k, Err: Integer; begin + // ★Повторный старт при уже идущей передаче — не событие, а шум: SetMOX не + // отсекает MOX=True поверх MOX=True (RadioController.SetMOX), а прислать его + // снова вправе и CAT, и web, и TCI. Перезапускать тракт по такому вызову + // НЕЛЬЗЯ: сброс индексов mic-кольца гонялся бы с чтением из TX-потока + // (правило «tail пишет только TX-поток» ломается), а SetChannelState — с его + // fexchange0. Худший исход не теоретический: поток дописывает свой локальный + // tail поверх обнулённого, кольцо выглядит почти полным СТАРЫХ данных, и они + // уходят в эфир пачкой — то есть всплеск. + if Run and FTXActive then Exit; FDispPos := 0; FTXDispPos := 0; if Run then begin - FActiveDisplayID := TX_DISP_ID; - // Сброс mic ring ДО активации, чтобы простаивавший поток не прочитал старое. - FTXMicHead := 0; - FTXMicTail := 0; - if FInitialized then - begin - SetChannelState(TXA_CHAN, 1, 0); // run, no delay - // Продувка канала: страховка от остатка прошлой TX-сессии в буферах/ - // фильтрах TXA (без неё огрызок — после TUN это полноразмахный тон — - // выплёвывался в эфир первым же блоком нового TX). Основной слив теперь - // на стопе (ниже), но нештатные пути останова канал не чистят. 4 нулевых - // блока через fexchange0, выход в мусор (не в FOnTXIQ/display); поток - // ещё idle (FTXActive=False) — гонки нет. - FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0); - for k := 1 to 4 do + // ★Весь старт — одной транзакцией под FTXLock, флаг публикуется последним: + // до него поток, взявший лок, перечитает гейт и уйдёт в простой, а после — + // канал уже поднят, кольцо пусто, буферы продуты. + FTXLock.Enter; + try + FActiveDisplayID := TX_DISP_ID; + // Сброс mic ring ДО активации, чтобы простаивавший поток не прочитал старое. + FTXMicHead := 0; + FTXMicTail := 0; + if FInitialized then begin - Err := 0; - fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err); + SetChannelState(TXA_CHAN, 1, 0); // run, no delay + // Продувка канала: страховка от остатка прошлой TX-сессии в буферах/ + // фильтрах TXA (без неё огрызок — после TUN это полноразмахный тон — + // выплёвывался в эфир первым же блоком нового TX). Основной слив теперь + // на стопе (ниже), но нештатные пути останова канал не чистят. 4 нулевых + // блока через fexchange0, выход в мусор (не в FOnTXIQ/display). + FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0); + for k := 1 to 4 do + begin + Err := 0; + fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err); + end; + // Сброс TX-анализатора: его усредняющая история хранит спектр ПРОШЛОЙ + // сессии (после TUN — тон), и при переключении на TX-дисплей первые + // кадры показывали «всплеск» старого тона (виден в non-DUP, где на + // экране TX-спектр; эфир при этом чист — в DUP старт чистый). + // ResetPixelBuffers — лёгкое зануление pixel/average буферов (MW0LGE); + // НЕ ApplyTXAnalyzerSettings: тот зовёт SetAnalyzer (перепланирование + // FFT в UI-потоке) — давал видимую задержку отрисовки на старте TX. + if FAnalyzerOpen then + ResetPixelBuffers(TX_DISP_ID); end; - // Сброс TX-анализатора: его усредняющая история хранит спектр ПРОШЛОЙ - // сессии (после TUN — тон), и при переключении на TX-дисплей первые - // кадры показывали «всплеск» старого тона (виден в non-DUP, где на - // экране TX-спектр; эфир при этом чист — в DUP старт чистый). - // ResetPixelBuffers — лёгкое зануление pixel/average буферов (MW0LGE); - // НЕ ApplyTXAnalyzerSettings: тот зовёт SetAnalyzer (перепланирование - // FFT в UI-потоке) — давал видимую задержку отрисовки на старте TX. - if FAnalyzerOpen then - ResetPixelBuffers(TX_DISP_ID); + // TX-DSP поток создаётся ОДИН раз и живёт весь сеанс. НЕ пересоздаём на каждом + // T/R: join (WaitFor) давал фиксированные ~100 мс фриза GUI на отпускании PTT. + // При FTXActive=False поток простаивает (см. TTXDSPThread.Execute: Continue). + if not Assigned(FTXThread) then + begin + FTXThread := TTXDSPThread.Create(Self); + FTXThread.Priority := tpHighest; + end; + FTXActive := True; // активируем последним + finally + FTXLock.Leave; end; - // TX-DSP поток создаётся ОДИН раз и живёт весь сеанс. НЕ пересоздаём на каждом - // T/R: join (WaitFor) давал фиксированные ~100 мс фриза GUI на отпускании PTT. - // При FTXActive=False поток простаивает (см. TTXDSPThread.Execute: Continue). - if not Assigned(FTXThread) then - begin - FTXThread := TTXDSPThread.Create(Self); - FTXThread.Priority := tpHighest; - end; - FTXActive := True; // активируем последним end else begin FTXActive := False; // поток уходит в простой (не трогает канал/ring) FActiveDisplayID := RX_DISP_ID; @@ -3863,12 +3910,21 @@ begin // 10мс) и штатный flush буферов исполняют наши же fexchange0 с нулевым // входом — суммарно <1мс, выход в мусор (в эфир не идёт: DUC-очередь // чистится контроллером на T/R). - SetChannelState(TXA_CHAN, 0, 0); // stop, без ожидания - FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0); - for k := 1 to 4 do - begin - Err := 0; - fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err); + // ★Под локом: FTXActive уже опущен, но поток мог войти в блок раньше — + // лок дожидается его конца и не пускает в канал следующий (гейт он + // перечитывает под этим же локом). Ждать приходится не дольше одного + // блока TXA — сотня-другая микросекунд. + FTXLock.Enter; + try + SetChannelState(TXA_CHAN, 0, 0); // stop, без ожидания + FillChar(FTXIn[0], FAudioBufSize * 2 * SizeOf(Double), 0); + for k := 1 to 4 do + begin + Err := 0; + fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err); + end; + finally + FTXLock.Leave; end; end; FTXMicHead := 0; @@ -4027,6 +4083,11 @@ begin RTLEventSetEvent(FTXMicSem); // будим TX поток end; +function TWDSPEngine.GetTXMicFill: Integer; +begin + Result := (FTXMicHead - FTXMicTail + TX_MIC_RING) and (TX_MIC_RING - 1); +end; + procedure TWDSPEngine.PushTXMicSampleD(const S: Double); var newHead: Integer; begin diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index 6d5a36b..e448fb7 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -2002,7 +2002,7 @@ var Cfg: TTCISettings; C: TRawClient; Markers, Answered, Quantum, LongGaps, MaxGapMs, MinReserve: Integer; - N, MinStart, SumStart, StaleRuns: Integer; + N, MinStart, SumStart, StaleRuns, i, FillBefore: Integer; DbgOwed, DbgFlight: Double; DbgWin, DbgQ, DbgLead, SeedLead: Integer; begin @@ -2048,6 +2048,20 @@ begin C.WaitText('trx:', 1500); Check('пейсинг: модуляция из TCI взята', Ctrl.TCIMicActive); + // ── Повторный MOX поверх идущей передачи ── + // ★Прислать MOX=True второй раз вправе и CAT, и web, и TCI, а SetMOX этого + // не отсекает. Перезапускать по нему TX-тракт нельзя: сброс индексов + // mic-кольца гоняется с чтением из TX-потока, и в худшем случае поток + // дописывает свой tail поверх обнулённого — кольцо выглядит почти полным + // СТАРЫХ данных, которые уходят в эфир пачкой. + for i := 1 to 3000 do Ctrl.FDSPEngine.PushTXMicSampleD(0.25); + FillBefore := Ctrl.FDSPEngine.TXMicFill; + Ctrl.SetMOX(True); + Check('повтор MOX: передача не перезапускается', Ctrl.FDSPEngine.TXActive); + Check('повтор MOX: mic-кольцо не обнуляется', + Ctrl.FDSPEngine.TXMicFill >= FillBefore - 512, + Format('было %d, стало %d', [FillBefore, Ctrl.FDSPEngine.TXMicFill])); + // ── Здоровый клиент ── // Прогрев: пока шёл разбор команды и проверки выше, клиент не отвечал, и // окно в полёте успело набиться маркерами. Плюс за это время адаптер выдаёт