




















核心摘要 (TL;DR)
- 问题现象:本地单线程运行通常可通过,但 CI 高并发(concurrency)下或者特定测试顺序中,
router_test.dart运行到/add或其他路由测试时会无限期卡死/挂起,直至被 CI 取消(Error: The operation was canceled)。- 根本原因:
- 异步数据源流监听:路由导航至目标页面(如
DashboardScreen、HistoryScreen)后,这些 Widget 在initState中开启了 Isar 数据库的异步查询或流监听(watchObject/watch)。- runAsync 逃逸 zone 控制:为防止 FakeAsync 区域内原生端口死锁,测试使用了
tester.runAsync。这导致 Isar 的异步任务在 Dart 真实事件循环上执行。- 数据库提前关闭导致 mdbx 崩溃:用例执行完毕后,
tearDown立即释放容器并关闭了Isar实例(isar.close())。但上个测试遗留在真实事件循环中的后台流和事务仍在尝试清理或回调,从而在跨测试线程/关闭的库上操作,触发 MDBX 粘性线程断言崩溃:Shell: Assertion failed: ((txn->flags & MDBX_TXN_FINISHED) || (txn->flags & MDBX_NOSTICKYTHREADS) == ...),导致运行器卡死。- 关键解法:修改 [router_test.dart](file:///Users/mac/flutter/UseUp/test/config/router_test.dart) 的 setup 生命周期。使用
setUpAll/tearDownAll共享同一个 Isar 实例,而对ProviderContainer依然保持每个测试单独setUp/tearDown进行状态隔离,使遗留后台异步任务能在关闭前平稳退出。
基本信息
- 问题分类: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.
在 CI (GitHub Actions) 上进行自动化测试流程时,经常发现在执行到 [router_test.dart](file:///Users/mac/flutter/UseUp/test/config/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 异常而直接陷入挂起或死锁状态,表现为整个测试框架无限期卡死。
这一死锁崩溃链条是由 Flutter Widget 测试的 zone 运行机制 和 Isar 原生多线程事务模型 共同作用导致的。
在标准 Widget 测试 testWidgets 中,所有代码都在 FakeAsync 环境下运行,时间流是被 mock 的。然而,Isar 数据库在进行 watch(流监听)时,依赖于底层 C++ 原生端口(FFI Port)跨线程发送数据。
由于 FakeAsync 无法推进真实的原生 Port 事件循环,当组件去监听 Isar Stream 时会发生阻塞。为了让事件循环继续走下去,先前在测试代码中引入了 tester.runAsync 包裹用例:
1 | testWidgets('navigates to /settings/categories', (tester) async { |
因为使用了 tester.runAsync,页面中的 initState(例如 CategorySelector / HistoryScreen 内触发的 isar.categorys.watch() / isar.items.watchObject())所产生的异步微任务和原生 Port 消息,被分发到了真实的 Dart 事件循环上。
此时,用例主体执行完毕(例如断言了当前页面类型正确),测试主线程立即进入 tearDown 周期:
1 | tearDown(() async { |
然而,上一个测试页面在真实事件循环中注册的后台异步流(StreamBuilder / Isar FFI 监听)可能还没有完全注销或正在返回数据。由于 Isar 已经被 tearDown 提前关闭:
这直接触发了 MDBX 引擎的 check_txn 粘性线程断言失败,导致 native 级别死锁挂起,进程无法退出。
要解决这个问题,关键在于避免在后台异步任务还未彻底清理完成时,提前销毁底层的 Isar 数据库实例。
由于路由测试只验证页面跳转和渲染关系,并不依赖于强隔离的、每次都重置的 Isar 数据库数据,我们完全可以将 Isar 数据库生命周期提升到文件级共享。
在 [router_test.dart](file:///Users/mac/flutter/UseUp/test/config/router_test.dart) 中,我们将 Isar 的生命周期修改为 setUpAll 与 tearDownAll。这样在整个测试文件的运行周期内只会创建/关闭一次 Isar,而后端的流即便发生延迟注销,也可以在数据库依然完好的情况下安全退出。
同时,我们仍保留 setUp 与 tearDown 来管理 ProviderContainer,以保证每个测试用例的 Riverpod 业务状态隔离:
1 | late ProviderContainer container; |
setUpAll)是避免跨测试用例异步资源竞争与挂起的最优解。runAsync 内运行的代码,其异步周期脱离了测试 Zone。必须随时提防在测试退出后仍有未收尾的 Future 在真实事件循环中运行所带来的副作用。--concurrency=3),避免过度拥挤抢占 CPU 导致页面渲染延迟进而触发超时。此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。