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
+168 -5
View File
@@ -72,6 +72,7 @@ var
Buf: array[0..63] of Byte;
N, i: Integer;
T: TTCISampleType;
Base: string;
begin
WriteLn('A. Протокол потоков');
@@ -140,7 +141,37 @@ begin
Check('клип int16 +', SmallInt(Word(Buf[0]) or (Word(Buf[1]) shl 8)) = 32767);
Check('клип int16 ', SmallInt(Word(Buf[2]) or (Word(Buf[3]) shl 8)) = -32767);
Check('путь | → :', TCIRecordPath('home|user/rec.wav') = 'home:user/rec.wav');
// ★Имя файла записи: каталог из просьбы клиента не берётся НИКОГДА —
// авторизации в TCI нет, и полный путь из сети означал бы запись в любой
// доступный процессу файл. Берём одно имя и кладём в свой каталог.
Base := IncludeTrailingPathDelimiter(GetTempDir) + 'tcirec';
Check('путь: простое имя',
TCIRecordPath(Base, 'rec.wav') =
IncludeTrailingPathDelimiter(Base) + 'rec.wav',
TCIRecordPath(Base, 'rec.wav'));
Check('путь: каталог из просьбы отброшен',
TCIRecordPath(Base, 'home/user/rec_dir/a.wav') =
IncludeTrailingPathDelimiter(Base) + 'a.wav',
TCIRecordPath(Base, 'home/user/rec_dir/a.wav'));
Check('путь: абсолютный не выводит наружу',
TCIRecordPath(Base, '/etc/passwd.wav') =
IncludeTrailingPathDelimiter(Base) + 'passwd.wav',
TCIRecordPath(Base, '/etc/passwd.wav'));
Check('путь: .. не выводит наружу',
TCIRecordPath(Base, '../../../home/vladimir/.bashrc.wav') =
IncludeTrailingPathDelimiter(Base) + '.bashrc.wav',
TCIRecordPath(Base, '../../../home/vladimir/.bashrc.wav'));
Check('путь: буква диска (| → :) не выводит наружу',
TCIRecordPath(Base, 'D|\\rec\\a.wav') =
IncludeTrailingPathDelimiter(Base) + 'a.wav',
TCIRecordPath(Base, 'D|\\rec\\a.wav'));
Check('путь: пусто — отказ', TCIRecordPath(Base, '') = '');
Check('путь: только каталог — отказ', TCIRecordPath(Base, 'a/b/') = '');
Check('путь: «..» — отказ', TCIRecordPath(Base, '..') = '');
Check('путь: не .wav — отказ', TCIRecordPath(Base, 'a.mp3') = '');
Check('путь: управляющий символ — отказ',
TCIRecordPath(Base, 'a' + #10 + 'b.wav') = '');
Check('путь: без каталога — отказ', TCIRecordPath('', 'a.wav') = '');
// Частоты Pluto (576…5760 кГц) на 384 делятся не все: клиенту обязана
// достаться ЗАКОННАЯ частота из набора протокола, а не 576/480 кГц.
@@ -531,12 +562,13 @@ var
Sz: LongWord;
W: TTCIWavWriter;
Waited: Integer;
Was: Int64;
begin
WriteLn('C. Рекордер линейного выхода');
// Максимум записи — 1 секунда: подаём полторы, лишнее не берём. ★Именно НЕ
// берём: окно записи по §4.3 начинается со START, а не «последняя секунда».
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Total := 0;
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
@@ -556,7 +588,7 @@ begin
end;
// Порядок отсчётов: первым обязан идти самый ПЕРВЫЙ записанный.
Rec := TTCIRecorder.Create(0, 1000, 1); // ёмкость 1000 отсчётов
Rec := TTCIRecorder.Create(0, 1000, 1, nil); // ёмкость 1000 отсчётов
try
for i := 0 to 1499 do
begin
@@ -575,7 +607,7 @@ begin
// ★Истечение срока: по документу «по истечении времени запись удаляется».
// Секунду ждать незачем — окно берём минимальное и смотрим на часы.
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
Rec.Feed(L, R, 8192);
@@ -583,7 +615,7 @@ begin
finally
Rec.Free;
end;
Rec := TTCIRecorder.Create(0, 48000, 1);
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Rec.Feed(L, R, 8192);
Sleep(1100); // окно закрылось
@@ -594,6 +626,58 @@ begin
Rec.Free;
end;
// ★Бюджет памяти. Раньше конструктор выделял MaxSec × 48 кГц × 2 × int16
// СРАЗУ — 57.6 МБ на строку из сети, 403 МБ на семь приёмников, и держать их
// мог кто угодно (авторизации в TCI нет). Теперь START не стоит ни байта.
Was := TCIRecBudgetUsed;
Rec := TTCIRecorder.Create(0, 48000, TCI_RECORD_MAX_SEC, nil);
try
Check('бюджет: START не выделяет памяти', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
for i := 0 to 8191 do begin L[i] := 0.25; R[i] := -0.25; end;
Rec.Feed(L, R, 8192);
Check('бюджет: растёт по мере записи', TCIRecBudgetUsed > Was);
Check('бюджет: кусками, а не всей ёмкостью',
TCIRecBudgetUsed - Was <= 4 * 48000 * 2 * 2,
IntToStr(TCIRecBudgetUsed - Was));
finally
Rec.Free;
end;
Check('бюджет: возвращается по Free', TCIRecBudgetUsed = Was);
// Потолок общий на все рекордеры сразу — иначе семь живых приёмников по
// 300 с всё равно дали бы 403 МБ.
Check('бюджет: потолок берётся целиком',
TCIRecBudgetTake(TCI_RECORD_MAX_BYTES - TCIRecBudgetUsed));
Check('бюджет: сверх потолка отказ', not TCIRecBudgetTake(1));
Rec := TTCIRecorder.Create(0, 48000, 10, nil);
try
Rec.Feed(L, R, 8192);
Check('бюджет: при отказе запись не рушится, а стоит пустой',
Length(Rec.Take) = 0);
finally
Rec.Free;
end;
TCIRecBudgetFree(TCI_RECORD_MAX_BYTES - Was);
Check('бюджет: после возврата снова можно брать', TCIRecBudgetTake(1));
TCIRecBudgetFree(1);
// ★Срок по ЧАСАМ спрашивает тик сервера: у мёртвого, молчащего или
// замьюченного приёмника Feed не зовут вовсе, и без этого вопроса память
// жила бы до остановки сервера.
Rec := TTCIRecorder.Create(0, 48000, 1, nil);
try
Rec.Feed(L, R, 8192);
Check('срок: до истечения окно открыто', not Rec.Expired);
Check('срок: память занята', TCIRecBudgetUsed > Was);
Sleep(1100);
Check('срок: истекло без единого Feed', Rec.Expired);
Check('срок: память отдана без Take и Free', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
finally
Rec.Free;
end;
// WAV: заголовок и длина.
Path := GetTempDir + 'tcitest_rec.wav';
DeleteFile(Path);
@@ -629,6 +713,20 @@ begin
finally
FS.Free;
end;
// ★Существующий файл не трогаем: имя приходит из сети, и fmCreate затирал
// бы любой доступный процессу файл. Пишем поверх заведомо другой длиной —
// файл обязан остаться прежним.
SetLength(Data, 10);
for i := 0 to 9 do Data[i] := 1;
TTCIWavWriter.Create(Path, Data, 48000);
Sleep(200);
FS := TFileStream.Create(Path, fmOpenRead);
try
Check('WAV: существующий файл не перезаписан',
FS.Size = 44 + 2000 * 2, IntToStr(FS.Size));
finally
FS.Free;
end;
DeleteFile(Path);
end;
end;
@@ -890,6 +988,7 @@ var
Op: Byte;
Pay: TBytes;
GotBinary, GotClose: Boolean;
Was: Int64;
begin
WriteLn('D. Команды потоков на живом сервере');
@@ -979,6 +1078,28 @@ begin
Ctrl.SetSampleRate(192000);
C.WaitText('if_limits', 1000);
// ★Рекордер только на ЖИВОМ приёмнике. Раньше хватало номера в потолке, и
// семь строк подряд занимали 403 МБ, которые никто не освобождал: у
// мёртвого приёмника Feed не зовут, значит и срок никто не проверял.
Was := TCIRecBudgetUsed;
C.SendText('line_out_recorder_start:3,300;');
S := C.WaitText('tci_error', 1500);
Check('recorder start на мёртвом приёмнике → ошибка',
Pos('receiver is not running', S) > 0, S);
C.SendText('line_out_recorder_start:99,300;');
S := C.WaitText('tci_error', 1500);
Check('recorder start с чужим номером → ошибка',
Pos('bad receiver', S) > 0, S);
Check('recorder: отказ не стоил памяти', TCIRecBudgetUsed = Was,
IntToStr(TCIRecBudgetUsed - Was));
// Имя файла из сети: каталог из просьбы игнорируется целиком, наружу
// записи не выходят (подробный разбор имён — в части A).
C.SendText('line_out_recorder_start:0,10;');
C.SendText('line_out_recorder_save:0,..' + '/' + '..' + '/etc/passwd;');
S := C.WaitText('tci_error', 1500);
Check('save с чужим путём → отказ по имени', Pos('bad file name', S) > 0, S);
// Рекордер: сохранять нечего — честная ошибка вместо пустого файла.
C.SendText('line_out_recorder_save:0,' +
StringReplace(GetTempDir + 'tcitest_none.wav', ':', '|', [rfReplaceAll]) + ';');
@@ -1205,6 +1326,8 @@ var
SliceId: Integer;
SV: TSliceView;
S: string;
Was: Int64;
C2: TRawClient;
begin
WriteLn('E. Сквозной прогон через движок');
@@ -1304,6 +1427,46 @@ begin
Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks));
Check('сквозной: заголовки верны', BadHdr = 0, IntToStr(BadHdr));
// ── ★Запись линейного выхода и уход клиента ──────────────────────────
// Рекордер живёт на приёмнике, но платит за него тот, кто нажал START.
// Раньше уход клиента его не трогал вовсе: пары «подключился, START,
// отключился» набивали память до потолка, и вернуть её было некому до
// остановки сервера. Тут это видно насквозь — по общему бюджету.
Was := TCIRecBudgetUsed;
C2 := TRawClient.Create;
try
Check('запись: второй клиент подключился', C2.Connect(PORT));
C2.WaitText('ready;', 2000);
C2.SendText('line_out_recorder_start:0,300;');
C2.Pump(100);
for k := 0 to 40 do
begin
for i := 0 to PAIRS - 1 do
begin
V := Round(Cos(2 * Pi * 1000 * Phase / RATE) * 4000000);
Pkt[i * 6] := Byte(V shr 16);
Pkt[i * 6 + 1] := Byte(V shr 8);
Pkt[i * 6 + 2] := Byte(V);
V := Round(Sin(2 * Pi * 1000 * Phase / RATE) * 4000000);
Pkt[i * 6 + 3] := Byte(V shr 16);
Pkt[i * 6 + 4] := Byte(V shr 8);
Pkt[i * 6 + 5] := Byte(V);
Inc(Phase);
end;
Ctrl.FDSPEngine.PushDDCPacket(Pkt, 0, PAIRS);
Sleep(2);
end;
C2.Pump(200);
Check('запись: набирает память по мере звука', TCIRecBudgetUsed > Was,
IntToStr(TCIRecBudgetUsed - Was));
finally
C2.Close_; // уход без close-кадра, как при обрыве
C2.Free;
end;
Sleep(400);
Check('запись: уход клиента освобождает её память',
TCIRecBudgetUsed = Was, IntToStr(TCIRecBudgetUsed - Was));
// ── ★Слайс главного пана = приёмник 1 ────────────────────────────────
// Ровно случай Pluto: панов больше одного там не бывает, и «второй
// приёмник» существует только как слайс. Приёмник = слот слайса, поэтому