fix(tci): рекордер линейного выхода — память под потолком, файл только в своём каталоге

Два дефекта уровня P1 в LINE_OUT_RECORDER_*. Авторизации в протоколе нет
(§3.1), bind наружу разрешён — значит «сколько стоит одна строка из сети»
это вопрос живучести процесса, а не аккуратности.

1. Неограниченное выделение памяти. CmdRecorder проверял только ValidRx
   (номер в потолке), а конструктор выделял буфер целиком: 300 с × 48 кГц
   × 2 канала × int16 = 57.6 МБ на команду, приёмников 1+MAX_SLICES=7, то
   есть 403 МБ семью строками. У мёртвого приёмника Feed не зовут — значит
   срок записи никто не проверял; уход клиента рекордеры не трогал вовсе;
   DropDeadRxStreams бежит только на rfDevice/rfConnected и смене карты
   слайсов, в покое не срабатывает. Память жила до остановки сервера.

   Лечение тремя замками:
   - память набирается кусками по секунде, START не стоит ни байта;
     куски не перевыделяются (никакого realloc в DSP-потоке) и склеиваются
     один раз в Take — уже после того, как рекордер вынут из таблицы;
   - общий бюджет TCI_RECORD_MAX_BYTES (128 МБ) на все рекордеры сразу,
     спрашивается на каждый кусок; отказ не рушит запись, набранное
     остаётся сохраняемым;
   - освобождение по трём событиям: START требует живого приёмника
     (RxActive, ответ receiver is not running), тик сервера подметает
     истёкшие окна по часам (SweepRecorders — окно закрывается от START
     и без единого блока звука), уход клиента забирает его записи
     (DropClientRecorders; рекордер живёт на приёмнике, но платит за него
     тот, кто нажал START).

2. Перезапись произвольного файла. Путь из сети уходил в fmCreate почти
   как пришёл — вместе с '..' и абсолютными путями. Теперь TCIRecordPath
   берёт из строки ТОЛЬКО имя файла, каталог — настроенный tci.record_dir
   (пусто = <каталог конфигурации>/records). Каталог из просьбы
   отбрасывается молча: полный путь на сервере клиенту всё равно
   бесполезен, файл ложится не на его машину. Имя валидируется (пусто,
   '.', '..', управляющие, ':', длиннее 120, расширение не .wav →
   bad file name); после ExtractFileName выйти за каталог нечем. Файл
   создаётся эксклюзивно (TCICreateNewFile: O_EXCL|O_NOFOLLOW на Unix,
   CREATE_NEW на Windows) — ни перезаписи, ни симлинка, без окна между
   FileExists и созданием; клиенту заранее file exists.

Попутно: MainForm.ApplyTCISettings собирал TTCISettings по полям с
чистого листа — новое поле RecordDir обнулялось бы при каждом применении
вкладки CAT. В uses TCIStreams Windows стоит первым намеренно: иначе его
TCriticalSection перекрыл бы SyncObjs (та же грабля, что в DX-кластере).

