Ориентация главы

Глава 133 показала OOM-тестирование как одно из четырёх оружия; эта глава расширяет его в полную дисциплину — за 185 файлами _oom стоит строгая методология. Четыре блока: поточечное вращение (циклическое перечисление «N-е выделение отказывает» — «каждое выделение, отказывая, проверяется отдельно», а не «какое-то однажды откажет»); три формы провального распределителя (xrtMemDebugFailAfter — счётная / самописная квотная (Limited+Remaining — форма OOM-теста map) / полностью провальная (FailAllocator у test_oom) — у каждой свой сценарий); выживание системы ошибок при истощении (отчёт об ошибке в OOM не выделяет память — при отказе xrtErrorCreate слот сохраняет XERR_MEMORY: сообщить «нет памяти» самой этой операции память не нужна); отладочная куча, вооружённая (FailAfter вместе с потоком событий — внедрённый отказ аудируем; карантина ловит двойное освобождение/UAF — формы сбоев не ограничиваются отказом выделения). Одна цель: «нет ресурсов» из рантайм-предположения становится исчерпывающе перечисленным фактом.

134.1引入

Необходимость поточечного вращения видна из одного вывода: в функции 5 выделений — очистка при отказе 3-го отличается от отказа 5-го (первая откатывает два полных побочных эффекта, вторая четыре с половиной); тестирование только «1-го отказа» (или вовсе без) оставляет 4 пути очистки неисполненными — любая утечка наполовину инициализированного/порча состояния среди них идёт прямо в прод. Метод вращения: точка отказа сканирует от 1 до 5 (каждый раунд перезапускает полный поток) — каждый путь очистки исполнен, каждое состояние отказа утверждено. 185 файлов — развёртка этого сканирования по всем модулям — само число есть доказательство дисциплины.

Выживание системы ошибок заслуживает отдельного акцента: если бы путь сообщения об ошибке требовал выделения, в OOM получилось бы «отчёт об отказе отказывает» — отладчик видит молчаливое падение вместо структурированной ошибки. Объект ошибки XRT (глава 4) избежал этого в проекте (неизменяемые объекты с минимальным выделением, при отказе деградация категории в статическую XERR_MEMORY) — а test_oom и есть машинная проверка этого обещания: «проверить, что отчёт об ошибке OOM сам не запрашивает память» — первая строка его комментария.

134.2概念

Поточечное вращение: форма перечисления

1Сборкаквотный распределитель (Limited=true, Remaining=N)
2ЦиклN убывает от стартового -> каждый раунд сбрасывает тестируемый объект -> полный прогон потока
3> тройное утверждениеотказ в возврате / состояние не изменилось (продолжает работать или безопасно разрушается) / ошибка верна
4Завершениепоток успешен (Remaining всё ещё >0 - прошли за последней точкой выделения)
5Сверка весь путьReset/Snapshot отладочной кучи - ноль утечек каждый раунд

Сильное утверждение «состояние не изменилось» двухуровневое: продолжает работать (OOM временен — после освобождения памяти тот же объект работает как раньше, например map после неудачной вставки всё ещё ищет); безопасно разрушается (минимум путь Destroy не падает второй раз — очистка наполовину инициализированного полна). Оба уровня — контракт: первый — качество, второй — нижняя планка.

Три формы провального распределителя

ФормаМеханизмПрименение
Счётная (xrtMemDebugFailAfter(N))первые N успешны, N+1-й отказывает; Clear сбрасывает и триггер-битодно-точечная инъекция + аудит событий (приложения и тесты делят)
Квотная (самописная: Fail/Limited/Remaining)пересылает вниз, пока не исчерпается квотасканирование вращением (Remaining убывает — это и есть скан)
Полностью провальная (FailAllocator)всегда NULLкрайние пути (быстрая проверка отказа самого первого выделения)

Три формы — «памятный бюджет как управляемая ручка» — тестирование из бинарного мира «есть память/нет» входит в точный мир «в какой момент нет».

Выживание системы ошибок при истощении

Цепочка утверждений test_oom — образец: xrtMalloc(32)==NULL + категория ошибки XERR_MEMORY + домен xrt.memory; затем xrtErrorCreate(...)==NULL (выделение самого объекта ошибки отказывает) + в слоте всё ещё XERR_MEMORY — «отказ создания ошибки не затирает факт OOM». Это проверяет глубокое обещание «минимальных зависимостей» главы 4: канал ошибок сохраняет работоспособность при истощении ресурсов. Семантика деградации при полном OOM (многократно в контрактах): «если OOM сделал невозможным выделение даже объектов ошибки, деградировать в статический XERR_MEMORY» — не каждый отказ несёт богатый контекст, но каждый отказ виден.

Расширение форм сбоев: не только отказ выделения

Карантин отладочной кучи (глава 6) расширяет инъекцию сбоев на сторону освобождения: двойное освобождение, использование после освобождения (UAF) — отложенное переиспользование карантина делает такие ошибки в тестах неизбежно видимыми (а не молча проходящими). Комбинация _mutation-тестов с OOM (мутация + поточечный отказ) — предельная форма давления: уродливый ввод × истощение ресурсов — глубочайшие углы парсера тоже освещены. Та же дисциплина в расширениях: поточечный откат xhttp, OOM-шлюз серверной маршрутизации xws — каждая библиотека-расширение везёт свой _oom-матрицу (тот же шлюз после наложения манифестов главы 130).

134.3示例

Первая полная программа: выживание системы ошибок при истощении

Программа ниже из tests/core/test_oom.c — это сам тест (не пример), показывающий центральное утверждение выживания:

tests/core/test_oom.c源码 ↗
#include "../test_allocator.h"



/* 验证 OOM 错误报告本身不申请内存。 */
int main(void)
{
	testRequire(testInstallFailAllocator(), "failure allocator install failed");
	testRequire(xrtMalloc(32) == NULL, "allocation should fail");
	testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "allocation failure must report memory error");
	testRequire(strcmp(xrtErrorDomain(xrtGetError()), "xrt.memory") == 0, "OOM domain mismatch");

	xrtClearError();
	testRequire(xrtErrorCreate(XERR_IO, "test", 1, "cannot allocate") == NULL, "error allocation should fail");
	testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "error allocation failure must preserve OOM");
	xrtClearError();
	printf("[PASS] oom\n");
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c tests/core/test_oom.c -lws2_32 -liphlpapi[PASS] oom

Что только что произошло. (1) testInstallFailAllocator() — сборка полностью провальной формы (распределитель «всегда NULL» берёт память библиотеки на себя). (2) Первая группа утверждений: отказ выделения + категория + домен — тройка сообщения об ошибке в минимальном сценарии выполняется. (3) Вторая группа — душа главы: сам вызов создания объекта ошибки отказывает (возвращает NULL) — а в слоте ошибки всё ещё XERR_MEMORY — «канал, сообщающий об отказах, не немеет из-за отказа». (4) За единственной строкой [PASS] oom — нижняя гарантия всех путей истощения ресурсов. Файл в десяток строк — потому что он фундамент (_oom сложных модулей наслаивает сверху бизнес-утверждения).

Вторая полная программа: прикладная грань инъекции

Вторая программа из examples/memory/fail_inject — ощущение трёхрежимной ручки:

examples/memory/fail_inject/main.c源码 ↗
























 */

#include <stdio.h>
#include <xrt.h>

/* 事件访问器:只打印前几个 alloc 事件的名字。 */
static bool printEvent(const xmemdebugevent* pEvent, ptr pUserData)
{
	size_t* pCount = (size_t*)pUserData;

	if ( *pCount < 1u && pEvent->Kind == XMEMDEBUG_ALLOC ) {
		printf("event[%zu]: %s\n", *pCount,
			xrtMemDebugEventName(pEvent->Kind));
	}
	(*pCount)++;
	return true;
}

