fix(tci): писатель WAV — один поток с очередью, файл публикуется атомарно

SAVE запускал поток на каждую запись с FreeOnTerminate: его никто не держал
и никто не ждал. Замерено отдельным процессом — при штатном выходе сразу
после сохранения от ожидаемых 100000044 байт на диске оставалось 40960, а
заголовок заявлял полную длину; на медленном каталоге идущие подряд SAVE
плодили сотни потоков, чья память стеков в 128-МБ бюджет не входила.

Теперь писатель один и принадлежит адаптеру (лениво на первом SAVE), очередь
ограничена TCI_RECORD_MAX_JOBS = 16 (сверх — клиенту writer busy и возврат
резерва), а деструктор адаптера гасит его через TCIStopWriter: Close →
WaitDrained (без срока) → Free. Срока здесь нет намеренно: поток, стоящий в
write/fsync, изнутри процесса не останавливается (Terminate не указ, Free
обязан WaitFor, бросить живой TThread нельзя — он ходит в общий бюджет),
поэтому срок не ограничивал выход, а только терял подтверждённые клиенту
записи. Ограниченный выход = писатель отдельным процессом, одним TThread не
делается; это записано в коде и в доке.

Результат записи больше не игнорируется: FileWrite возвращает число байт и
при ошибке даёт 0/-1 без исключения, поэтому на полном диске файл спокойно
дописывался до конца огрызком. TCIWriteAll — цикл с проверкой каждого вызова,
плюс FileFlush перед публикацией (на ext4 с отложенным размещением ENOSPC
приходит именно там).

Файл появляется под целевым именем целиком или не появляется вовсе: данные
пишутся во временный файл рядом (эксклюзивно, не по симлинку), а публикует
их TCIPublishFile — renameat2(RENAME_NOREPLACE) напрямую через Do_SysCall,
если его нет — link + unlink, если нет и ссылок (FAT/exFAT, часть CIFS/SMB и
FUSE) — отказ с сохранением данных в .part. FileExists + rename не делается
нигде: это тот самый TOCTOU. На Windows — MoveFileW без REPLACE_EXISTING.

doc/TCI.md: §2.5 переписан (писатель-очередь, остановка, публикация); заодно
исправлено устаревшее описание склейки кусков в Take (её нет с 7aae0fd).

