fix(tci): callsign_send слал размноженный позывной, tci_error — неэкранированное имя

Два расхождения со спекой, оба в том, что уходит клиенту.

callsign_send (§3.2.2). Команда должна нести «финальный вариант позывного,
переданного в эфир», а несла его вместе с повторами: для «cw_msg:0,_,RA6LH$2,
599 004;» уезжало «callsign_send:RA6LH RA6LH;», потому что переменная Call к
этому моменту уже была затёрта развёрнутым текстом сообщения. Теперь позывной
запоминается ДО развёртки. Заодно: подтверждение уходит АВТОРУ сообщения, а не
broadcast'ом (у остальных клиентов своих сообщений нет), и через TCIEscape —
позывной пришёл от клиента, и символы ^ ~ * после снятия экранирования
превращались в сырые : , ; прямо посреди кадра.

Момент отправки остаётся расхождением, теперь честно описанным в doc/TCI.md:
документ шлёт команду по факту окончания передачи позывного, а наш передатчик
текста моментов внутри очереди не отмечает. Доотправка позывного (cw_msg:arg1;)
всё равно не поддержана, так что финальный вариант известен уже в момент
постановки в очередь и позже не изменится.

tci_error. Имя команды возвращается клиенту как ПЕРВЫЙ АРГУМЕНТ, то есть обязано
экранироваться, — и общий обработчик (HandleCommand) это делает, а пятнадцать
точечных отказов пропускали его сырым. Имя приходит от клиента, TCIParse режет
его только по ':', так что «foo,bar:1;» возвращался как «tci_error:foo,bar,bad
receiver;» — три аргумента вместо двух.

