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
+9
View File
@@ -466,6 +466,12 @@ type
Enabled: Boolean;
Port: Integer; // 1..65535, умолчание 40001 (как у ExpertSDR3)
BindAddr: string; // '127.0.0.1' или '0.0.0.0'
// ★Единственный каталог, куда TCI пишет записи линейного выхода
// (LINE_OUT_RECORDER_SAVE). Пусто — <каталог конфигурации>/records.
// Имя файла клиент выбирает сам, но КАТАЛОГ из его строки не берётся
// никогда: авторизации в протоколе нет, и полный путь из сети означал бы
// запись в любой доступный процессу файл (см. TCIRecordPath).
RecordDir: string;
end;
// Transverter (XVTR) entry — один трансвертер.
@@ -2999,6 +3005,7 @@ begin
T.Enabled := False; // включается осознанно, как CAT-транспорты
T.Port := 40001;
T.BindAddr := '127.0.0.1';
T.RecordDir := ''; // пусто = <каталог конфигурации>/records
end;
procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
@@ -3010,6 +3017,7 @@ begin
T.Enabled := JB(O, 'enabled', False);
T.Port := EnsureRange(JI(O, 'port', 40001), 1, 65535);
T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
T.RecordDir := JS(O, 'record_dir', '');
end;
procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
@@ -3019,6 +3027,7 @@ begin
JW(O, 'enabled', T.Enabled);
JW(O, 'port', T.Port);
JWS(O, 'bind_addr', T.BindAddr);
JWS(O, 'record_dir', T.RecordDir);
Save;
end;