GoRouter 结合 Isar 运行测试并发与粘性线程死锁排查 - Flutter 测试实战 04
问题概览卡片
基本信息
- 问题分类:Widget 测试卡死 / 数据库多线程冲突
- 环境说明:macOS / Ubuntu (CI) / Flutter 3.x / Isar 4.0.0-dev / MDBX 存储引擎
- 触发条件:Widget 测试中使用
GoRouter并通过tester.runAsync导航 to 监听 Isar Stream 的页面,同时在每个测试用例的tearDown中销毁数据库实例。- 报错摘要:
1
2 Shell: Assertion failed: ((txn->flags & MDBX_TXN_FINISHED) || (txn->flags & MDBX_NOSTICKYTHREADS) == (txn->env->flags & MDBX_NOSTICKYTHREADS)), function check_txn, file mdbx, line 439.
Error: The operation was canceled.
1. 现象描述与现场还原
在 CI (GitHub Actions) 上进行自动化测试流程时,经常发现在执行到 router_test.dart 里的特定路由测试用例时(例如 /add 路由或平台页面适配测试),CI Runner 会在输出特定用例启动信息后突然失去响应,不再输出任何进展:
1 | Error: 30] [ERROR] test/config/router_test.dart > Router Tests /add route without extras shows AddItemScreen |
在本地手动尝试运行该测试文件时,同样有一定概率触发控制台输出底层原生数据库断言错误:
1 | IsarCore using libmdbx: v0.13.8 |
由于该断言发生在原生 C++ / FFI 线程中,导致 Dart 虚拟机测试运行器未捕捉到正常 Dart 异常而直接陷入挂起或死锁状态,表现为整个测试框架无限期卡死。
2. 根本原因分析
这一死锁崩溃链条是由 Flutter Widget 测试的 zone 运行机制 和 Isar 原生多线程事务模型 共同作用导致的。
2.1 逃逸 FakeAsync
在标准 Widget 测试 testWidgets 中,所有代码都在 FakeAsync 环境下运行,时间流是被 mock 的。然而,Isar 数据库在进行 watch(流监听)时,依赖于底层 C++ 原生端口(FFI Port)跨线程发送数据。
由于 FakeAsync 无法推进真实的原生 Port 事件循环,当组件去监听 Isar Stream 时会发生阻塞。为了让事件循环继续走下去,先前在测试代码中引入了 tester.runAsync 包裹用例:
1 | testWidgets('navigates to /settings/categories', (tester) async { |
2.2 并发与 tearDown 提前销毁
因为使用了 tester.runAsync,页面中的 initState(例如 CategorySelector / HistoryScreen 内触发的 isar.categorys.watch() / isar.items.watchObject())所产生的异步微任务和原生 Port 消息,被分发到了真实的 Dart 事件循环上。
此时,用例主体执行完毕(例如断言了当前页面类型正确),测试主线程立即进入 tearDown 周期:
1 | tearDown(() async { |
然而,上一个测试页面在真实事件循环中注册的后台异步流(StreamBuilder / Isar FFI 监听)可能还没有完全注销或正在返回数据。由于 Isar 已经被 tearDown 提前关闭:
- 后台流尝试释放或清理未完成的事务(Transaction);
- 跨线程的 FFI 端口回调试图访问已被销毁的 Isar 资源;
- 或者是事务绑定的线程上下文(Sticky Threads)在并发调度下发生了混乱。
这直接触发了 MDBX 引擎的 check_txn 粘性线程断言失败,导致 native 级别死锁挂起,进程无法退出。
3. 解决方案
要解决这个问题,关键在于避免在后台异步任务还未彻底清理完成时,提前销毁底层的 Isar 数据库实例。
由于路由测试只验证页面跳转和渲染关系,并不依赖于强隔离的、每次都重置的 Isar 数据库数据,我们完全可以将 Isar 数据库生命周期提升到文件级共享。
3.1 改造 Isar 生命周期
在 router_test.dart 中,我们将 Isar 的生命周期修改为 setUpAll 与 tearDownAll。这样在整个测试文件的运行周期内只会创建/关闭一次 Isar,而后端的流即便发生延迟注销,也可以在数据库依然完好的情况下安全退出。
同时,我们仍保留 setUp 与 tearDown 来管理 ProviderContainer,以保证每个测试用例的 Riverpod 业务状态隔离:
1 | late ProviderContainer container; |
4. 预防与建议
- 测试中的数据库共享:对于只读、重度使用路由/异步组件的 Widget 测试集,共享同一内存数据库实例(利用
setUpAll)是避免跨测试用例异步资源竞争与挂起的最优解。 - 谨慎使用 runAsync 包裹整个用例:在
runAsync内运行的代码,其异步周期脱离了测试 Zone。必须随时提防在测试退出后仍有未收尾的 Future 在真实事件循环中运行所带来的副作用。 - 隔离配置环境运行:若测试集数量巨大,尽量控制并发度(例如在 CI 上使用
--concurrency=3),避免过度拥挤抢占 CPU 导致页面渲染延迟进而触发超时。