惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
D
DataBreaches.Net
F
Fortinet All Blogs
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
Apple Machine Learning Research
Apple Machine Learning Research
H
Help Net Security
M
MIT News - Artificial intelligence
美团技术团队
人人都是产品经理
人人都是产品经理
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The Cloudflare Blog
有赞技术团队
有赞技术团队
L
LangChain Blog
博客园 - Franky
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 【当耐特】
S
SegmentFault 最新的问题
V
Visual Studio Blog
Blog — PlanetScale
Blog — PlanetScale
Hugging Face - Blog
Hugging Face - Blog
B
Blog
I
InfoQ

阿尔的代码屋 | 全栈技术笔记

VoxCPM2 多语言语音合成与声音克隆本地部署 | 阿尔的代码屋 MiniMax-H3 NF4 视音频联合生成模型本地部署与调试 | 阿尔的代码屋 ShareX 联动 Antigravity 自动化记录与跨环境管道构建 | 阿尔的代码屋 国内搜索引擎收录实战:百度与头条搜索接入、无备案验证绕行与自动化推送 - 独立博客 SEO 与 GEO 03 | 阿尔的代码屋 技术博客工程化治理与 WebP 自动化质量门禁 - Hexo 博客建站与优化实战 05 | 阿尔的代码屋 Hexo NexT 静态资源本地自托管、KaTeX 公式渲染与移动端适配 - Hexo 博客建站与优化实战 04 | 排坑笔记 | 阿尔的代码屋 把 VS Code 打造成 Git 终极编辑器、Diff 与 Merge 利器 - Git 避坑与工作流 04 | 排坑笔记 | 阿尔的代码屋 告别架构图看不清:Hexo NexT 8.x 本地化集成 Fancybox 5 高清灯箱实战 | 开发日志 | 阿尔的代码屋 Hexo new 日期无法自动生成且出现 object Object 报错根治 | 排坑笔记 | 阿尔的代码屋 IndexNow 毫秒级主动推送与全站语义拓扑网格 - 独立博客 SEO 与 GEO 02 | 架构实战 | 阿尔的代码屋 从拦截 AI 爬虫到成为大模型答案源 - 独立博客 SEO 与 GEO 01 | 架构实战 | 阿尔的代码屋 Chrome 扩展开发与上架全流程实战避坑 - 开发技巧 | 阿尔的代码屋 VS Code 终端日志被截断?两项配置彻底解锁完整输出与会话持久化 | 排坑笔记 | 阿尔的代码屋 在 WSL2 环境下部署 Pixal3D 的从零实战与全流程排雷日志 | 阿尔的代码屋 在 Android Termux 环境下安装 Hermes Agent 的踩坑与完美解决实践 开发日志| 阿尔的代码屋 VS Code 连接 WSL 精确每 10 分钟掉线 排坑笔记 | 阿尔的代码屋 Android 模拟器代理联网与 No Internet WiFi 锁死排坑笔记 | 阿尔的代码屋 [object Object] Flutter 本地通知实现排坑实录 - Android inexactAllowWhileIdle 调度策略与测试方案全解析 | 阿尔的代码屋 typing_extensions 有用(四):使用 TypeIs 替代危险的 cast,做最严谨的类型收窄 | 阿尔的代码屋 typing_extensions 有用(三):使用 Unpack 结合 TypedDict 给 **kwargs 装上透视眼 | 阿尔的代码屋 typing_extensions 有用(二):使用 @override 打造重构代码时的“防呆神器” | 阿尔的代码屋 Flutter 并发测试踩坑实录 - IsarCore 动态库下载冲突与 Widget 测试 HTTP 拦截全链路解决 | 阿尔的代码屋 typing_extensions 有用(一):使用 Self 终结继承时的类型推断灾难 | 阿尔的代码屋 基于 Cloudflare Pages 的纯前端 WebAssembly 应用自动化部署实践 | 开发日志 | 阿尔的代码屋 基于 VS Code 远程开发的 GPU Docker 容器自动清理方案实践 开发日志| 阿尔的代码屋 Patrol iOS 集成测试排坑实录 - xcodebuild exit code 70 全链路解决 | 阿尔的代码屋 Flutter E2E 测试从 integration_test 迁移到 Patrol - 实践笔记 | 阿尔的代码屋 Linux/macOS 下 micromamba 报错 Shard Index not available 与极度卡顿 排坑笔记 | 阿尔的代码屋 critical libmamba Shell not initialized micromamba报错 subprocess 无法修改父 Shell - 排坑笔记 | 阿尔的代码屋
GoRouter 结合 Isar 运行 Widget 测试并发/粘性线程死锁卡死...
Algieba · 2026-06-20 · via 阿尔的代码屋 | 全栈技术笔记

核心摘要 (TL;DR)

  • 问题现象:本地单线程运行通常可通过,但 CI 高并发(concurrency)下或者特定测试顺序中,router_test.dart 运行到 /add 或其他路由测试时会无限期卡死/挂起,直至被 CI 取消(Error: The operation was canceled)。
  • 根本原因
    1. 异步数据源流监听:路由导航至目标页面(如 DashboardScreenHistoryScreen)后,这些 Widget 在 initState 中开启了 Isar 数据库的异步查询或流监听(watchObject/watch)。
    2. runAsync 逃逸 zone 控制:为防止 FakeAsync 区域内原生端口死锁,测试使用了 tester.runAsync。这导致 Isar 的异步任务在 Dart 真实事件循环上执行。
    3. 数据库提前关闭导致 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.

1. 现象描述与现场还原

在 CI (GitHub Actions) 上进行自动化测试流程时,经常发现在执行到 [router_test.dart](file:///Users/mac/flutter/UseUp/test/config/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 {

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);
});

然而,上一个测试页面在真实事件循环中注册的后台异步流(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](file:///Users/mac/flutter/UseUp/test/config/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 导致页面渲染延迟进而触发超时。