From 66892fc7e0dd92e32f830cae9b11c3ba895877ea Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 19 Aug 2026 22:02:40 +0300 Subject: [PATCH] =?UTF-8?q?fix(tci):=20=D0=BF=D0=B0=D1=80=D0=B0=20=C2=AB?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82+=D0=BF=D1=80=D0=B8=D1=91?= =?UTF-8?q?=D0=BC=D0=BD=D0=B8=D0=BA=C2=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=87=D0=B8;=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B1=D0=BE=D1=80=20=D0=B8=D0=BC=D1=91=D0=BD=20.pa?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] FTxClient/FTxRx писались ДО Invoke, и команда, которая ничего не сделала, всё равно их перебивала. Клиент, уже передающий с приёмника 1, шлёт trx:0,true,tci — передатчик занят, SyncSetTRX не делает ничего (Started=False), а маркеры ИДУЩЕЙ передачи с этого мига уходят под номером 0. MSHV такие блоки отбрасывает (network.cpp:231), то есть передача просто замолкает. Теперь пара назначается после Invoke и только при Started — тем же признаком, по которому назначается хозяин эфира. Тот же гейт закрывает близнеца: trx:,true без ',tci' поверх своей же передачи больше не снимает источник модуляции. Снятие (',false') работает как прежде. [P2] Имя временного файла (pid + счётчик) уникально внутри процесса, но не между запусками: «.part», оставшийся от прошлой жизни (публиковать было нечем), плюс повторно выданный системой pid дают EEXIST на создании — и задание пропадало молча. TCICreateTempNear перебирает до 64 имён, но только пока ошибка — «имя занято»: нет прав или каталога перебором не лечится. Стенд test/tci: 219 проверок (было 217). Новое — занятое имя «.part» записи не теряет (стенд занимает ровно то имя, которое возьмёт писатель) и пустая команда TRX не меняет номер приёмника в маркерах. Негативный контроль на обе правки. Попутно в тесте поправлены два комментария, описывавшие прежнюю реализацию публикации. Co-Authored-By: Claude Opus 5 --- TCIAdapter.pas | 37 +++++++++++++++++++----- TCIStreams.pas | 41 ++++++++++++++++++++++++-- doc/TCI.md | 20 +++++++++---- test/tci/tcitest.pas | 69 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 143 insertions(+), 24 deletions(-) diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 8d225b4..1573336 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -1962,14 +1962,6 @@ begin Tune := M.Name = 'TUNE'; Name := LowerCase(Trim(TCIArg(M, 2))); FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client); - FTxLock.Enter; - try - // ★Вместе с клиентом запоминаем НОМЕР ПРИЁМНИКА, которым он назвался: - // маркеры TX_CHRONO обязаны идти под ним же (см. PushTxChrono). - if FromTCI then begin FTxClient := Client; FTxRx := Rx; end - else if (not Tune) and (FTxClient = Client) then - begin FTxClient := nil; FTxRx := 0; end; - finally FTxLock.Leave; end; Started := False; FLock.Enter; try @@ -1991,6 +1983,35 @@ begin try if Started then FTrxOwner := Client else if (not B) and (FTrxOwner = Client) then FTrxOwner := nil; + // Источник модуляции и НОМЕР ПРИЁМНИКА, под которым идут маркеры + // TX_CHRONO (см. PushTxChrono), — по тому же правилу: меняем их + // ТОЛЬКО когда команда и правда что-то сделала. + // ★Раньше пара «клиент+приёмник» писалась до Invoke, и команда, которая + // ничего не изменила, всё равно её перебивала: клиент, уже + // передававший с приёмника 1, присылает trx:0,true,tci — передатчик + // занят, SyncSetTRX не делает ничего, а маркеры его же идущей передачи + // с этого мига уходят под номером 0. MSHV такие блоки отбрасывает + // (network.cpp:231), то есть передача просто замолкает. + if B then + begin + if Started and FromTCI then + begin + FTxClient := Client; + FTxRx := Rx; + end + // Эфир поднял он же, но модуляцию просит НЕ из TCI — снимаем свою. + else if Started and (not Tune) and (FTxClient = Client) then + begin + FTxClient := nil; + FTxRx := 0; + end; + end + // Сам просит выключить — источник ему больше не нужен в любом случае. + else if (not Tune) and (FTxClient = Client) then + begin + FTxClient := nil; + FTxRx := 0; + end; finally FTxLock.Leave; end; end; // ★Автору отвечаем ЕГО номером приёмника (см. StrTrx): клиент фильтрует diff --git a/TCIStreams.pas b/TCIStreams.pas index 7526a1b..5a4a0a6 100644 --- a/TCIStreams.pas +++ b/TCIStreams.pas @@ -353,6 +353,11 @@ function TCIRecordPath(const BaseDir, Req: string): string; имя приходит из сети. THandle(-1) — не вышло. } function TCICreateNewFile(const Path: string): THandle; +{ Очередное имя временного файла рядом с целью: <путь>.-.part. Каждый + вызов даёт следующее. Наружу открыто ради стенда — ему нужно занять ровно то + имя, которое возьмёт писатель. } +function TCIRecTempName(const Path: string): string; + { Погасить писателя: закрыть приём заданий, дождаться ВСЕЙ очереди и освободить объект (Destroy = Terminate + WaitFor). Зовут при гибели адаптера, ради этого писатель и стал управляемым. W обнуляется в любом @@ -972,6 +977,37 @@ begin [Path, Integer(GetProcessID), InterLockedIncrement(RecTmpSeq)]); end; +function TCILastErrorIsExists: Boolean; +// «Такое имя уже есть» — единственная ошибка создания, которую лечит другое имя. +begin +{$IFDEF UNIX} + Result := fpGetErrno = ESysEEXIST; +{$ELSE} + Result := (GetLastError = ERROR_ALREADY_EXISTS) or + (GetLastError = ERROR_FILE_EXISTS); +{$ENDIF} +end; + +function TCICreateTempNear(const Path: string; out Tmp: string): THandle; +// Временный файл рядом с целью, с перебором имён. +// ★pid + счётчик уникальны внутри процесса, но не между запусками: «.part», +// оставшийся от прошлой жизни (публиковать было нечем — см. TCIPublishFile), +// плюс повторно выданный системой pid дают EEXIST, и задание пропадало бы +// молча. Занятое имя — не повод терять запись, берём следующее. +var i: Integer; +begin + for i := 1 to 64 do + begin + Tmp := TCIRecTempName(Path); + Result := TCICreateNewFile(Tmp); + if Result <> THandle(-1) then Exit; + // Не «имя занято» (нет прав, нет каталога) — перебор не поможет. + if not TCILastErrorIsExists then Break; + end; + Tmp := ''; + Result := THandle(-1); +end; + function TCIWriteAll(H: THandle; const Buf; Count: Integer): Boolean; // ★Результат записи проверяем, и не «= Count», а циклом. FileWrite (как и // THandleStream.Write под ним) возвращает ЧИСЛО записанных байт и при ошибке @@ -1276,9 +1312,8 @@ begin // Так клиент, увидевший файл, всегда прав: раньше по имени сначала // появлялась пустышка на 0 байт, и на медленном диске её было видно всю // запись, а до того — огрызок с заголовком на полную длину. - Ok := False; - Tmp := TCIRecTempName(J.Path); - HT := TCICreateNewFile(Tmp); + Ok := False; + HT := TCICreateTempNear(J.Path, Tmp); if HT <> THandle(-1) then begin try diff --git a/doc/TCI.md b/doc/TCI.md index f232958..1f5ad9b 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -503,7 +503,11 @@ TX-аудио не по своей воле, а строго в ответ на сидящий на **втором слайсе** (`tci_trx = 1`), маркеров не видел вовсе: эфир по `trx:1,true,tci` поднимался, а звука не было ни одного блока. Поэтому вместе с клиентом-модулятором запоминается номер приёмника из его же `TRX` -(`FTxRx`), и маркеры идут под ним. +(`FTxRx`), и маркеры идут под ним. ★Пара «клиент + приёмник» меняется **только +когда команда и правда что-то сделала** (`Started`, тот же признак, по которому +назначается хозяин эфира): иначе клиент, уже передающий с приёмника 1, своим же +`trx:0,true,tci` — командой-пустышкой, потому что передатчик занят, — перевёл бы +маркеры идущей передачи на номер 0 и замолчал. **★`rfTransmitting` — не всегда «эфир изменился».** Контроллер шлёт это поле и просто «перерисуй TX-бейджи»: `SetTxSlice` заканчивается `Changed(rfTransmitting)`, @@ -580,7 +584,11 @@ ExpertSDR3 давно бы не было. 1. данные пишутся во **временный файл** рядом (`<имя>.-.part`, создаётся эксклюзивно и не по симлинку — `TCICreateNewFile`), циклом, с проверкой каждого вызова (короткая запись законна, её дописываем; 0 или - -1 — провал); + -1 — провал). ★Имя перебирается (`TCICreateTempNear`): pid со счётчиком + уникальны внутри процесса, но не между запусками, и `.part`, оставшийся от + прошлой жизни, вместе с повторно выданным системой pid дал бы `EEXIST` — + занятое имя не повод терять запись, а вот другая ошибка (нет прав, нет + каталога) перебором не лечится и обрывает попытку сразу; 2. перед публикацией идёт `FileFlush`: на ext4 с отложенным размещением «нет места» приходит не в `write`, а именно здесь; 3. и только после этого файл появляется под целевым именем — **одним вызовом @@ -954,7 +962,7 @@ ExpertSDR3 давно бы не было. движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и неразрывность пачки инициализации под крутящейся ручкой. -### Стенд этапа 2 (бинарные потоки) — 217 проверок, все зелёные +### Стенд этапа 2 (бинарные потоки) — 219 проверок, все зелёные Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, внешних библиотек не требует) проверяет потоки на четырёх уровнях: @@ -988,7 +996,8 @@ ExpertSDR3 давно бы не было. возвращает резерв по окончании записи. ★Писатель: задание принимается и дописывается очередью, после удачи временного файла рядом не остаётся, неудачная запись (счёт обещает больше, чем есть в кусках) не оставляет НИ - файла, НИ `.part`, за всё время записи 32 МБ целевое имя ни разу не видно + файла, НИ `.part`, занятое имя `.part` (файл от прошлого запуска с тем же + pid) записи не теряет, за всё время записи 32 МБ целевое имя ни разу не видно незаконченным, сверх потолка заданий следует отказ, после `Close` заданий не берут, а `TCIStopWriter` дожидается 32-МБ записи — файл цел сразу после его возврата, и **хвост очереди за ней дописан тоже** (это и есть проверка на @@ -1025,7 +1034,8 @@ ExpertSDR3 давно бы не было. аудио блоками с `receiver = 1`, ★по `trx:1,true,tci` берёт модуляцию из TCI (то есть просьбу не стирает `Changed(rfTransmitting)` из `SetTxSlice`) и шлёт маркеры `TX_CHRONO` **под номером 1** — оба этих места и ломали передачу - MSHV, сидящего на втором слайсе, а после удаления слайса приёмник 1 замолкает + MSHV, сидящего на втором слайсе; а `trx:0,true,tci` поверх этой же передачи + (команда-пустышка) номер приёмника в маркерах не меняет, а после удаления слайса приёмник 1 замолкает целиком — вместо прежнего `vfo:1,0,0`. - **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index 94b2d20..b8b99db 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -595,6 +595,20 @@ begin Result := W.Pending = 0; end; +function NextTempName(const Name: string): string; +// Имя «.part», следующее за данным: у писателя счётчик один на процесс, и +// стенду нужно занять именно то имя, которое он возьмёт после нашего вызова. +var i, p1, p2: Integer; +begin + p2 := Pos('.part', Name); + p1 := p2; + for i := p2 - 1 downto 1 do + if Name[i] = '-' then begin p1 := i; Break; end; + Result := Copy(Name, 1, p1) + + IntToStr(StrToIntDef(Copy(Name, p1 + 1, p2 - p1 - 1), 0) + 1) + + '.part'; +end; + function PartFiles(const Path: string): Integer; // Сколько временных файлов писателя осталось рядом с целью. var SR: TSearchRec; @@ -624,7 +638,7 @@ var Tk: TTCIRecTake; Big: TTCIPcm; Refused, k, Bad: Integer; - Path2: string; + Path2, Squat: string; begin WriteLn('C. Рекордер линейного выхода'); @@ -831,9 +845,10 @@ begin finally FS.Free; end; - // ★Данные пишутся во временный файл и переименовываются поверх занятого - // имени: по имени файла клиент считает запись готовой, и незаконченного - // содержимого он там видеть не должен. После удачи «.part» не остаётся. + // ★Данные пишутся во временный файл, а под целевым именем он появляется + // одним вызовом ядра и уже целым: по имени файла клиент считает запись + // готовой, и незаконченного содержимого он там видеть не должен. После + // удачи «.part» рядом не остаётся. Check('WAV: временный файл убран', PartFiles(Path) = 0, IntToStr(PartFiles(Path))); @@ -849,15 +864,15 @@ begin TCIRecBudgetUsed = Was, IntToStr(TCIRecBudgetUsed - Was)); // ★Существующий файл не трогаем: имя приходит из сети, и fmCreate затирал - // бы любой доступный процессу файл. Пишем поверх заведомо другой длиной — - // файл обязан остаться прежним. + // бы любой доступный процессу файл. Публикация идёт без замены + // (renameat2(RENAME_NOREPLACE) → link), так что занятое имя = отказ. Пишем + // поверх заведомо другой длиной — файл обязан остаться прежним. Tk := MakeTake(Copy(Data, 0, 10), 0); W.Enqueue(Path, Tk, 48000); Drained(W, 5000); FS := TFileStream.Create(Path, fmOpenRead); try - // Публикация идёт link(2)/MoveFileW без замены: занятое имя = отказ. - Check('WAV: существующий файл не перезаписан', + Check('WAV: существующий файл не перезаписан', FS.Size = 44 + 2000 * 2, IntToStr(FS.Size)); finally FS.Free; @@ -866,6 +881,21 @@ begin DeleteFile(Path); end; + // ★Имя «.part» уникально внутри процесса (pid + счётчик), но не между + // запусками: файл, оставшийся от прошлой жизни (публиковать было нечем), + // плюс повторно выданный системой pid дают EEXIST на создании — и задание + // пропадало бы молча. Занимаем ровно то имя, которое писатель возьмёт + // следующим, и убеждаемся, что запись всё равно легла. + DeleteFile(Path); + Squat := NextTempName(TCIRecTempName(Path)); + FS := TFileStream.Create(Squat, fmCreate); + FS.Free; + W.Enqueue(Path, MakeTake(Data, 0), 48000); + Drained(W, 5000); + Check('WAV: занятое имя «.part» не теряет запись', FileExists(Path)); + DeleteFile(Squat); + DeleteFile(Path); + // ★Запись не удалась на полпути — по имени не остаётся НИЧЕГО. Прежний код // не смотрел на результат записи вовсе (FileWrite и THandleStream.Write при // ошибке возвращают 0 и не поднимают исключения), и на полном диске @@ -1788,6 +1818,29 @@ begin Check('TX слайса: маркеры TX_CHRONO идут', n > 0, IntToStr(n)); Check('TX слайса: маркер назван номером приёмника (MSHV фильтрует)', (n > 0) and (BadRx = 0), IntToStr(BadRx)); + + // ★Команда, которая ничего не сделала, не должна перебивать пару + // «клиент + приёмник»: передатчик занят своей же передачей, SyncSetTRX + // на неё не отвечает ничем — а если запомнить номер заранее, маркеры + // идущей передачи уедут под чужим номером, и клиент замолчит. + C.SendText('trx:0,true,tci;'); + C.WaitText('trx:', 1000); + C.Pump(300); + n := 0; + BadRx := 0; + while C.NextFrame(Op, Pay) do + if (Op = $02) and (Length(Pay) >= SizeOf(H)) then + begin + Move(Pay[0], H, SizeOf(H)); + if H.StreamType = LongWord(Ord(tstTXChrono)) then + begin + Inc(n); + if H.Receiver <> 1 then Inc(BadRx); + end; + end; + Check('TX слайса: пустая команда не сменила номер приёмника', + (n > 0) and (BadRx = 0), IntToStr(BadRx)); + C.SendText('trx:1,false;'); C.WaitText('trx:', 1000); Ctrl.SetSliceSlotAutoTx(Ctrl.SliceSlotOf(SliceId), False);