本章导读

第 133 章把 OOM 测试作为四武器之一;本章把它放大成完整学科——185 个 _oom 文件的背后是一套严密的方法论。四块:逐分配点轮转(第 N 次分配失败的循环穷举——"每一次分配失败"都被单独验证,不是"某次失败");失败分配器三形态(xrtMemDebugFailAfter 计数式 / 自写限量式(Limited+Remaining——map OOM 测试的形态)/ 全失败式(test_oom 的 FailAllocator)——各有适用场景);错误系统的枯竭存活(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终止流程成功(Remaining 仍>0——已越过最后一个分配点)
4全程对账debug 堆 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 的逐分配点回滚、xws 服务端路由的 OOM 门禁——每个扩展库带着自己的 _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

刚才发生了什么。 ① testInstallFailAllocator()——全失败形态装配(恒 NULL 的分配器接管库内存)。② 第一组断言:分配失败 + 类别 + 域——错误报告的三件套在最小场景成立。③ 第二组是本章的灵魂:创建错误对象的调用自身失败(返回 NULL)——此刻错误槽保留的仍然是 XERR_MEMORY——"报告失败的通道不会因为失败而失声"。④ [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

刚才发生了什么。 ① 与第 133 章并读:那里的重点是"四种武器"的分类学;这里聚焦注入的精确控制——FailAfter(1) 的边界语义(1st=ok 2nd=NULL)在输出里逐字段可见。② triggered 标志的独立性:触发后仍可查询、Clear 才复位——注入器自身状态可审计(事件流里 alloc 事件在列——失败也是事件)。③ 真实测试用这套入口写轮转:外层 for 循环 N 递增、每轮 Reset 注入状态与被测对象——本示例是这个循环的单步剖面。

为什么 C 库的 OOM 纪律比托管语言更严

对照能看清这份投入的必要性。托管语言(Java/Go/C#)里分配失败是 Throwable——运行时兜底展开栈、GC 保证回收——应用层的清理责任被平台分走大半。C 没有这层兜底:分配失败就是一个 NULL 返回值,清理是调用方 100% 的责任——半初始化结构的每个字段、部分构建的容器的每块内存,都要有人按正确顺序归还。XRT 的 185 个 _oom 文件就是"归还责任"的逐点验收入账。同量级的库极少做到这个密度——多数 C 库的 OOM 处理停留在"关键路径查了 NULL"——这正是嵌入式与长运行服务里"偶发内存泄漏最终 OOM 死机"的根源:不是漏查了某个 NULL,而是某条清理路径从未被任何人走过。轮转穷举把"走过每条清理路径"变成机器保证——这就是它的全部价值。

134.4契约

  • 轮转穷举:失败点扫描覆盖每个分配——每条清理路径都被执行与断言。
  • 三件断言:失败返回 / 状态未变(可继续用或可安全销毁)/ 错误正确(类别+域)。
  • 对账纪律:每轮零泄漏(debug 堆 Reset/Snapshot 交叉验证)。
  • 分配器三形态:计数式/限量式/全失败式——旋钮精确到"哪一次"。
  • 错误存活:OOM 中错误报告不申请内存;创建错误失败时槽保留 XERR_MEMORY。
  • 退化语义:错误对象无法分配时退化为静态 XERR_MEMORY——失败永远可见。
  • 释放侧注入:隔离区捕获双释放/UAF——故障形态覆盖释放路径。
  • 组合压力:变异 × 逐点失败——畸形输入与资源枯竭的乘积空间。
  • 扩展同律:每个扩展库自带 _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:断言"返回失败"却用了已损坏的对象

症状:测试通过但生产崩溃——测试断言失败返回后直接丢弃对象(没走 Destroy);生产的调用方走了清理路径踩到半初始化字段。

原因:"可安全销毁"是必测项——失败路径的 Destroy 必须在测试里真实执行(这是清理路径的执行凭证)。

错误写法
testRequire(op(&Obj) == false, "oom");
/* 直接 return——Destroy 从未在失败态下执行过 */
正确写法
testRequire(op(&Obj) == false, "oom");
testRequire(destroy(&Obj), "失败态可安全销毁");  /* 清理路径执行 */
坑 3:错误断言只看类别不看域

症状:OOM 被报成 XERR_IO 或参数错误——测试(只查类别)放过——上层重试逻辑因此走错分支(把"没内存"当"暂时 IO 错"重试风暴)。

原因:类别与域共同构成错误身份(第 4 章);OOM 测试断言两者——域错就是错报。

错误写法
testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "kind");
/* 域没查:xrt.io 域的 MEMORY 也会过 */
正确写法
testRequire(xrtErrorKind(xrtGetError()) == XERR_MEMORY, "kind");
testRequire(strcmp(xrtErrorDomain(xrtGetError()),
	"xrt.memory") == 0, "domain");   /* 类别+域双断言 */

编程练习

基础:三形态手感

对同一段含 3 次分配的代码分别用三形态注入:FailAfter(2)(第 3 次失败)/限量 2(等价)/全失败(第 1 次失败)。验收标准:三形态的失败点断言一致;理解等价与差异(计数式带事件审计)。

进阶:轮转器封装

写 oom_scan(被测流程, 最大点数) 泛型轮转器:内部循环注入-重置-断言-对账。对第 18 章 map 的插入跑一遍。验收标准:扫描自动终止(越过最后分配点);每轮零泄漏;故意在清理路径留泄漏——轮转器能指出第几轮。

挑战:变异 × OOM 乘积

解析器(如 percent 编码)的变异测试叠加逐点失败:外层变异输入、内层失败点扫描——抽样组合(全乘积太大)。验收标准:抽样策略能说明(如每变异 10 点抽 1 失败点);发现或证明不存在"仅畸形输入+仅枯竭时"才暴露的缺陷。

小结

知识点速查
轮转穷举失败点 1..N 扫描——每条清理路径都执行过
三件断言失败返回 / 状态未变(两级)/ 错误类别+域
三形态计数式(审计)/限量式(扫描)/全失败式(快速)
错误存活OOM 中报告不申请内存;创建错误失败保留 XERR_MEMORY
退化语义错误对象不可分配→静态 XERR_MEMORY——失败永远可见
释放侧隔离区捕获双释放/UAF——注入不止分配失败
组合压力变异×逐点失败——乘积空间的抽样探索
重置纪律每轮完整重置注入器与被测对象——穷举不退化
规模事实185 个 _oom 文件——纪律的数字化证明
扩展同律扩展库自带 _oom 矩阵——叠加即门禁

📖 本章涉及的 API 参考手册:memory_debug(19 个条目)memory_stats(5 个条目)error(34 个条目)

点击查看完整签名、参数约束、返回值与错误说明