Заодно в doc/TCI.md: правило 3 §1.1 теперь говорит про ВСЕ потоки сервера
(accept и тик тоже, общий JoinPumped), а не только про клиентские, и счётчик
проверок стенда приведён к нынешним 244.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:21:58 +03:00
co-authored by Claude Opus 5
parent 4081ea0e57
commit b62369ec6b
2 changed files with 54 additions and 27 deletions
+39 -23
View File
@@ -334,7 +334,7 @@ type
// ── Отдельные команды (чтобы HandleCommand не превратился в простыню) ──
procedure CmdFreq(Client: TTCIClient; const M: TTCIMessage; IsIF: Boolean);
procedure CmdCWMacros(const M: TTCIMessage; IsMsg: Boolean);
procedure CmdCWMacros(Client: TTCIClient; const M: TTCIMessage; IsMsg: Boolean);
procedure CmdKeyer(Client: TTCIClient; const M: TTCIMessage);
procedure CmdSpot(const M: TTCIMessage);
public
@@ -1728,14 +1728,15 @@ begin
if IsIF then Reply(Client, StrIf(Rx, Ch)) else Reply(Client, StrVfo(Rx, Ch));
end;
procedure TTCIAdapter.CmdCWMacros(const M: TTCIMessage; IsMsg: Boolean);
procedure TTCIAdapter.CmdCWMacros(Client: TTCIClient; const M: TTCIMessage;
IsMsg: Boolean);
// CW_MACROS:trx,текст; CW_MSG:trx,префикс,позывной,суффикс;
// Разметку TCI приводим к тому, что понимает передатчик текста ewsdr:
// |ABBR| — слитная передача (у нас такой команды нет) → скобки снимаем;
// < / > — шаг скорости ±5 wpm (передатчик работает на одной скорости) → снимаем;
// CALL$N — повтор позывного N раз.
var
Text, Prefix, Call, Suffix, Rep: string;
Text, Prefix, Call, Suffix, Rep, Final: string;
P, N, i: Integer;
begin
if IsMsg then
@@ -1749,9 +1750,13 @@ begin
if Prefix = '_' then Prefix := '';
if Suffix = '_' then Suffix := '';
// Финальный вариант позывного — ДО развёртки повторов: клиенту он нужен
// как позывной (§3.2.2), а не как «RA6LH RA6LH».
Final := Call;
P := Pos('$', Call);
if P > 0 then
begin
Final := Trim(Copy(Call, 1, P - 1));
Rep := Copy(Call, P + 1, MaxInt);
Call := Copy(Call, 1, P - 1);
N := StrToIntDef(Trim(Rep), 1);
@@ -1788,9 +1793,14 @@ begin
FLock.Leave;
end;
// Позывной ушёл в эфир целиком — подтверждаем финальный вариант (§3.2.2).
if IsMsg and (Call <> '') then
FServer.Broadcast(TCIBuild('callsign_send', [Call]));
// §3.2.2: подтверждение финального варианта позывного — АВТОРУ сообщения, а
// не всем (у остальных клиентов своих сообщений нет). ★Расхождение со спекой,
// осознанное: документ шлёт эту команду по факту окончания передачи позывного,
// а наш передатчик текста моментов внутри очереди не отмечает — подтверждаем
// сразу. Доотправка позывного (cw_msg:arg1;) всё равно не поддержана, так что
// финальный вариант известен уже здесь и позже не изменится.
if IsMsg and (Final <> '') then
Reply(Client, TCIBuild('callsign_send', [TCIEscape(Final)]));
end;
procedure TTCIAdapter.CmdKeyer(Client: TTCIClient; const M: TTCIMessage);
@@ -1814,19 +1824,20 @@ var
begin
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(LowerCase(M.Name)), 'bad receiver']));
Exit;
end;
// Номер объявлен потолком железа, но пана под ним может не быть — как у TRX.
if (Rx > 0) and not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
[TCIEscape(LowerCase(M.Name)), 'receiver is not running']));
Exit;
end;
if not TCITryArgBool(M, 1, Down) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad state']));
Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'bad state']));
Exit;
end;
if not TCITryArgInt(M, 2, Ms) then Ms := 0;
@@ -2001,7 +2012,8 @@ begin
begin
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(LowerCase(M.Name)), 'bad receiver']));
Exit;
end;
// Номер объявлен (TRX_COUNT = потолок железа), но пана под ним может не
@@ -2010,7 +2022,7 @@ begin
if (Rx > 0) and not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
[TCIEscape(LowerCase(M.Name)), 'receiver is not running']));
Exit;
end;
// Ключ захвата (§3.5) — общий на радио, а не на приёмник: передатчик один,
@@ -2539,8 +2551,8 @@ begin
end;
if M.Name = 'KEYER' then begin CmdKeyer(Client, M); Exit; end;
if M.Name = 'CW_MACROS' then begin CmdCWMacros(M, False); Exit; end;
if M.Name = 'CW_MSG' then begin CmdCWMacros(M, True); Exit; end;
if M.Name = 'CW_MACROS' then begin CmdCWMacros(Client, M, False); Exit; end;
if M.Name = 'CW_MSG' then begin CmdCWMacros(Client, M, True); Exit; end;
if M.Name = 'CW_MACROS_STOP' then
begin
FLock.Enter;
@@ -3066,7 +3078,7 @@ begin
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'bad receiver']));
[TCIEscape(LowerCase(M.Name)), 'bad receiver']));
Exit;
end;
@@ -3086,7 +3098,7 @@ begin
if not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
[TCIEscape(LowerCase(M.Name)), 'receiver is not running']));
Exit;
end;
StartStream(Client, K, Rx);
@@ -3108,7 +3120,8 @@ var
begin
if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(LowerCase(M.Name)), 'bad receiver']));
Exit;
end;
@@ -3120,7 +3133,7 @@ begin
if not RxActive(Rx) then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'receiver is not running']));
[TCIEscape(LowerCase(M.Name)), 'receiver is not running']));
Exit;
end;
if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC;
@@ -3159,7 +3172,8 @@ begin
Req := TCIUnescape(TCIArg(M, 1));
if Req = '' then
begin
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(LowerCase(M.Name)), 'no file name']));
Exit;
end;
// MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже,
@@ -3168,21 +3182,22 @@ begin
if SameText(ExtractFileExt(Req), '.mp3') then
begin
Reply(Client, TCIBuild('tci_error',
[LowerCase(M.Name), 'only wav is supported']));
[TCIEscape(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']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(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']));
Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'file exists']));
Exit;
end;
@@ -3216,7 +3231,8 @@ begin
// тут же и пишется), но возвращаем на всякий случай: единственный путь,
// на котором запись не доходит до писателя.
TCIRecBudgetFree(Rec.Reserved);
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'nothing recorded']));
Reply(Client, TCIBuild('tci_error',
[TCIEscape(LowerCase(M.Name)), 'nothing recorded']));
Exit;
end;
// Пишет отдельный поток: файл может быть в десятки мегабайт, а мы сейчас в
@@ -3228,7 +3244,7 @@ begin
// Очередь полна (медленный диск) — запись отдать некому, значит и её
// место в бюджете держать больше незачем.
TCIRecBudgetFree(Rec.Reserved);
Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'writer busy']));
Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'writer busy']));
end;
end;
+15 -4
View File
@@ -85,10 +85,15 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
том и в другом случае наверх уходит `OnDisconnect` (единая точка
`Disconnected`): иначе после остановки у адаптера оставались висеть захваты
параметров ушедших клиентов.
3. **`Stop` ждёт выхода клиентских потоков без таймаута** и прокачивает при
3. **`Stop` ждёт выхода ВСЕХ своих потоков без таймаута** и прокачивает при
этом очередь `Synchronize`. Останавливает сервер поток контроллера (UI), а
клиентский поток в этот момент может висеть как раз на `Invoke` в него же:
без прокачки это взаимный клин. Выйти по таймауту нельзя — следом
без прокачки это взаимный клин. ★Это относится и к accept- с тик-потоком
(общий `JoinPumped`), а не только к клиентским: тик-поток ходит в контроллер
через `ReapClients``Disconnected``Invoke`, и проверка `CanInvoke` от
клина не спасает — она читает `Stopping` ДО входа в `Synchronize`, так что
клиент, отвалившийся ровно в момент остановки, успевает проскочить в это
окно. Выйти по таймауту нельзя — следом
освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток
вернулся бы в эту память. Поэтому: `Stopping` (адаптер новых `Invoke` не
начинает) + закрытые сокеты + повторный `shutdown` раз в полсекунды, и
@@ -703,7 +708,13 @@ ExpertSDR3 давно бы не было.
Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`):
экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов
позывного, префикс/суффикс `_` считаются пустыми. **Не поддержано:** шаг
позывного, префикс/суффикс `_` считаются пустыми. Подтверждение
`callsign_send` уходит **автору сообщения** и содержит позывной ДО развёртки
повторов (`RA6LH`, а не «RA6LH RA6LH»). ★Расхождение со спекой, осознанное:
документ шлёт эту команду по факту окончания передачи позывного, а наш
передатчик текста моментов внутри очереди не отмечает — подтверждаем сразу.
Доотправка всё равно не поддержана, так что финальный вариант известен уже
здесь и позже не изменится. **Не поддержано:** шаг
скорости внутри текста (`<` / `>`) и слитная передача аббревиатур (`|SK|`) —
эти символы просто снимаются, потому что `TCWSender` работает на одной
скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`)
@@ -999,7 +1010,7 @@ running`), а сам телеграф уходит **текущему TX-ист
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 238 проверок, все зелёные
### Стенд этапа 2 (бинарные потоки) — 244 проверки, все зелёные
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях: