fix(platform): монотонные часы переполнялись на Windows и были стенными на macOS

Два дефекта в источнике времени, на котором стоят абсолютные дедлайны пейсинга
TX: планировщик TX_CHRONO и отправитель DUC ведут по нему сетку, и скачок часов
для них означает не потерю точности, а остановку выдачи.

* Windows: (V * 1000000) div QPCFreq переполняет Int64 на 9.223e12 тиках — при
  типовой для Windows 8+ частоте QPC 10 МГц это 10.7 суток аптайма, дальше
  разрыв повторяется каждые 21.35 суток (2^64/10^6). Между разрывами функция
  линейна, поэтому страдает не «всякая машина старше N суток», а сессия,
  пережившая сам момент скачка: время уходит в большой минус, NextDueUs
  становится недостижим, маркеры TX_CHRONO прекращаются до перезапуска, долг
  TCI уходит в минус, а проверки вида `FTxLastRxUs > 0` перестают срабатывать.
  ★Мест было ДВА: PlatformUtils.MonotonicUs и локальная ClockUs в потоке
  отправителя DUC (win-ветка HPSDRNetwork) — вторая пейсит ВСЕ передачи на
  Windows, не только TCI: WaitUntil на переполненной разности выходит сразу,
  пакеты уходят без выдержки, очередь опустошается пачкой и сохнет.
  Лечение — общая TicksToUs через частное и остаток (остаток меньше частоты,
  произведение не переполняется; потолок отодвинулся на сотни тысяч лет).
* macOS: MonotonicUs проваливалась в общий Unix-{$ELSE} с fpgettimeofday, то
  есть на СТЕННЫЕ часы. Шаг NTP назад — планировщик замирает до недостижимого
  срока, вперёд — выдаёт пачку и начисляет фиктивный долг. Взят
  mach_absolute_time + mach_timebase_info: монотонен и есть на любой версии
  (clock_gettime на macOS только с 10.12), пересчёт тоже через частное/остаток —
  на Apple Silicon база 125/3.

Публикация состояния часов: всё держится в ОДНОМ слове и поднимается в
initialization, до старта любых потоков. Пара «значение + флаг» на слабой
модели памяти (Windows ARM, Apple Silicon) позволяла читателю увидеть флаг
раньше значения, получить частоту 0 и вернуть время 0 — единичный ноль в
монотонных часах есть прыжок на десятки лет назад. Windows: QPCFreq, где 0 —
не выбран, >0 — частота, -1 — QPC непригоден (тогда GetTickCount64, чтобы часы
хотя бы шли). Darwin: MachBase = numer shl 32 or denom.

Стенд, секция H: совпадение со старой формулой ниже порога, ★негативный
контроль на пороге (старая даёт -922337203685 мкс, новая +922337203685),
монотонность через прежний разрыв, 100 суток на частотах 10/3.579545/24/1 МГц,
год при 24 МГц, и контракт «один источник на все потоки» — четыре потока,
~850 тыс. чтений, ни нулей, ни хода назад. Итого 274/274, test/cat 50/50.

★Чего стенд не покрывает: win- и darwin-ветки здесь не компилируются вовсе
(кросс-RTL не установлен). Их тела проверялись вырезкой в пробную программу с
подставным API — синтаксис и арифметика сходятся, живой вызов системного
счётчика остаётся за прогоном на той платформе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bkwwyj7xVRrqnSVEseTRfV
This commit is contained in:
2026-08-24 21:17:57 +03:00
co-authored by Claude Opus 5
parent 867da5921e
commit edc19fa882
3 changed files with 321 additions and 7 deletions
+129 -4
View File
@@ -32,6 +32,18 @@ function GetControlScale(AControl: TControl): Integer;
// абсолютные дедлайны, и часы, способные прыгнуть от NTP, там не годятся.
function MonotonicUs: Int64;
// Тики счётчика → микросекунды, без переполнения. ★Наивное (Ticks * 1000000)
// div Freq переполняет Int64, когда Ticks переваливает за 2^63/10^6 = 9.223e12:
// при типовой для Windows 8+ частоте QPC 10 МГц это 10.7 суток аптайма, дальше
// разрыв повторяется каждые 21.35 суток (2^64/10^6 тиков). В момент разрыва
// время прыгает в большой минус, и код на абсолютных дедлайнах встаёт намертво:
// планировщик TX_CHRONO перестаёт слать маркеры, пейсинг отправителя DUC
// выпускает пакеты без выдержки. Считаем через частное и остаток: остаток
// меньше частоты, поэтому второе слагаемое не переполняется, а первое упирается
// в потолок Int64 через сотни тысяч лет. Функция вынесена в интерфейс, чтобы
// стенд мог проверить её на граничных значениях там, где Windows нет.
function TicksToUs(Ticks, Freq: Int64): Int64;
// Возвращает каталог для хранения конфигурации (с завершающим разделителем).
// macOS: ~/Library/Application Support/ewsdr/
// Linux: ~/.config/ewsdr/
@@ -49,18 +61,95 @@ uses
{$IFDEF WINDOWS}
var
// ★ОДНО слово на всё состояние часов, и вот почему. Публикуется оно обычной
// записью, без барьера, а читают его все потоки. Пара «значение + флаг
// готовности» здесь недопустима: на слабой модели памяти (Windows ARM)
// читатель вправе увидеть выставленный флаг раньше самого значения, получить
// частоту 0 и вернуть время 0 — единичный НОЛЬ в монотонных часах, то есть
// прыжок на десятки лет назад в бухгалтерии TX. С одним словом такого
// расхождения не бывает: выровненная запись Int64 атомарна, а читатель
// снимает её РОВНО ОДИН РАЗ в локальную переменную.
// 0 — источник ещё не выбран;
// >0 — частота QPC;
// -1 — QPC непригоден, считаем по GetTickCount64 (выбор защёлкнут навсегда:
// смена источника на ходу — это тот же скачок времени).
QPCFreq: Int64 = 0;
procedure InitWinClock;
// Идемпотентна. Зовётся из initialization — то есть ДО старта потоков.
var F: Int64;
begin
if QPCFreq <> 0 then Exit;
if QueryPerformanceFrequency(F) and (F > 0) then
QPCFreq := F
else
// Счётчика нет — часы обязаны хотя бы ИДТИ: замершее время для кода на
// абсолютных дедлайнах означает мёртвую передачу, а не потерю точности.
QPCFreq := -1;
end;
{$ENDIF}
function TicksToUs(Ticks, Freq: Int64): Int64;
begin
if Freq <= 0 then Exit(0);
Result := (Ticks div Freq) * 1000000 + ((Ticks mod Freq) * 1000000) div Freq;
end;
{$IFDEF DARWIN}
// ★macOS: gettimeofday — СТЕННЫЕ часы, шаг NTP или правка руками уводят их
// вперёд и назад. Для абсолютных дедлайнов это негодный источник: назад —
// планировщик замирает до недостижимого срока, вперёд — выдаёт пачку и
// начисляет фиктивный долг. mach_absolute_time монотонен и есть на любой
// версии системы (в отличие от clock_gettime, появившегося в 10.12).
type
TMachTimebase = record
Numer: LongWord;
Denom: LongWord;
end;
function mach_absolute_time: QWord; cdecl; external 'c' name 'mach_absolute_time';
function mach_timebase_info(var Info: TMachTimebase): Integer; cdecl;
external 'c' name 'mach_timebase_info';
var
// ★Тоже ОДНО слово: numer в старшей половине, denom в младшей. Раздельные
// numer/denom дали бы ту же гонку публикации, что и на Windows (Apple Silicon
// — слабая модель памяти), а делить на прочитанный ноль нельзя вовсе.
MachBase: Int64 = 0;
procedure InitMachClock;
// Идемпотентна. Зовётся из initialization — то есть ДО старта потоков.
var
Info: TMachTimebase;
begin
if MachBase <> 0 then Exit;
Info.Numer := 0;
Info.Denom := 0;
if (mach_timebase_info(Info) <> 0) or (Info.Denom = 0) or (Info.Numer = 0) then
begin
// Тайминг-базы нет (быть такого не должно) — считаем тики наносекундами,
// как на Intel, где numer = denom = 1.
Info.Numer := 1;
Info.Denom := 1;
end;
MachBase := (Int64(Info.Numer) shl 32) or Int64(Info.Denom);
end;
{$ENDIF}
function MonotonicUs: Int64;
{$IFDEF WINDOWS}
var
V: Int64;
V, F: Int64;
begin
if QPCFreq <= 0 then
if not QueryPerformanceFrequency(QPCFreq) then Exit(0);
F := QPCFreq; // снимок ОДНОГО слова: см. объявление
if F = 0 then
begin
InitWinClock; // страховка: сюда доходит только вызов раньше
F := QPCFreq; // initialization, чего в норме не бывает
end;
if F < 0 then Exit(Int64(GetTickCount64) * 1000);
QueryPerformanceCounter(V);
Result := (V * 1000000) div QPCFreq;
Result := TicksToUs(V, F);
end;
{$ELSE}
{$IFDEF LINUX}
@@ -71,12 +160,36 @@ begin
Result := Int64(TS.tv_sec) * 1000000 + TS.tv_nsec div 1000;
end;
{$ELSE}
{$IFDEF DARWIN}
var
T, Ns, MachNumer, MachDenom: QWord;
B: Int64;
begin
B := MachBase; // снимок ОДНОГО слова: см. объявление
if B = 0 then
begin
InitMachClock; // страховка: сюда доходит только вызов раньше
B := MachBase; // initialization, чего в норме не бывает
end;
MachNumer := QWord(B) shr 32;
MachDenom := QWord(B) and $FFFFFFFF;
if (MachNumer = 0) or (MachDenom = 0) then Exit(0);
T := mach_absolute_time;
// Через частное и остаток по той же причине, что и на Windows: на Apple
// Silicon numer/denom = 125/3, и произведение тиков на 125 растёт быстро.
Ns := (T div MachDenom) * MachNumer + ((T mod MachDenom) * MachNumer) div MachDenom;
Result := Int64(Ns div 1000);
end;
{$ELSE}
var
TV: TTimeVal;
begin
// Прочие Unix: монотонного источника под рукой нет, остаются стенные часы.
// ★Знать об этом обязан тот, кто ведёт абсолютные дедлайны (см. TCIServer).
fpgettimeofday(@TV, nil);
Result := Int64(TV.tv_sec) * 1000000 + TV.tv_usec;
end;
{$ENDIF}
{$ENDIF}
{$ENDIF}
@@ -168,4 +281,16 @@ begin
ForceDirectories(Result);
end;
// ★Часы инициализируем ЗДЕСЬ, до старта любых потоков: ленивая инициализация
// из рабочего потока публиковала бы состояние обычной записью, а на слабой
// модели памяти сосед вправе увидеть её частично. Одного нуля в монотонных
// часах хватает, чтобы бухгалтерия TX получила скачок времени.
initialization
{$IFDEF WINDOWS}
InitWinClock;
{$ENDIF}
{$IFDEF DARWIN}
InitMachClock;
{$ENDIF}
end.