fix(tci): писатель WAV — один поток с очередью, файл публикуется атомарно

SAVE запускал поток на каждую запись с FreeOnTerminate: его никто не держал
и никто не ждал. Замерено отдельным процессом — при штатном выходе сразу
после сохранения от ожидаемых 100000044 байт на диске оставалось 40960, а
заголовок заявлял полную длину; на медленном каталоге идущие подряд SAVE
плодили сотни потоков, чья память стеков в 128-МБ бюджет не входила.

Теперь писатель один и принадлежит адаптеру (лениво на первом SAVE), очередь
ограничена TCI_RECORD_MAX_JOBS = 16 (сверх — клиенту writer busy и возврат
резерва), а деструктор адаптера гасит его через TCIStopWriter: Close →
WaitDrained (без срока) → Free. Срока здесь нет намеренно: поток, стоящий в
write/fsync, изнутри процесса не останавливается (Terminate не указ, Free
обязан WaitFor, бросить живой TThread нельзя — он ходит в общий бюджет),
поэтому срок не ограничивал выход, а только терял подтверждённые клиенту
записи. Ограниченный выход = писатель отдельным процессом, одним TThread не
делается; это записано в коде и в доке.

Результат записи больше не игнорируется: FileWrite возвращает число байт и
при ошибке даёт 0/-1 без исключения, поэтому на полном диске файл спокойно
дописывался до конца огрызком. TCIWriteAll — цикл с проверкой каждого вызова,
плюс FileFlush перед публикацией (на ext4 с отложенным размещением ENOSPC
приходит именно там).

Файл появляется под целевым именем целиком или не появляется вовсе: данные
пишутся во временный файл рядом (эксклюзивно, не по симлинку), а публикует
их TCIPublishFile — renameat2(RENAME_NOREPLACE) напрямую через Do_SysCall,
если его нет — link + unlink, если нет и ссылок (FAT/exFAT, часть CIFS/SMB и
FUSE) — отказ с сохранением данных в .part. FileExists + rename не делается
нигде: это тот самый TOCTOU. На Windows — MoveFileW без REPLACE_EXISTING.

doc/TCI.md: §2.5 переписан (писатель-очередь, остановка, публикация); заодно
исправлено устаревшее описание склейки кусков в Take (её нет с 7aae0fd).

Стенд test/tci: 214 проверок (было 199), все зелёные. Новое — временный файл
убирается после удачи, неудачная запись не оставляет ни файла, ни .part, за
всё время записи 32 МБ целевое имя ни разу не видно незаконченным, отказ
сверх потолка заданий, после Close заданий не берут, TCIStopWriter дожидается
и самой записи, и хвоста очереди за ней. Все новые гарантии прогнаны
негативным контролем; путь link проверен сборкой с выключенным renameat2,
сам renameat2 — под strace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:40:31 +03:00
co-authored by Claude Opus 5
parent 7aae0fdfcd
commit 3dc7035f9e
4 changed files with 734 additions and 134 deletions
+34 -2
View File
@@ -146,6 +146,7 @@ type
FStreamLock: TCriticalSection;
FStreams: array of TTCIStreamOut;
FRec: array[0..TCI_MAX_RX-1] of TTCIRecorder;
FWriter: TTCIWavWriter; // писатель WAV: один поток с очередью
FTapsOn: Boolean; // тапы навешены на контроллер/движок
// ── TX-аудио от клиента (§3.4) ──
@@ -236,6 +237,8 @@ type
procedure PushTxChrono; // тик: маркеры времени клиенту
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
function EnqueueWav(const APath: string; const R: TTCIRecTake;
ARate: Integer): Boolean;
procedure DropClientRecorders(C: TTCIClient); // клиент ушёл — и запись с ним
procedure SweepRecorders; // тик: истёкшие окна записи
function RecordDir: string; // каталог, куда пишем WAV
@@ -390,6 +393,7 @@ begin
FStreamLock := TCriticalSection.Create;
FTxLock := TCriticalSection.Create;
FWriter := nil; // заводится на первом SAVE (см. EnqueueWav)
FTapsOn := False;
SetLength(FTxRaw, TCI_STREAM_DATA_MAX div 2); // худший случай: int16
SetLength(FTxMono, TCI_STREAM_DATA_MAX div 2);
@@ -426,6 +430,11 @@ begin
end;
// Потоки — уже после остановки сервера: их объекты ссылаются на клиентов.
StopAllStreams;
// ★Писателя гасим ЗДЕСЬ и с ожиданием: раньше SAVE запускал ничей поток с
// FreeOnTerminate, и штатный выход из программы обрывал запись на полуслове
// (из 100000044 байт на диске оставалось 40960). Ждём ВСЮ очередь без срока:
// за каждую запись в ней клиенту сказано «сохранено» (см. TCIStopWriter).
TCIStopWriter(FWriter);
FreeAndNil(FTxInterp);
FLock.Free;
FEchoLock.Free;
@@ -3115,8 +3124,31 @@ begin
Exit;
end;
// Пишет отдельный поток: файл может быть в десятки мегабайт, а мы сейчас в
// потоке клиента, который в это время не читает свой сокет.
TTCIWavWriter.Create(Path, Rec, Rate);
// потоке клиента, который в это время не читает свой сокет. ★Поток ОДИН и
// принадлежит адаптеру: очередь заданий ограничена, а при закрытии
// программы её дожидаются (см. TTCIWavWriter).
if not EnqueueWav(Path, Rec, Rate) then
begin
// Очередь полна (медленный диск) — запись отдать некому, значит и её
// место в бюджете держать больше незачем.
TCIRecBudgetFree(Rec.Reserved);
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'writer busy']));
end;
end;
function TTCIAdapter.EnqueueWav(const APath: string; const R: TTCIRecTake;
ARate: Integer): Boolean;
// Писателя заводим на первом сохранении: большинству операторов рекордер не
// нужен вовсе, и держать ради них спящий поток незачем. Зовут из потока
// клиента, поэтому создание — под FStreamLock.
begin
FStreamLock.Enter;
try
if FWriter = nil then FWriter := TTCIWavWriter.Create;
finally
FStreamLock.Leave;
end;
Result := FWriter.Enqueue(APath, R, ARate);
end;
procedure TTCIAdapter.DropClientRecorders(C: TTCIClient);