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
+105 -9
View File
@@ -43,7 +43,7 @@ interface
uses
Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings,
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
const
@@ -236,6 +236,9 @@ type
procedure PushTxChrono; // тик: маркеры времени клиенту
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
procedure DropClientRecorders(C: TTCIClient); // клиент ушёл — и запись с ним
procedure SweepRecorders; // тик: истёкшие окна записи
function RecordDir: string; // каталог, куда пишем WAV
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
procedure ForgetTxOwner; // передача кончилась не по TCI
@@ -1329,6 +1332,7 @@ procedure TTCIAdapter.HandleDisconnect(Client: TTCIClient);
begin
DropHolds(Client);
DropClientStreams(Client);
DropClientRecorders(Client);
ClearTxClient(Client);
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
@@ -2640,6 +2644,7 @@ begin
try
FServer.EnumClients(PushSensors);
PushTxChrono;
SweepRecorders;
except
// молча: следующий тик через 20 мс попробует снова
end;
@@ -2992,7 +2997,7 @@ end;
procedure TTCIAdapter.CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
var
Rx, Sec, Rate: Integer;
Path: string;
Path, Req: string;
Data: TTCIPcm;
Old, New_: TTCIRecorder;
begin
@@ -3004,11 +3009,21 @@ begin
if M.Name = 'LINE_OUT_RECORDER_START' then
begin
// ★Только ЖИВОЙ приёмник. Раньше хватало номера в потолке, а у мёртвого
// приёмника Feed не зовут вовсе — значит и срок записи никто не проверял:
// рекордер висел до перезапуска сервера. Отвечаем как потокам.
if not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
Exit;
end;
if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC;
Sec := EnsureRange(Sec, 1, TCI_RECORD_MAX_SEC);
// Кольцо заводим ДО лока: на предельных 300 с это 57 МБ, и выделять их
// под локом, которого ждёт DSP-поток, значит уронить звук на десятки мс.
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec);
// Объект заводим ДО лока — не ради памяти (её он больше не выделяет, см.
// TTCIRecorder), а чтобы не звать чужой конструктор под локом DSP-потока.
// Владельца помним: уйдёт клиент — уйдёт и его запись.
New_ := TTCIRecorder.Create(Rx, TCI_AUDIO_ENGINE_RATE, Sec, Client);
FStreamLock.Enter;
try
// Рекордер один на приёмник, а не на клиента: пишет он то, что слышно
@@ -3036,20 +3051,35 @@ begin
end;
// LINE_OUT_RECORDER_SAVE
Path := TCIRecordPath(TCIUnescape(TCIArg(M, 1)));
if Path = '' then
Req := TCIUnescape(TCIArg(M, 1));
if Req = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
Exit;
end;
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
// чем сказать правду: клиент такой файл всё равно не откроет.
if SameText(ExtractFileExt(Path), '.mp3') then
// чем сказать правду: клиент такой файл всё равно не откроет. Проверяем до
// TCIRecordPath, чтобы у самой частой причины отказа был свой текст.
if SameText(ExtractFileExt(Req), '.mp3') then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'only wav is supported']));
Exit;
end;
// ★Каталог всегда наш, из просьбы берётся одно имя файла — см. TCIRecordPath.
Path := TCIRecordPath(RecordDir, Req);
if Path = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad file name']));
Exit;
end;
// Существующий файл не трогаем (окончательно это решит O_EXCL в писателе,
// здесь — только чтобы клиент услышал причину).
if FileExists(Path) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'file exists']));
Exit;
end;
// Забираем рекордер из таблицы (сохранение завершает запись, §4.3) и только
// потом снимаем с него данные: копия кольца — это десятки мегабайт, и делать
@@ -3079,6 +3109,72 @@ begin
TTCIWavWriter.Create(Path, Data, Rate);
end;
procedure TTCIAdapter.DropClientRecorders(C: TTCIClient);
// ★Клиент ушёл — забираем его записи. Рекордер живёт на приёмнике, а не на
// клиенте (второй такой же был бы копией памяти), но платит за него тот, кто
// нажал START: без этого пары «подключился, START, отключился» набивали память
// до потолка бюджета, и вернуть её было некому до перезапуска сервера.
var
Rx: Integer;
Dead: array[0..TCI_MAX_RX-1] of TTCIRecorder;
begin
if C = nil then Exit;
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do
begin
Dead[Rx] := nil;
if (FRec[Rx] <> nil) and (FRec[Rx].Owner = TObject(C)) then
begin
Dead[Rx] := FRec[Rx];
FRec[Rx] := nil;
end;
end;
finally
FStreamLock.Leave;
end;
// Освобождаем вне лока: его ждёт DSP-поток, а деструктор трогает свой лок.
for Rx := 0 to TCI_MAX_RX - 1 do Dead[Rx].Free;
end;
procedure TTCIAdapter.SweepRecorders;
// Тик сервера. ★Срок записи раньше проверял только Feed, то есть DSP-поток —
// а он к рекордеру замьюченного, молчащего или пропавшего приёмника не приходит
// вовсе. Окно закрывается по ЧАСАМ от START (§4.3), значит и спрашивать про
// него надо по часам, а не по звуку.
var
Rx: Integer;
Dead: array[0..TCI_MAX_RX-1] of TTCIRecorder;
begin
FStreamLock.Enter;
try
for Rx := 0 to TCI_MAX_RX - 1 do
begin
Dead[Rx] := nil;
if (FRec[Rx] <> nil) and FRec[Rx].Expired then
begin
Dead[Rx] := FRec[Rx];
FRec[Rx] := nil;
end;
end;
finally
FStreamLock.Leave;
end;
for Rx := 0 to TCI_MAX_RX - 1 do Dead[Rx].Free;
end;
function TTCIAdapter.RecordDir: string;
// Каталог записей: настройка, а пусто — <каталог конфигурации>/records.
// Каталог создаём здесь же: писатель работает в своём потоке и сказать об
// отсутствии каталога ему уже некому.
begin
Result := Trim(FCfg.RecordDir);
if Result = '' then Result := GetAppCfgDir + 'records';
Result := IncludeTrailingPathDelimiter(Result);
if not DirectoryExists(Result) then
if not ForceDirectories(Result) then Result := '';
end;
procedure TTCIAdapter.ClearTxClient(C: TTCIClient);
// C = nil — снять кого угодно (остановка сервера).
var Drop: Boolean;