mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
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:
+168
-5
@@ -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: панов больше одного там не бывает, и «второй
|
||||
// приёмник» существует только как слайс. Приёмник = слот слайса, поэтому
|
||||
|
||||
Reference in New Issue
Block a user