可读的无I/O状态机
无I/O库是一个将I/O抽离出来的状态机。调用者向其提供输入(handle_*,每个都带now),取出其输出(poll_transmit、poll_event、poll_timeout),并拥有套接字和定时器。只有人们能够理解它的逻辑,这样的代码才值得存在:无论是下一位维护者、驱动的作者,还是对照RFC进行审查的人。他们每个人都应该能快速回答四个问题:
- 状态:每个长期存在的实体(连接、查询、流、会话)可以处于哪些状态,每个状态下存在哪些数据?
- 转换:哪些输入使它从一个状态转换到另一个状态,每个转换发生在代码的哪个位置?
- 输出:每个转换排队了什么(传输、事件、写入),顺序如何?
- 时间:它什么时候需要被唤醒,哪个规则设定了每个截止时间?
你的工作是让这些答案在类型和代码中变得可见,同时让状态机保持完全相同的功能。你编辑工作树,并以总结结束。
<skill> 下面指的是该技能的基础目录,即包含此SKILL.md的文件夹。
基本规则
-
行为保持完全相同。 在无I/O crate中,行为涵盖看起来像是表面功夫的东西:
- 输出排队的顺序
- 映射的迭代顺序(
BTreeMap或插入顺序的Vec决定谁先谁后) poll_timeout返回的即时时刻- 是否发出空的写入或无操作事件
- 实体失败或关闭时的错误
- 你更改字段的
#[derive(Debug)]类型的输出(如果快照、日志断言或错误消息打印了它)
通过公共API的测试以及每个快照都必须保持不变。如果某个测试需要编辑,说明那个步骤改变了行为:撤销它。crate内部命名私有项的单元测试可能需要与代码相同的机械编辑(变为变体的标志、重命名的辅助函数)。只做那个编辑,保持每个断言检查的内容,并在总结中列出该测试。
-
不要改动公共API。 保持
pub项的名称、签名、变体和字段不变,因为驱动程序和下游crate依赖于它们(以及语义化版本)。pub(crate)和私有项目是可以修改的。将API改进作为建议放在总结中。如何检查这一点见步骤5。 -
不要为了可读性牺牲性能。 不要在每次输入(每个数据包、消息或查询)的路径上引入新的克隆、分配或对映射的多余遍历。
mem::replace或mem::take是免费的;克隆一个Vec来绕过借用检查则不是。 -
小步骤工作。 每个步骤都要能构建并通过检查,这样失败的检查就能指向一个更改,并且diff可以逐块审查。
-
遵循项目规范。 在编辑之前,阅读其代理和贡献者文档(AGENTS.md、CLAUDE.md、CONTRIBUTING)、lint配置和CI工作流。匹配周围的注释密度和惯用风格。如果项目将文档与代码保持同步(生命周期表、将行映射到函数的注释、规范覆盖表),在同一个步骤中更新它们。
根据更改调整工作量
安全网的存在是为了捕获现有测试可能遗漏的行为更改,因此要根据你计划更改的大小来调整:
- 小。 一个handler或几个辅助函数,不改变哪些字段持有状态的字段。项目检查在之前和之后各运行一次,并跳过下面标记为大的所有内容。在总结中,只映射你触及的实体。
- 中。 一个模块中的多个转换或输出辅助函数。在每个步骤之前和结束时运行检查。仅在现有测试没有固定你所更改路径的确切输出时,并且仅在这些路径上构建差分测试工具。不需要模糊测试、额外的属性测试用例或基准测试:项目自身在其正常设置下的检查就是这里合适的权重。
- 大。 用阶段枚举替换标志,将状态字段的许多写入点合并到转换中,大多数handler被重塑。使用整个工作流。
在映射(步骤2)之后决定大小。如果更改中途变大,提升一个等级并在总结中说明。
工作流
1. 设置安全网
在第一次编辑之前执行此操作,以便后续每个失败都是你的,而不是预先存在的。
- 记录基准。 运行
git status并记录基准提交:之后的每次比较都针对它。如果你计划触及的文件已经有未提交的更改,请在将你的编辑混入其中之前告知用户。如果你继续在其基础上进行,请使用git stash create作为基准:它会将它们记录为一个提交而不触及工作树。 - 修复一个检查命令。 使用项目自身的命令(如果有的话)(CI工作流、justfile、xtask、其代理文档中的命令)。否则使用捆绑的门控:
<skill>/scripts/check.sh <repo> default all。列出CI使用的特性集,例如default all none tls。门控会检查格式但不修改,然后为每个特性集运行clippy和测试,如果任何操作失败则退出非零代码。使用CHECK_PACKAGES="-p <crate> -p <dependent>"将其范围限制在你更改的包及其依赖它的成员,最后对整个工作空间运行一次。如果CI还运行其他东西(MSRV检查、额外的lint标志),也一起运行。 - 如果基线不是绿色的,不要修复它。 记录哪些检查失败以及如何失败,告知用户,然后从那时起期望相同的失败并且没有新的失败。只格式化你编辑的文件。来自环境而非代码的失败(带有仅夜间版标志的全局cargo配置、缺少的工具链或工具)会作为“无法运行”放在“验证”下,并说明原因。如果仓库没有CI,则从
rust-version、所用版本和语言特性中获取工具链下限,并说明你能运行什么和不能运行什么。 - 大型更改:构建差分测试工具 当现有测试没有固定你将更改路径的确切输出时。固定意味着跟踪或快照测试,这些测试会逐字断言输出序列和截止时间;仅对最终状态的断言不算。该工具并排运行基线和工作树,在同一组随机调用上,并比较每个输出。
<skill>/scripts/difftest_scaffold.py在仓库外部创建crate对和模板。填充模板是真正的工作,所以要为此预留时间:先阅读 references/differential.md。在依赖它之前,检查它是否能捕获一个植入的单行变异;一个就足够了。 - 保持工具比重构更便宜。 驱动你重构的路径,而不是整个crate。如果覆盖某条路径的成本高于对其更改的成本,则在“验证”下报告该缺口,并依靠该路径的现有测试。示例:位于TLS或加密后面的路径、必须规范化的非确定性、必须模拟的对端。
- 仅在大型更改时:用更多用例运行慢速检查一次。 运行属性测试,调整大小使其在几分钟内完成(默认的十倍,或者当用例便宜时使用
PROPTEST_CASES=10000),并在最后再次运行。仅当模糊测试目标能触及你要更改的代码时运行它,时间限制在30-60秒。仅当更改会加重每个输入路径的工作量时才运行基准测试;重构控制流不需要它们。 - 填补应该比更改更持久的覆盖缺口。 如果你要重构的路径没有覆盖并且你没有构建工具,则首先添加固定其当前输出的测试。如果你正在构建工具,它会覆盖重构期间的缺口;仅为它发现的、值得保留的行为(你故意保留的怪癖)添加一个测试。将新测试放在新文件或新模块中,作为单独的步骤,以便现有测试保持不变。唯一例外是单个crate测试布局(如
tests/main.rs)需要的那个mod行。
2. 绘制机器映射
映射请求涉及的实体,以及计划更改会触及的任何实体。在大型crate中,如果请求没有说明哪个部分重要,选择用户命名的实体并说明你省略了什么。在更改任何内容之前写下映射(临时文件也可以)。它会成为总结的状态映射,并且通常是模块文档。
- 实体。 对于范围内的每个实体,列出其字段,并按它们有意义的阶段分组。
bool和Option的组合(它们共同编码一个阶段)是主要目标。 - 输入。 每个接受外部数据或
now的pub fn,以及每个可以触发什么。 - 写入点。 对于每个状态字段,每个赋值它的位置(搜索
.field =、mem::replace、.take()、+= 1)。具有许多写入点的字段是一个分散在文件中的转换。 - 输出。 每次推送到输出队列的操作,以及每个
poll_timeout读取的截止时间。 - 不变量。 必须始终成立的条件:一个ID恰好在一个映射中、每当有东西等待时就要设置截止时间、引用的连接存在。
- 怪癖。 看起来像是偶然行为:宽松的解析、两个错误之间的奇怪优先级、对垃圾输入的回答仍然发送回复、截止时间之后仍接受输入。你保持它(规则1),但为用户列出它。
然后以表格形式编写映射:状态、输入、下一个状态、输出。如果你无法在不追踪多个函数的情况下从代码中填写它,那么代码还不够清晰,这就是工作要做的部分。
3. 选择更改
references/patterns.md 是一个目录,包含前后示例以及每个模式不划算的情况。阅读其索引,然后阅读你的映射所揭示的任何内容的相应部分,包括它们的“不值得”段落。对于大型更改,阅读整个文件。根据每个更改缩短四个问题之一答案的程度,对比diff的大小,对候选更改进行排序。通常最有效的方法:
- 一个枚举,其变体携带每个阶段的数据,取代编码阶段的标志和选项(模式1)
- 每个转换一个命名函数,以便每个状态字段只有少数写入点(模式2)
- 处理程序匹配输入、决定、应用转换并排队输出,沿一个一致的调度轴(模式3)
- 每种输出一个辅助函数,以其意图命名(模式4)
- 每个截止时间规则只写一次,
poll_timeout从状态派生(模式5) - 在读者进入的地方写下生命周期,并在字段上写下不变量(模式9)
如果用户命名了一个函数或一部分,只更改那部分及其所需的内容。你提取的辅助函数可能会被其自然的兄弟姐妹重用,但不要仅仅因为辅助函数也适合那里就重新编写请求之外的代码。将这些改进列在“建议”下:询问一个函数的审查者不应该需要检查第二个函数。
排除那些只移动代码、重命名局部变量以迎合个人品味、以及任何使代码变长但不能使问题更容易回答的事情。当用户要求进行移动本身(将长文件拆分为模块)时,请参见模式11。已经可读性很好的代码可以保持不变:在总结中说明,而不是发明工作。
4. 逐步应用
对步骤进行排序,使每个步骤都可审查,并且没有一个步骤添加后续步骤会删除的代码:
- 纯添加和提取:输出辅助函数、命名谓词、截止时间辅助函数、将特性化门控步骤移动到辅助函数、将线路编码和解析移动到函数(模式4、5、6、10)。
- 核心更改:阶段枚举,以及它强制进行的转换和处理程序形状(模式1-3、7、12)。这通常必须作为一个步骤落地。如果是这样,说明原因。
- 命名(模式8)。
- 文档(模式9)。
- 重新排序方法,最后且单独进行,如果需要的话(模式11)。
在编辑之间,使用快速循环:clippy和你正在更改的包的测试,在默认特性集中,加上你构建的工具(如果有)。在每个步骤结束时,运行步骤1中的检查命令。然后如果用户想要提交(他们要求了,或者项目的工作流期望提交),则将该步骤作为一个提交;否则保持工作未提交并在总结中列出步骤。不要用下一个步骤会移除的脚手架(仅为了一个步骤变绿而添加的 cfg 门控或 #[allow])来静默lint。相反,重新排序步骤。
在借用检查器迫使代码变形的地方放弃一个模式,因为变形的代码比你开始时的更难看。常见迹象:
- 一个方法将
self的兄弟字段(例如输出队列&mut VecDeque<Transmit>)或其自己的映射键作为参数,仅为了绕过借用 - 一个
&self方法仅为了读取配置而接受一个子结构 - 克隆的键或额外的查找
通常的修复方法:
- 返回输出并让调用者排队
- 将辅助函数放在实体上并传入它需要的
Copy配置值 - 保持代码内联
5. 验证并总结
最后一次运行检查命令,覆盖整个工作空间,加上你在开始时运行的其他任何东西:慢速检查和工具。
然后检查公共API,如果diff触及了 pub 项、派生、trait实现、字段类型(诸如 Send 之类的自动trait遵循私有字段)或项上的 cfg:
- 每个特性集使用一次
cargo semver-checks --baseline-rev <base>,使用显式标志:--default-features、--all-features、--only-explicit-features(无),以及--only-explicit-features --features x。 - 它只捕获破坏性更改,因此还要确认没有新东西变成公开:如果安装了
cargo public-api diff则使用它,或者阅读git diff <base> | grep -E '^\+.*\bpub\b'。 - 如果没有安装工具,请在安装前询问,否则将API检查报告为未运行。
- 如果diff只触及函数体和私有项,请说明。
然后编写总结。