Стенд 189/189 (было 169): 11 проверок имени файла (/etc/passwd.wav,
../../.., D|\rec\a.wav), бюджет (START не выделяет, растёт кусками,
потолок, отказ не рушит запись, срок истекает без Feed), существующий WAV
не перезаписывается, START на мёртвом приёмнике и с чужим номером, и
сквозная проверка в части E — клиент стартует запись, набирает память
живым звуком через WDSP, рвёт TCP, бюджет возвращается к нулю. Без фиксов
новые проверки краснеют. GUI (--ws=qt6) и демон зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:30:36 +03:00
co-authored by Claude Opus 5
parent adbb8d02ac
commit 79132f133c
7 changed files with 609 additions and 54 deletions
+64 -2
View File
@@ -136,9 +136,16 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
Секция `tci` в корне `settings.json`:
```json
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" }
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1",
"record_dir": "" }
```
`record_dir` — единственный каталог, куда TCI пишет записи линейного выхода;
пусто = `<каталог конфигурации>/records`, каталог создаётся при первом `SAVE`.
Поля в UI у него нет намеренно: это не рабочая настройка, а граница, и менять
её осмысленно только руками в файле. Почему каталог вообще существует и почему
путь из команды в него не попадает — §2.5, «Имя файла: каталог всегда наш».
UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
и оператор ищет его там, а не в «Advanced». В протоколе
@@ -506,6 +513,50 @@ ExpertSDR3 давно бы не было.
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
**★Память рекордера — три замка, и все три нужны.** Авторизации в протоколе
нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос
не об аккуратности, а о живучести процесса. Раньше `START` выделял буфер
целиком: 300 с × 48 кГц × 2 канала × int16 = 57.6 МБ на команду, а приёмников
1 + `MAX_SLICES` = 7, то есть 403 МБ семью строками, и держать их можно было
сколько угодно.
1. **Память набирается кусками по секунде, а не вся сразу.** `START` не стоит
ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит
ничего вовсе. Куски не перевыделяются (никакого `realloc` в DSP-потоке) и
склеиваются один раз — в `Take`, а он идёт уже после того, как рекордер
вынут из таблицы, то есть без DSP-потока на плечах.
2. **Общий бюджет `TCI_RECORD_MAX_BYTES` (128 МБ) на все рекордеры сразу.**
Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное
остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы
уже записанное из-за чужой записи на другом приёмнике.
3. **Освобождение по трём событиям, а не по одному.** Раньше срок проверял
только `Feed`, то есть DSP-поток — а к мёртвому приёмнику он не приходит
никогда, и `START` на несуществующий номер оставлял память навсегда.
Теперь: `START` требует **живого** приёмника (`RxActive`, ответ
`receiver is not running` — как у потоков); тик сервера подметает истёкшие
окна по часам (`SweepRecorders`, окно закрывается от `START` и без единого
блока звука); уход клиента забирает его записи (`DropClientRecorders`
рекордер живёт на приёмнике, но платит за него тот, кто нажал `START`, иначе
пары «подключился, `START`, отключился» набивали бы бюджет до потолка).
**★Имя файла: каталог всегда наш.** `LINE_OUT_RECORDER_SAVE` в §4.3 принимает
«полное имя файла», и раньше оно почти без изменений уходило в `fmCreate`
то есть любой, кто дотянулся до порта (а bind наружу разрешён), писал файлы от
имени EWSDR куда угодно и затирал существующие. Теперь из строки берётся
**только имя файла** (`TCIRecordPath`), а каталог — настроенный каталог
записей (`tci.record_dir`, пусто = `<каталог конфигурации>/records`). Полный
путь на СЕРВЕРЕ клиенту всё равно бесполезен: файл ложится не на его машину, а
на нашу, поэтому каталог из просьбы отбрасывается молча — клиент получает
разумный файл, а не ошибку. Само имя проверяется: пусто, `.`, `..`,
управляющие символы, `:`, длиннее 120 и расширение не `.wav` — отказ
(`bad file name`). Выйти за каталог после `ExtractFileName` нечем:
разделителей в имени уже не осталось, а `..` не проходит проверку. Файл
создаётся **эксклюзивно** (`TCICreateNewFile`: `O_EXCL or O_NOFOLLOW` на Unix,
`CREATE_NEW` на Windows) — существующий не перезаписывается и симлинк не
уводит наружу, причём одним вызовом ядра, без окна между `FileExists` и
созданием. Клиенту про уже занятое имя отвечаем `file exists` до постановки
задачи писателю.
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
@@ -806,11 +857,22 @@ ExpertSDR3 давно бы не было.
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
запись, файл получает верные RIFF/fmt/data и длину.
запись, файл получает верные RIFF/fmt/data и длину. ★Память: `START` не
выделяет ни байта, бюджет растёт кусками по мере звука и возвращается по
`Free`, потолок берётся целиком и сверх него следует отказ, при отказе запись
не рушится, окно истекает по часам **без единого `Feed`** и отдаёт память
само. ★Имя файла: простое имя ложится в каталог записей, а каталог из
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
записей дают отказ; существующий файл писатель не перезаписывает.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
`LINE_OUT_RECORDER_START` на мёртвом приёмнике и с чужим номером отвечает
ошибкой и **не стоит памяти**, `SAVE` с чужим путём отбивается по имени;
запись, начатая ушедшим клиентом, освобождается вместе с ним (видно насквозь
по общему бюджету в части E);
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт,
реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента;
чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`