int main(void)
{
	ptr pFirst;
	ptr pSecond;
	ptr pThird;
	size_t iEvents = 0;

	(void)xrtMemDebugReset();
	(void)xrtMemDebugEnable(true);
	printf("enabled=%d\n", xrtMemDebugEnabled() ? 1 : 0);

	/* 注入:第 2 次分配失败。 */
	(void)xrtMemDebugFailAfter(1u);
	pFirst = xrtMalloc(16);
	pSecond = xrtMalloc(16);
	printf("fail-after-2: 1st=%s 2nd=%s triggered=%d\n",
		pFirst != NULL ? "ok" : "NULL",
		pSecond != NULL ? "ok" : "NULL",
		xrtMemDebugFailTriggered() ? 1 : 0);
	xrtFree(pFirst);

	/* 事件流:最早的 alloc 事件可按名访问。 */
	(void)xrtMemDebugVisit(printEvent, &iEvents);

	/* 清除未触发注入后再分配恢复成功;已触发的标志保持。 */
	xrtMemDebugFailClear();
	pThird = xrtMalloc(16);
	printf("cleared: 3rd=%s triggered-still=%d\n",
		pThird != NULL ? "ok" : "NULL",
		xrtMemDebugFailTriggered() ? 1 : 0);
	xrtFree(pThird);

	(void)xrtMemDebugEnable(false);
	printf("reset=%d\n", xrtMemDebugReset() ? 1 : 0);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c examples/memory/fail_inject/main.c -lws2_32 -liphlpapienabled=1fail-after-2: 1st=ok 2nd=NULL triggered=1event[0]: alloccleared: 3rd=ok triggered-still=0reset=1

Что только что произошло. (1) Параллельно с главой 132: там в центре таксономия «четырёх оружия»; здесь фокус на точном управлении инъекцией — граничную семантику FailAfter(1) (1st=ok 2nd=NULL) видно в выводе поле за полем. (2) Независимость флага triggered: после срабатывания доступен опрос, сбрасывает только Clear — состояние самого инъектора аудируемо (событие alloc стоит в потоке событий — отказ тоже событие). (3) Настоящие тесты пишут вращение на этих входах: внешний цикл for наращивает N, каждый раунд Reset-ит состояние инъектора и тестируемого объекта — этот образец есть одношаговый разрез того цикла.

Почему OOM-дисциплина C-библиотек строже, чем у управляемых языков

Сопоставление показывает необходимость этих вложений. В управляемых языках (Java/Go/C#) отказ выделения — Throwable — рантайм подстилает разматывание стека, GC гарантирует возврат — платформа забирает большую часть очистительной ответственности приложения. У C такой подстилки нет: отказ выделения — просто NULL в возврате, очистка на 100% в ответственности вызывающего — каждое поле наполовину инициализированной структуры, каждый блок частично построенного контейнера кто-то обязан вернуть в правильном порядке. 185 файлов _oom XRT — поточечная приёмка «обязанности возврата». Библиотек того же масштаба с такой плотностью почти нет — OOM-обработка большинства C-библиотек останавливается на «критический путь проверил NULL» — это и есть корень «случайных утечек памяти, в конце концов замораживающих OOM» во встраиваемых и долго работающих сервисах: не какой-то NULL пропущен, а какой-то путь очистки никогда никем не пройден. Вращательное перечисление делает «каждый путь очистки пройден» машинной гарантией — в этом вся его ценность.

134.4契约

  • Вращательное перечисление: скан точек отказа покрывает каждое выделение — каждый путь очистки исполнен и утверждён.
  • Тройное утверждение: отказ в возврате / состояние не изменилось (продолжает работать или безопасно разрушается) / ошибка верна (категория + домен).
  • Дисциплина сверки: ноль утечек каждый раунд (Reset/Snapshot отладочной кучи перекрёстно).
  • Три формы распределителя: счётная/квотная/полностью провальная — ручка точна до «в какой момент».
  • Выживание ошибки: отчёт в OOM не выделяет память; при отказе создания ошибки слот сохраняет XERR_MEMORY.
  • Семантика деградации: при невозможности выделить объект ошибки — деградация в статический XERR_MEMORY — отказы видны всегда.
  • Инъекция на стороне освобождения: карантин ловит двойное освобождение/UAF — формы сбоев покрывают пути освобождения.
  • Комбинированное давление: мутация × поточечный отказ — пространство произведения уродливого ввода и истощения ресурсов.
  • Расширения — тот же закон: каждая библиотека-расширение с собственной _oom-матрицей — наложение манифеста есть наложение шлюза.

Типичные ошибки

Ловушка 1: OOM-тест не сбрасывает состояние инъектора:Ловушка 1: OOM-тест не сбрасывает состояние инъектора

Симптом: со второго раунда вращения всё «отказывает» — триггер-бит FailAfter первого раунда всё ещё висит; или Remaining квотного распределителя не заполнен заново — проверяется «всегда отказ», а не «отказ в N-й раз».

Причина: инъектор состояние держит — каждый раунд цикла обязан сбрасывать полностью (FailClear/заполнение Remaining/пересборка тестируемого объекта). Забытый сброс превращает «перечисление» в «повторное тестирование первого состояния».

错误写法
for ( N = 1; N < 100; N++ ) {
	fail_after(N);
	run_flow();        /* остатки триггер-бита/квоты прошлого раунда - при N>1 всё отказывает */
}
正确写法
for ( N = 1; N < 100; N++ ) {
	reset_injector();   /* Clear триггер-бита / заполнить квоту */
	rebuild_target();  /* пересобрать тестируемый объект в согласованную стартовую точку */
	fail_after(N);
	run_flow();
	assert_three();    /* тройное утверждение */
}
Ловушка 2: утверждён «отказ в возврате» на уже испорченном объекте:Ловушка 2: утверждён «отказ в возврате» на уже испорченном объекте

Симптом: тест проходит, прод падает — тест после утверждения отказа выбросил объект (Destroy не шёл); вызывающий в проде прошёл путём очистки и наступил на наполовину инициализированное поле.

Причина: «безопасно разрушается» — обязательный пункт теста — Destroy пути отказа обязан реально исполниться в тесте (это удостоверение исполнения путей очистки).

错误写法
testRequire(op(&Obj) == false, "oom");
/* сразу return - Destroy в состоянии отказа ни разу не исполнялся */
正确写法
testRequire(op(&Obj) == false, "oom");
testRequire(destroy(&Obj), "失败态可安全销毁");  /* путь очистки исполнен */
Ловушка 3: утверждение ошибки смотрит только категорию, не домен:Ловушка 3: утверждение ошибки смотрит только категорию, не домен

Симптом: OOM выдан как XERR_IO или ошибка параметра — тест (только категория) пропускает — логика повторов верхнего уровня идёт не в ту ветку («нет памяти» принято за «временную IO-ошибку», буря повторов).

Причина: категория и домен вместе образуют идентичность ошибки (глава 4); OOM-тесты утверждают оба — неверный домен есть неверный отчёт.

错误写法
testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "kind");
/* домен не проверен: MEMORY домена xrt.io тоже пройдёт */
正确写法
testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "kind");
testRequire(strcmp(xrtErrorDomain(xrtGetError()),
	"xrt.memory") == 0, "domain");   /* двойное утверждение категория+домен */

