先看结论与判断条件
- 协程调用的结果、异常、取消、清理和持久化副作用共同构成语义;只断言最终 UI 文案会漏掉父子 Job 与资源释放差异。
- 取消是协作式行为,测试必须包含挂起点和主动检查路径,并验证 finally 清理完成,不能把测试线程结束当成协程已正确取消。
- 超时既要核对外层收到的结果,也要核对内部任务是否仍执行、是否重复提交副作用以及清理代码是否被新的挂起操作打断。
- launch、async、普通 Job 和 SupervisorJob 的异常传播与观察位置不同,不能使用一条统一的捕获断言覆盖所有 builder。
- 进程退出不是 CancellationException。应用恢复后应依据持久化业务状态决定继续、补偿或停止,不能假定协程 finally 一定在进程死亡前运行。
- 设备端 instrumented test、ApplicationExitInfo 和 Android vitals 提供不同证据层级,三者都必须绑定候选和场景,不能互相替代。
先冻结语义基线,而不是先增加重试次数
语义回归首先固定可比较对象:源码提交、构建变体、依赖解析、Kotlin 与协程库版本、加固配置、APK 摘要和签名证书摘要。原始包与加固包必须使用同一业务输入、调度条件和测试时钟;任一基线漂移都会让异常归属失去意义。
每个用例要写出可观察契约,包括返回值或最终状态、异常类型与归属 Job、子任务是否取消、清理动作、持久化副作用、允许的日志事件和禁止出现的状态。协程函数没有抛错不等于业务正确,后台子任务仍可能继续修改数据或重复调用接口。
Kotlin 协程指南描述挂起、调度、结构化并发、取消与异常传播的语言层语义,但不能证明加固变换后的状态机仍然等价。文章只讨论运行回归,不决定哪些协程状态机进入 VMP,也不把指南示例当成具体候选的测试回执。
| 对象 | 固定字段 | 比较方式 | 漂移后处置 |
|---|---|---|---|
| 应用候选 | 文件摘要与签名 | 原始包和加固包一一对应 | 停止归因 |
| 代码与依赖 | 提交和解析版本 | 使用同一基线 | 重新构建对照 |
| 协程环境 | dispatcher、scope、Job 类型 | 逐用例相同 | 分开实验 |
| 测试时间 | 真实或虚拟时钟策略 | 同一超时控制 | 结果不可直接比较 |
| 业务输入 | 公开样本摘要 | 复用同一数据 | 拒绝合并回执 |
| 设备 | 型号、系统和执行身份 | 保存每次回执 | 标记环境差异 |
正常完成路径也要检查挂起前后的状态
正常完成用例至少跨过一次真实或可控挂起点,避免只测试没有发生状态机切换的同步分支。记录挂起前业务状态、恢复后状态、返回值和持久化结果;如果函数要求特定 dispatcher 或线程,还要断言关键 UI 或数据操作发生在约定上下文。
重复调用要区分业务新请求和同一请求重入。测试可给每次业务意图稳定 operationId,确认恢复后没有重复写库、重复扣减或重复提交网络任务。幂等不是协程库自动提供的属性,状态机恢复正确也不代表业务副作用只发生一次。
正常路径是其他场景的语义基线。若原始包已经在给定设备和输入上失败,就不能把相同失败归因于加固;若加固包出现首个差异,应保留最早状态、Job 关系和异常,而不是立刻扩大超时或改用全局 scope 掩盖问题。
| 阶段 | 观察对象 | 通过条件 | 常见假通过 |
|---|---|---|---|
| 调用前 | 输入和初始状态 | 与基线一致 | 复用上次残留数据 |
| 首次挂起 | Job 与业务状态 | 无提前提交 | 只看到线程空闲 |
| 恢复执行 | 上下文与中间状态 | 满足调用契约 | 不检查 dispatcher |
| 函数完成 | 返回或结果对象 | 业务断言成立 | 只断言未抛错 |
| 持久化 | 数据库或队列状态 | 副作用一次 | 重复写入未统计 |
| 资源清理 | listener、流和锁 | 按契约释放 | 依赖进程结束 |
父子取消要验证传播、协作点和 finally 清理
协程取消是协作式的。挂起函数通常会检查取消状态,但纯计算循环如果没有挂起点或主动检查,可能继续执行。回归用例应分别覆盖在挂起点取消和在计算阶段取消,记录取消请求时间、子任务观察时间、停止后的业务状态与是否仍产生副作用。
父 Job 取消时,受其结构化并发约束的子协程应按契约终止;子协程自身失败是否取消兄弟或父任务,则取决于 scope 与 Job 类型。测试要保存父子标识和完成原因,不能仅凭一条 CancellationException 日志判断整棵 Job 树已经停止。
finally 中的同步清理与需要挂起的清理要分开验证。处于已取消上下文时,新的挂起操作可能立即再次收到取消;如果项目确实需要不可取消的短清理区,应明确其边界、失败处置和副作用。文章不提供通用清理时长,实际限制由资源与业务恢复能力决定。
| 场景 | 取消触发 | 核心断言 | 失败信号 |
|---|---|---|---|
| 挂起点取消 | 等待期间取消父 Job | 子任务停止并清理 | 恢复后继续写状态 |
| 计算阶段取消 | 循环执行时取消 | 协作检查生效 | 长期不响应取消 |
| 父取消 | scope 主动取消 | 受管子任务结束 | 遗留活跃 Job |
| 子取消 | 单个子任务取消 | 影响符合 scope 契约 | 兄弟状态异常 |
| finally 清理 | 取消后进入清理 | 必要资源释放 | 清理被静默跳过 |
| 重复取消 | 多次触发取消 | 终态幂等 | 重复补偿或崩溃 |
超时回归要同时观察外层结果和内部任务
超时用例要控制触发点,使操作在明确的挂起或等待阶段越过时限。外层收到超时只证明调用者结束等待,不能证明底层网络、数据库事务或第三方回调已经停止。测试应在超时后继续观察一段由项目定义的窗口,确认没有迟到结果覆盖终态。
当业务使用超时返回空值或降级结果时,要区分正常无数据和真正超时。状态记录至少包含 operationId、开始、超时触发、内部任务完成或取消、补偿动作和最终业务状态。不能将所有超时转成 null 后让上层误判为查询成功但没有内容。
虚拟时间适合验证纯协程调度,但涉及 Android 组件、Binder、真实网络或系统 API 的语义仍需要设备端 instrumented test。两类证据应使用相同场景名称与候选摘要,分别说明调度断言和端到端结果,不能把本地虚拟时钟通过直接外推到真实设备。
| 检查点 | 调用层断言 | 内部任务断言 | 禁止结论 |
|---|---|---|---|
| 触发前 | 仍在等待 | 任务处于预期阶段 | 用 sleep 猜状态 |
| 超时发生 | 返回或抛出符合契约 | 收到取消或停止信号 | 只看 UI 提示 |
| 清理阶段 | 不重复提示 | 资源按契约释放 | 假定 finally 完成 |
| 迟到回调 | 终态不被覆盖 | 回调被丢弃或幂等处理 | 忽略后台写入 |
| 重试 | 复用或创建正确操作身份 | 不重复副作用 | 每次换新标识 |
| 恢复 | 用户得到明确状态 | 任务状态可查询 | 把 null 当成功 |
launch 与 async 的异常不能用同一断言
Kotlin 异常处理文档说明,launch 与 async 对异常的暴露方式不同。launch 中未处理异常可能沿父级传播并到达异常处理器;async 将异常保存在 Deferred 中,通常在 await 时重新抛出。回归必须分别记录异常产生位置、观察位置和最终 Job 状态。
只验证日志里出现异常名称会产生假通过。对于 launch,要确认父 scope、兄弟任务和业务终态符合结构化并发契约;对于 async,要同时覆盖正常 await、延迟 await、未 await 前取消和 await 捕获后的状态。错误被捕获不代表副作用已经回滚。
CancellationException 参与取消传播,不应与业务故障混成同一告警。测试报告保留原始异常类型、cause 链的公开安全摘要、Job 完成原因和业务错误码;生产日志不能记录 token、用户输入或内部敏感数据。加固前后比较的是分类和传播,不是堆栈文本逐字相同。
| 结构 | 异常观察点 | 必须断言 | 遗漏风险 |
|---|---|---|---|
| launch | 父级或异常处理器 | 父子终态和业务状态 | 只记录日志 |
| async | await | Deferred 与调用方状态 | 从不 await |
| coroutineScope | 作用域调用点 | 失败传播和兄弟取消 | 只看失败子任务 |
| supervisorScope | 各子任务观察点 | 隔离与显式处理 | 误以为自动吞异常 |
| 取消异常 | 协作取消链 | 不当作业务故障 | 触发错误重试 |
| 清理异常 | finally 或 close | 保留原始失败关系 | 覆盖首个异常 |
监督作用域要证明失败隔离而不是异常消失
SupervisorJob 或 supervisorScope 的价值在于一个子任务失败时可以按契约隔离其他子任务,但失败仍需被观察和处理。回归用例创建至少两个有独立业务断言的子任务,让其中一个在可控点失败,确认另一个是否继续、父作用域如何结束以及失败如何进入业务状态。
监督隔离不适合掩盖共享事务的一部分失败。如果两个子任务共同构成一个不可分割业务操作,允许一个成功一个失败可能留下半完成状态。工程判断应在测试前写清哪些任务可以独立成功,哪些需要统一补偿;测试只验证批准的契约。
异常处理器的位置同样重要。安装在不会接收根异常的位置,或者把异常转换成成功结果,会让仪表测试看似通过。回执应标出失败子任务、观察者、处理动作、兄弟任务结果和最终对用户可见状态,不用线程名猜测 Job 层级。
| 子任务关系 | 一个子任务失败后 | 兄弟任务 | 业务终态 |
|---|---|---|---|
| 完全独立 | 记录单项失败 | 允许继续 | 部分结果可解释 |
| 共享事务 | 触发统一失败 | 停止或补偿 | 不得半完成 |
| 可降级功能 | 标记降级原因 | 核心任务继续 | 显示受限状态 |
| 安全前置检查 | 立即拒绝主流程 | 不得继续敏感动作 | 明确失败 |
| 后台预取 | 记录可重试状态 | 前台功能不受阻 | 不冒充已完成 |
| 未知关系 | 停止自动决定 | 保留证据 | 要求人工定义契约 |
进程退出与协程取消是两种证据链
系统终止进程时,应用不能假定所有协程 finally 都会完成。需要持久化的业务状态必须在关键提交点写入可靠存储,并能在新进程中判断操作是未开始、执行中、已完成还是需要补偿。用进程重启后的页面正常显示,不能证明退出前的后台任务没有重复或丢失。
ApplicationExitInfo 可以提供进程退出原因,并在支持版本上提供部分诊断材料,但记录必须与应用版本、进程、时间窗和用户路径关联。它不能直接告诉团队某个协程状态机是否正确;价值在于把进程级事实与测试时间线和业务状态拼接。
Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,样本仍受安装来源、用户同意与统计口径限制。上线后可用它发现候选相关的异常模式,但不能用没有看到指标上升证明全部设备的协程语义已经通过。
用代码拒绝缺场景、缺断言和候选错配的回执
下面的 Python 脚本只读取公开安全的设备测试结果 JSON,不运行协程、不控制进程,也不接触生产日志。它要求每个候选覆盖正常完成、父子取消、超时、launch 异常、async await、监督隔离和进程退出,并校验预期状态、实际状态和业务断言。
同一 scenario 的调度器、Job 结构和测试时钟策略形成 contract。若不同结果对同一 contractId 给出不同配置,脚本直接拒绝,避免把变量漂移后的两次执行写成对照。真实项目可以扩展字段,但不能删除候选摘要、设备、场景、结构和断言。
脚本还拒绝包含 token、userInput、privateData 或 stackDump 的结果,促使团队只保存必要摘要与分类。结构门禁通过不代表协程实现通过;它只证明证据覆盖完整、可比较,最终语义仍由设备断言和业务负责人确认。
import json
import re
import sys
from pathlib import Path
REQUIRED_SCENARIOS = {
'normal_complete',
'parent_child_cancel',
'timeout_cleanup',
'launch_uncaught',
'async_await_failure',
'supervisor_isolation',
'process_exit_restore',
}
SENSITIVE = {'token', 'userInput', 'privateData', 'stackDump'}
if len(sys.argv) != 2:
raise SystemExit('usage: validate_coroutine_matrix.py results.json')
input_path = Path(sys.argv[1])
if not input_path.is_file():
raise SystemExit('results file is missing')
data = json.loads(input_path.read_text(encoding='utf-8'))
results = data.get('results')
if not isinstance(results, list) or not results:
raise SystemExit('results array is missing')
coverage = {}
contracts = {}
for index, item in enumerate(results):
if not isinstance(item, dict):
raise SystemExit(f'result {index} is not an object')
leaked = SENSITIVE.intersection(item)
if leaked:
raise SystemExit(f'result {index} contains sensitive fields: {sorted(leaked)}')
required = {'candidateSha256', 'device', 'scenario', 'contract', 'expected', 'actual', 'assertions'}
if not required.issubset(item):
raise SystemExit(f'result {index} is missing required fields')
candidate = str(item['candidateSha256']).lower()
if not re.fullmatch(r'[0-9a-f]{64}', candidate):
raise SystemExit(f'result {index} has an invalid candidate digest')
scenario = str(item['scenario'])
if scenario not in REQUIRED_SCENARIOS:
raise SystemExit(f'result {index} has an unknown scenario')
contract = item['contract']
if not isinstance(contract, dict):
raise SystemExit(f'result {index} contract is not an object')
contract_fields = {'id', 'dispatcher', 'jobStructure', 'clockPolicy'}
if not contract_fields.issubset(contract):
raise SystemExit(f'result {index} has an incomplete contract')
contract_id = str(contract['id'])
stable_contract = json.dumps(contract, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
if contract_id in contracts and contracts[contract_id] != stable_contract:
raise SystemExit(f'contract {contract_id} changes between results')
contracts[contract_id] = stable_contract
assertions = item['assertions']
if not isinstance(assertions, list) or not assertions or any(not str(value).strip() for value in assertions):
raise SystemExit(f'result {index} has no substantive assertions')
if item['actual'] != item['expected'] and not str(item.get('errorType', '')).strip():
raise SystemExit(f'result {index} failed without an error type')
coverage.setdefault(candidate, set()).add(scenario)
for candidate, scenarios in coverage.items():
missing = sorted(REQUIRED_SCENARIOS - scenarios)
if missing:
raise SystemExit(f'candidate {candidate} lacks scenarios: {missing}')
print(json.dumps({'candidates': len(coverage), 'contracts': len(contracts), 'status': 'pass'}))发布门禁必须同时看到语义、进程和线上边界
发布前把七类场景按原始包与加固包成对执行,所有回执绑定同一候选、测试契约和设备。通过条件包括业务断言一致、异常归属符合设计、取消后无迟到副作用、进程恢复不重复提交,以及任何失败都有可定位的场景与错误类型。
若差异只在单一调度器或设备出现,先保持其他变量不变复现,再缩小到具体 suspend 点、Job 关系或恢复状态。增加 timeout、改成 GlobalScope、吞掉异常或重跑至通过都不是修复证据。一次失败也不能仅因后续通过而从报告删除。
需要继续定位线程阻塞或退出问题时,可结合本站的加固后 ANR 分层排查指南核对主线程、锁和进程证据,再携带脱敏矩阵、候选摘要与最早差异申请御盾技术评估。所有申请与控制台动作由御盾中央平台承接,文章不宣称任何未取得的兼容结果。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 协程运行语义由挂起、调度、结构化并发、取消和异常传播共同构成。 | Kotlin coroutines guide 说明协程的核心概念与结构。 | 语言指南不证明加固后的具体状态机仍保持原语义。 |
| 协程取消是协作式行为,挂起点和取消检查会影响停止与清理。 | Kotlin coroutine cancellation 说明取消、超时、finally 和协作检查语义。 | 通用取消规则不能替代目标函数、调度器和业务副作用的逐路径回归。 |
| launch、async、父子 Job 与 CancellationException 的异常观察和传播方式不同。 | Kotlin coroutine exception handling 说明不同 builder 和 Job 结构的异常语义。 | 文档示例不能证明某一加固变换实现正确,也不定义项目业务错误码。 |
| 依赖 Android 运行时、组件和系统 API 的语义应在设备环境执行测试。 | Android instrumented tests 说明设备端 instrumented test 的运行环境与用途。 | 单一设备通过不能代表完整 API、ABI、厂商、账号和网络矩阵。 |
| 进程退出原因和部分诊断材料可通过系统记录关联到应用进程。 | Android ApplicationExitInfo 说明退出原因、ANR trace 与可用诊断字段。 | 退出记录仍需绑定应用版本、时间窗和用户路径,不能直接证明协程根因。 |
| 线上质量信号可以观察崩溃、ANR、启动和设备分布。 | Android vitals 说明相应质量指标与呈现范围。 | 样本受安装来源、用户同意和统计口径限制,没有告警不等于全部设备通过。 |
| 协程回归必须同时断言业务终态、异常归属、子任务状态、清理和持久化副作用。 | 工程判断:只看函数返回或 UI 文案无法发现迟到回调、兄弟取消和半完成状态。 | 断言集合由业务契约决定,文章不提供所有应用通用的通过模板。 |
| 进程退出后的恢复不能依赖 finally 必然执行。 | 工程判断:进程被终止时应用代码没有可靠机会完成所有清理,关键状态必须可持久恢复。 | 具体继续、补偿或停止策略取决于业务幂等、数据模型和服务端状态。 |
工程常见问题
加固后协程接口最终返回正确,是否可以判定回归通过?
不可以。还要检查父子 Job、异常归属、取消后迟到副作用、finally 清理、持久化状态和进程恢复,最终返回只是语义的一部分。
协程取消后为什么任务仍可能继续一段时间?
取消是协作式的。挂起点通常检查取消,但纯计算或未主动检查的代码可能继续执行;测试需要覆盖实际协作点并观察停止后的副作用。
把超时时间调大能否证明加固兼容问题已经修复?
不能。调大超时只是改变条件,可能掩盖迟到任务或阻塞。应保持其他变量不变,定位具体挂起点、调度器、Job 关系和清理路径。
SupervisorJob 会自动处理所有子协程异常吗?
不会。它改变失败传播和隔离关系,但异常仍需在正确位置观察并转换为明确业务状态;共享事务还可能需要统一补偿。
ApplicationExitInfo 能直接定位协程状态机错误吗?
不能。它提供进程级退出事实和部分材料,需要与候选、测试时间线、Job 结构和业务状态关联后,才能支持进一步判断。
申请御盾协助定位协程回归需要准备什么?
准备候选摘要、协程与依赖版本、dispatcher 和 Job 结构、场景矩阵、业务断言、最早差异、退出记录及脱敏错误类型,再通过御盾中央平台提交。