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:
+105
-9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user