Стенд test/tci: 214 проверок (было 199), все зелёные. Новое — временный файл
убирается после удачи, неудачная запись не оставляет ни файла, ни .part, за
всё время записи 32 МБ целевое имя ни разу не видно незаконченным, отказ
сверх потолка заданий, после Close заданий не берут, TCIStopWriter дожидается
и самой записи, и хвоста очереди за ней. Все новые гарантии прогнаны
негативным контролем; путь link проверен сборкой с выключенным renameat2,
сам renameat2 — под strace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:40:31 +03:00
co-authored by Claude Opus 5
parent 7aae0fdfcd
commit 3dc7035f9e
4 changed files with 734 additions and 134 deletions
+114 -16
View File
@@ -508,10 +508,97 @@ web-клиента: явная просьба сильнее умолчания
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у
ExpertSDR3 давно бы не было.
`SAVE` завершает запись и отдаёт буфер отдельному потоку-писателю:
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
`SAVE` завершает запись и ставит её в очередь писателю: файл бывает в десятки
мегабайт, а команда пришла в потоке клиента, который в это время не читает свой
сокет. MP3 не поддержан — кодера в проекте нет, и на `.mp3` уходит честный
`tci_error`.
**★Писатель — один поток с очередью, и он принадлежит адаптеру.** Раньше
`SAVE` был равен «создать поток с `FreeOnTerminate`»: его никто не держал и
никто не ждал. Отсюда две беды сразу.
- **Штатный выход из программы обрывал запись.** Замерено отдельным процессом:
при закрытии сразу после `SAVE` от ожидаемых 100000044 байт на диске
оставалось 40960 — то, что успел сбросить буфер файловой системы, — а
заголовок при этом заявлял полную длину.
- **Медленный (или зависший сетевой) каталог плодил потоки.** Идущие подряд
сохранения запускали их сотнями, и память их стеков в 128-МБ бюджет не
входила вовсе.
Теперь писатель один (`TTCIWavWriter`, заводится лениво — на первом `SAVE`),
очередь ограничена `TCI_RECORD_MAX_JOBS` = 16 заданиями (сверх — клиенту
`writer busy`, а место в бюджете возвращает `CmdRecorder`), а деструктор
адаптера гасит его через `TCIStopWriter`: закрыть приём заданий, дождаться,
пока очередь допишется, и освободить.
**★Срока у этого ожидания нет намеренно** — и это вывод из двух неверных
попыток его завести. Внутрипроцессный поток, стоящий в `write(2)` или `fsync`,
остановить нечем: `Terminate` ему не указ, а `Free` обязан сделать `WaitFor`
бросить живой `TThread` нельзя, он ходит в общий бюджет (`RecBudgetLock`),
который освобождает финализация юнита. Значит, срок **не ограничивает выход**:
на мёртвом каталоге программа всё равно стоит в syscall. Ограничивал он ровно
одно — сколько подтверждённых клиенту записей мы выбросим по дороге (сперва
весь остаток очереди по общему сроку, потом, с отсчётом от последнего
продвижения, — остаток очереди на каталоге, который тормозил дольше срока и
оживал). То есть не покупал ничего и стоил данных. Убран вместе с
`FProgress`/`Advance`/`Terminate` в остановке.
Так что при закрытии программы очередь **дописывается вся**, а поток ждётся
по-настоящему (`Destroy` = `Terminate` + `WaitFor`): ни брошенных потоков, ни
выброшенных записей после нас не остаётся. Цена названа прямо: на мёртвой
сетевой ФС выход подвиснет вместе с syscall'ом. Кому нужен гарантированно
ограниченный выход — писателя придётся выносить в отдельный процесс, который
гасится средствами ОС; одним `TThread` это не делается.
**★Файл появляется целиком или не появляется вовсе.** Прежний код не смотрел
на результат записи: `FileWrite` (как и `THandleStream.Write` под ним)
возвращает **число записанных байт** и при ошибке отдаёт 0 или -1, не поднимая
исключения, — то есть на полном диске файл дописывался «до конца» и оставался
огрызком с заголовком на полную длину. Теперь:
1. данные пишутся во **временный файл** рядом (`<имя>.<pid>-<n>.part`,
создаётся эксклюзивно и не по симлинку — `TCICreateNewFile`), циклом, с
проверкой каждого вызова (короткая запись законна, её дописываем; 0 или
-1 — провал);
2. перед публикацией идёт `FileFlush`: на ext4 с отложенным размещением «нет
места» приходит не в `write`, а именно здесь;
3. и только после этого файл появляется под целевым именем — **одним вызовом
ядра и сразу целиком** (`TCIPublishFile`).
**★Целевого имени до этого момента не существует вовсе, и публикация ничего не
заменяет.** Промежуточный вариант — занять имя пустым файлом заранее — был
хуже обоих: на медленном диске клиент всю запись видел WAV на 0 байт, а по
имени файла он вправе считать запись готовой.
Портируемо получить сразу три свойства — атомарное появление, запрет замены и
работу на любой файловой системе — нельзя, поэтому `TCIPublishFile` идёт по
списку и на последнем шаге честно отказывается:
1. **`renameat2(AT_FDCWD, tmp, AT_FDCWD, dst, RENAME_NOREPLACE)`** — ровно то,
что нужно, одним вызовом ядра. Обёртки в RTL нет, зовём напрямую через
`Do_SysCall` (номера ABI: x86_64 316, i386 353, aarch64 276, arm 382);
`EEXIST` — имя занято, это отказ по существу.
2. **`link(2)` + `unlink`** — если `renameat2` нет (старое ядро, другая
архитектура, ФС не умеет флаг: `ENOSYS`/`EINVAL`). Семантика та же: новое
имя обязано не существовать, иначе `EEXIST`, и на симлинк по этому имени
`link` тоже не пойдёт. Каталог у временного и целевого файла один
(`tci.record_dir`), так что `EXDEV` здесь не бывает.
3. **Отказ**, если нет и жёстких ссылок (FAT/exFAT, часть CIFS/SMB и FUSE).
`FileExists` + `rename` в этом месте недопустим: `rename` затирает то, что
лежит по имени СЕЙЧАС, а между проверкой и переносом туда может попасть что
угодно — это ровно то окно (TOCTOU), ради закрытия которого всё и
затевалось. Данные при этом не пропадают: писатель **оставляет их во
временном файле** `<имя>.wav.<pid>-<n>.part` — сказать клиенту уже нечем
(`SAVE` подтверждён давно), а стирать его десятки мегабайт из-за нашей
неспособности переименовать хуже, чем оставить их лежать. Лечится
настройкой `tci.record_dir` на обычную файловую систему.
На Windows нужной семантикой обладает `MoveFileW` без
`MOVEFILE_REPLACE_EXISTING`: существующее имя = отказ.
Провал записи (и занятое имя) убирает временный файл и не оставляет целевого;
единственное исключение — шаг 3 выше. Клиент, увидевший файл под запрошенным
именем, всегда вправе считать запись готовой.
**★Память рекордера — три замка, и все три нужны.** Авторизации в протоколе
нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос
@@ -523,8 +610,9 @@ ExpertSDR3 давно бы не было.
1. **Память набирается кусками по секунде, а не вся сразу.** `START` не стоит
ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит
ничего вовсе. Куски не перевыделяются (никакого `realloc` в DSP-потоке) и
склеиваются один раз — в `Take`, а он идёт уже после того, как рекордер
вынут из таблицы, то есть без DSP-потока на плечах.
**не склеиваются вовсе**: `Take` отдаёт их писателю как есть, а тот пишет
их в файл подряд. Сам `Take` идёт уже после того, как рекордер вынут из
таблицы, то есть без DSP-потока на плечах.
2. **Общий бюджет `TCI_RECORD_MAX_BYTES` (128 МБ) на все рекордеры сразу.**
Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное
остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы
@@ -535,10 +623,10 @@ ExpertSDR3 давно бы не было.
бюджете рядом жили бы 128 МБ кусков и 128 МБ копии, а счётчик показывал бы
128. Куски и так лежат встык, поэтому `TTCIWavWriter` пишет их в файл
подряд (хвост последнего за `Count` — не данные), а место отпускает в
`ReleaseData` — ровно один раз, сколько бы путей выхода ни было у
`Execute`. Пока писатель ждёт медленный или зависший сетевой каталог, эти
байты остаются занятыми: потолок держит и очередь сохранений, а не только
сами записи.
`ReleaseJob` — ровно один раз, сколько бы путей выхода ни было у записи.
Пока писатель ждёт медленный или зависший сетевой каталог, эти байты
остаются занятыми: потолок держит и очередь сохранений, а не только сами
записи.
3. **Освобождение по трём событиям, а не по одному.** Раньше срок проверял
только `Feed`, то есть DSP-поток — а к мёртвому приёмнику он не приходит
никогда, и `START` на несуществующий номер оставлял память навсегда.
@@ -562,10 +650,12 @@ ExpertSDR3 давно бы не было.
(`bad file name`). Выйти за каталог после `ExtractFileName` нечем:
разделителей в имени уже не осталось, а `..` не проходит проверку. Файл
создаётся **эксклюзивно** (`TCICreateNewFile`: `O_EXCL or O_NOFOLLOW` на Unix,
`CREATE_NEW` на Windows) — существующий не перезаписывается и симлинк не
уводит наружу, причём одним вызовом ядра, без окна между `FileExists` и
созданием. Клиенту про уже занятое имя отвечаем `file exists` до постановки
задачи писателю.
`CREATE_NEW` на Windows) — симлинк не уводит наружу, причём одним вызовом
ядра, без окна между `FileExists` и созданием; существующий файл не
перезаписывается и при публикации (`renameat2(RENAME_NOREPLACE)`/`link`/
`MoveFileW` — все без замены, см. выше). Клиенту про уже занятое имя отвечаем
`file exists` до постановки задачи писателю — но это вежливость, а не защита:
защита в самих вызовах.
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
@@ -843,7 +933,7 @@ ExpertSDR3 давно бы не было.
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 158 проверок, все зелёные
### Стенд этапа 2 (бинарные потоки) — 214 проверок, все зелёные
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
@@ -874,7 +964,15 @@ ExpertSDR3 давно бы не было.
само, `Take` отдаёт САМИ КУСКИ (2.5 с записи = три куска по секунде, а не
один свёрнутый — сплошной копии не появляется ни на миг), счёт бюджета при
этом не меняется, склеенные куски дают непрерывный звук, и писатель
возвращает резерв по окончании записи. ★Имя файла: простое имя ложится в каталог записей, а каталог из
возвращает резерв по окончании записи. ★Писатель: задание принимается и
дописывается очередью, после удачи временного файла рядом не остаётся,
неудачная запись (счёт обещает больше, чем есть в кусках) не оставляет НИ
файла, НИ `.part`, за всё время записи 32 МБ целевое имя ни разу не видно
незаконченным, сверх потолка заданий следует отказ, после `Close` заданий не
берут, а `TCIStopWriter` дожидается 32-МБ записи — файл цел сразу после его
возврата, и **хвост очереди за ней дописан тоже** (это и есть проверка на
обрыв при выходе; без ожидания краснеют обе).
★Имя файла: простое имя ложится в каталог записей, а каталог из
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
записей дают отказ; существующий файл писатель не перезаписывает.