Практические задания

Базовое: ощущение трёх форм

На один и тот же код с 3 выделениями инъекция всех трёх форм: FailAfter(2) (отказ 3-го)/квота 2 (эквивалент)/полный отказ (отказ 1-го). Критерии приёмки: утверждения точек отказа трёх форм совпадают; вы понимаете эквивалентность и различие (счётная несёт аудит событий).

Продвинутое: обёртка вращения

Написать родовой ротатор oom_scan(被测流程, 最大点数) (тестируемый поток, максимум точек): внутренний цикл инъекция-сброс-утверждение-сверка. Прогнать вставку map главы 18. Критерии приёмки: скан завершается автоматически (за последней точкой выделения); ноль утечек каждый раунд; намеренно оставить утечку на пути очистки — ротатор называет раунд.

Вызов: произведение мутация × OOM

Мутационные тесты парсера (скажем, percent-кодирования) плюс поточечный отказ: внешняя мутация ввода, внутренний скан точек отказа — выборка комбинаций (полное произведение слишком велико). Критерии приёмки: стратегия выборки обоснована (скажем, 1 точка отказа на 10 мутаций); найден дефект «виден только при уродливом вводе + истощении» — или доказано его отсутствие.

Итоги

ТемаКратко
Вращательное перечислениескан точек отказа 1..N — каждый путь очистки пройден
Тройное утверждениеотказ в возврате / состояние не изменилось (два уровня) / категория+домен ошибки
Три формысчётная (аудит)/квотная (скан)/полностью провальная (быстро)
Выживание ошибкиотчёт в OOM не выделяет память; отказ создания ошибки сохраняет XERR_MEMORY
Семантика деградацииобъект ошибки не выделяется → статический XERR_MEMORY — отказы видны всегда
Сторона освобождениякарантин ловит двойное освобождение/UAF — инъекция не только отказ выделения
Комбинированное давлениемутация × поточечный отказ — выборочное исследование пространства произведения
Дисциплина сбросаполный сброс инъектора и тестируемого объекта каждый раунд — перечисление не деградирует
Факт масштаба185 файлов _oom — дисциплина, доказанная числом
Расширения — тот же законрасширения с _oom-матрицами — наложение есть шлюз

📖 Справочники API в этой главе:memory_debug (19 записей)memory_stats (5 записей)error (34 записей)

Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки