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
2
3
Error: 30] [ERROR] test/config/router_test.dart > Router Tests /add route without extras shows AddItemScreen
[15:46:30] [Start] test/config/router_test.dart > Router Tests /add route with extras passes parameters
Error: The operation was canceled.

在本地手动尝试运行该测试文件时,同样有一定概率触发控制台输出底层原生数据库断言错误:

1
2
3
4
IsarCore using libmdbx: v0.13.8
...
Shell: Assertion failed: ((txn->flags & MDBX_TXN_FINISHED) || (txn->flags & MDBX_NOSTICKYTHREADS) == (txn->env->flags & MDBX_NOSTICKYTHREADS)), function check_txn, file mdbx, line 439.
Bad state: Cannot add event while adding stream.

由于该断言发生在原生 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
2
3
4
5
6
7
testWidgets('navigates to /settings/categories', (tester) async {
await tester.runAsync(() async {
// 强制脱离 FakeAsync 并在真实事件循环中运行
await tester.pumpWidget(_buildRouterApp(container));
...
});
});

2.2 并发与 tearDown 提前销毁

因为使用了 tester.runAsync,页面中的 initState(例如 CategorySelector / HistoryScreen 内触发的 isar.categorys.watch() / isar.items.watchObject())所产生的异步微任务和原生 Port 消息,被分发到了真实的 Dart 事件循环上。

此时,用例主体执行完毕(例如断言了当前页面类型正确),测试主线程立即进入 tearDown 周期:

1
2
3
4
tearDown(() async {
container.dispose();
await isar.close(deleteFromDisk: true); // 销毁并关闭 Isar 实例
});

然而,上一个测试页面在真实事件循环中注册的后台异步流(StreamBuilder / Isar FFI 监听)可能还没有完全注销或正在返回数据。由于 Isar 已经被 tearDown 提前关闭:

  1. 后台流尝试释放或清理未完成的事务(Transaction);
  2. 跨线程的 FFI 端口回调试图访问已被销毁的 Isar 资源;
  3. 或者是事务绑定的线程上下文(Sticky Threads)在并发调度下发生了混乱。

这直接触发了 MDBX 引擎的 check_txn 粘性线程断言失败,导致 native 级别死锁挂起,进程无法退出。


3. 解决方案

要解决这个问题,关键在于避免在后台异步任务还未彻底清理完成时,提前销毁底层的 Isar 数据库实例

由于路由测试只验证页面跳转和渲染关系,并不依赖于强隔离的、每次都重置的 Isar 数据库数据,我们完全可以将 Isar 数据库生命周期提升到文件级共享。

3.1 改造 Isar 生命周期

router_test.dart 中,我们将 Isar 的生命周期修改为 setUpAlltearDownAll。这样在整个测试文件的运行周期内只会创建/关闭一次 Isar,而后端的流即便发生延迟注销,也可以在数据库依然完好的情况下安全退出。

同时,我们仍保留 setUptearDown 来管理 ProviderContainer,以保证每个测试用例的 Riverpod 业务状态隔离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
     late ProviderContainer container;
late Isar isar;

- setUp(() async {
+ setUpAll(() async {
isar = await IsarTestHelper.createTestIsar();
+ });
+
+ tearDownAll(() async {
+ await isar.close(deleteFromDisk: true);
+ });
+
+ setUp(() {
container = ProviderContainer(
overrides: [
databaseProvider.overrideWithValue(isar),
@@ -65,9 +65,8 @@
);
});

- tearDown(() async {
+ tearDown(() {
container.dispose();
- await isar.close(deleteFromDisk: true);
debugDefaultTargetPlatformOverride = null;
});

4. 预防与建议

  1. 测试中的数据库共享:对于只读、重度使用路由/异步组件的 Widget 测试集,共享同一内存数据库实例(利用 setUpAll)是避免跨测试用例异步资源竞争与挂起的最优解。
  2. 谨慎使用 runAsync 包裹整个用例:在 runAsync 内运行的代码,其异步周期脱离了测试 Zone。必须随时提防在测试退出后仍有未收尾的 Future 在真实事件循环中运行所带来的副作用。
  3. 隔离配置环境运行:若测试集数量巨大,尽量控制并发度(例如在 CI 上使用 --concurrency=3),避免过度拥挤抢占 CPU 导致页面渲染延迟进而触发超时。