上线能跑,本地一跑就崩——一个 Rust 项目给我上的六堂课
LOL-AI-Intelligence 开发过程中遇到的六个问题:async/blocking 边界、debug assertion 陷阱、UI 架构重构、错误处理、国际化和 line ending。每个都是踩出来的。
上线能跑,本地一跑就崩——一个 Rust 项目给我上的六堂课
我在做一个英雄联盟桌面助手,叫 LOL-AI-Intelligence。Tauri + Rust + React,悬浮窗显示实时比赛数据,AI 顾问给建议。
写到差不多能用的那天,我在本地跑了 npm run dev——崩了。
而且崩得很诡异:打 release 包完全正常,本地 dev 就炸。
这篇文章记录了这个项目里我踩过的六个坑。每一个都花了我至少几个小时。
第一课:async 里不能随便放 blocking 代码
现象
npm run dev 启动后,应用直接 crash:
Cannot drop a runtime in a context where blocking is not allowed
这个错误信息本身就很奇怪——“在不能阻塞的上下文里销毁 runtime”?我没销毁什么 runtime 啊。
最诡异的是:cargo build --release 打出来的包完全没问题。 上线跑得好好的,就是本地开发环境炸。
排查
我顺着调用链往上翻。
我的应用有三条数据源:LCU(英雄联盟客户端本地 API)、Tencent API、CommunityDragon(社区数据镜像)。三个适配器都在用 reqwest::blocking 发 HTTP 请求。
同时,我有一个”自动接受对局”的监视器,它跑在 async 上下文里。这个监视器会调用那些 blocking 请求。
问题链条是这样的:
- Tauri 的命令分发在 async 上下文运行
- 我的自动接受监视器也在 async 上下文
- 它们调用了
reqwest::blocking::Client - reqwest 在 debug 模式下有一个内部 assertion——它会创建一个临时 Tokio runtime 来检查自己是不是在 async 上下文里被调用了
- 在已有 async 上下文里创建/销毁一个临时 runtime → panic
而 release 编译时,debug assertions 被编译器直接干掉了。所以这个检查根本不存在,代码正常工作。
这就是为什么上线没问题、本地炸。我一度怀疑是自己环境坏了。
解决方案
我用了三管齐下:
1. 共享 blocking client
不再每次请求都 new 一个新 client,而是用 lazy_static 搞一个全局的:
lazy_static::lazy_static! {
static ref BLOCKING_CLIENT: reqwest::blocking::Client =
reqwest::blocking::Client::new();
}
这样减少了 runtime 创建/销毁的次数,还顺便提升了性能(连接复用)。
2. block_in_place 路由
在 async 上下文里调用 blocking 代码时,显式告诉 Tokio:“我知道我在做阻塞操作,把这个 task 挂起来,让别人先跑”:
pub async fn fetch_from_lcu() -> Result<Data> {
tokio::task::block_in_place(|| {
BLOCKING_CLIENT.get(url).send()?.json()
})
}
3. 加回归测试
写了专门测这个场景的测试——确保 blocking reqwest 在 async 上下文里不再 panic。以后谁改了这里,测试会直接炸。
学到什么
Rust 的 async 和 blocking 之间有一道很硬的边界。跨过去可以,但得用正确的方式(block_in_place、spawn_blocking)。想省事直接调 blocking 代码,debug 模式下 reqwest 会教你做人。
另外——dev 和 release 行为不一致的时候,99% 是 debug assertions 的问题。
第二课:reqwest 的 debug assertion 是个地雷
现象
第一课的问题修了大部分,但 reqwest 自己的 debug 检查还在。它内部大概长这样:
// reqwest 内部(伪代码)
if cfg!(debug_assertions) {
let _ = tokio::runtime::Runtime::new()?;
}
只要你在 debug 模式下构建,reqwest 就会在每个 blocking 请求前做一个 runtime 检查。而这个检查本身——在你已经跑在 async 上下文里的时候——就是那颗雷。
解决方案
我在 Cargo.toml 里直接把 dev 和 test profile 的 debug assertions 关了:
[profile.dev]
debug-assertions = false
[profile.test]
debug-assertions = false
权衡:
- 失去的:
assert!()和debug_assert!()在开发时不再触发。理论上会错过一些早期 bug。 - 得到的: 开发环境不再被一个无关的、框架级别的 assertion 炸掉。dev 行为和 release 一致。
这不是完美的方案。但对这个具体问题来说,是正确的取舍。我还有其他防御手段——类型系统、单元测试、集成测试。不是所有保护都得靠 assertion。
第三课:UI 导航没考虑使用场景
现象
我的应用有两种使用模式:
- Desk Mode——坐在电脑前,有大屏幕,慢慢操作
- Live Mode——在打比赛,通过悬浮窗快速瞄一眼
但我的导航结构对这两种模式一视同仁。结果就是在 Live Mode 里,导航菜单又长又乱,打游戏的时候根本没法用。
解决方案
1. 分组侧边栏
把导航项按功能分组:
- 主要功能: Home、Advisor、Chat
- 设置: Settings、Activity
2. Activity 迁出主导航
Activity(活动日志)之前占了一个主导航位,但它不是核心流程。我把它移到设置页面里,主导航干净了。
3. Home 页面重新定位
原来的 Dashboard 改成 Home 页面,做成任务导向的设计:快速设置检查清单、当前活动状态、快速操作按钮。不再是空洞的仪表板。
4. Live Status Strip
在 Live Mode 里加了一个状态条组件,显示当前比赛状态、实时排名、一键操作。不需要翻导航。
5. 写 ADR
我给这个决策写了 ADR——记录为什么需要基于模式的导航、两种模式的不同需求、代码里怎么实现区分。防止以后有人”修复”这个有意的设计。
学到什么
同一个界面在不同的使用场景下,需求完全不同。做桌面应用不能只考虑”功能全不全”,得考虑”用户在什么情况下用这个功能”。
第四课:错误不能吞
现象
很多地方,错误被默默吞了:
- 赛季数据拉不到 → 页面空白
- 排名信息加载失败 → 什么都没有
- Advisor 数据获取失败 → 完全没反馈
用户看到的是”应用卡住了”,而实际上是有个错误被 unwrap_or 吃掉了。
解决方案
1. ErrorBoundary
前端加了 React ErrorBoundary,渲染崩溃时至少显示一个友好的错误页,而不是白屏。
2. 每个页面的错误状态
在关键的页面/组件里加了明确的错误状态管理:championsError、advisorDataError 等等。数据加载失败时显示错误提示 + 重试按钮。
3. Rust 侧用 thiserror
#[derive(thiserror::Error)]
pub enum AppError {
#[error("Failed to fetch season: {0}")]
SeasonFetch(String),
#[error("Invalid config: {0}")]
InvalidConfig(#[from] ConfigError),
}
不再到处 unwrap(),把错误传上去,保留源错误信息。
4. tracing 日志
用 tracing crate 记录错误,不再默默失败。调试时能追踪到完整的错误链。
学到什么
用户需要反馈。一个静默失败的系统和崩溃一样糟糕——甚至更糟,因为你不知道它在坏。
第五课:国际化是个体力活
现象
项目一开始全用英文硬编码。后来需要支持中文。
这不是”加个翻译文件”的问题。涉及:
- 几十个页面
- 几百个 UI 文本
- 多个 UI 库里的标签
- 技术术语的翻译一致性
解决方案
1. 轻量级 i18n 框架
选择简单的 t() 函数,不搞过度工程化:
import { t } from '@/i18n';
<button>{t('advisor.title')}</button>
2. 按功能区域逐步翻译
没有一次性全部翻译,而是迭代推进——每个 commit 翻译一个功能区域:
- Advisor + Chat Presets + Ranked + Live status
- League client 阶段标签
- Match Recap 窗口标题
- 排名、区域、排序标签
- Chat Presets + admin 权限通知
挑战
- 翻译文件的同步——加新功能时得同时更新 en.json 和 zh.json
- 技术术语的一致性——同一个概念不能在两个地方翻译成不同的词
- 体力活——这活没有什么技术难度,就是量大
学到什么
如果从一开始就知道要多语言,第一天就用 i18n。哪怕一开始只有一个语言文件,也比事后从零替换几百个硬编码字符串强。
第六课:Line Ending 不能忽视
现象
每次提交,Git 都在警告:
warning: in the working copy of '.claude/settings.local.json',
LF will be replaced by CRLF the next time Git touches it
原因
我在 Windows 上开发(CRLF),但项目文件应该统一用 LF(Unix 标准)。Git 的自动转换配置不一致,导致某些文件混杂了两种 line ending。
解决方案
1. 加 .gitattributes
* text=auto
*.json text eol=lf
*.rs text eol=lf
*.tsx text eol=lf
*.ts text eol=lf
强制所有代码文件用 LF。
2. 清理已有文件
git add --renormalize .
git commit -m "fix: normalize line endings"
为什么重要
- 干净的 diff——不会因为 line ending 变化污染代码审查
- 跨平台一致——Windows、Mac、Linux 看到的东西一样
- 工具兼容——很多 linter 和 formatter 对 line ending 敏感
学到什么
项目第一天就配好 .gitattributes。这跟 .gitignore 一样基础,但不是每个人都会想到。
时间线
| 问题 | 状态 |
|---|---|
| Async/Blocking Panic | ✅ 已解决 |
| Debug Assertions | ✅ 已解决 |
| UI 导航重构 | ✅ 已完成 |
| 错误处理 | ✅ 已完善 |
| 国际化 | 🔄 进行中 |
| Line Ending | ⏳ 待处理 |
总结
这六个问题有一个共同点:都不是算法难、架构难,而是”边界条件”难。
- async 和 blocking 的边界
- debug 和 release 的边界
- 两种使用模式的边界
- 错误和非错误的边界
- 单语言和多语言的边界
- Windows 和 Unix 的边界
写应用代码跟写算法不一样。算法的难点在逻辑,应用的难点在这些看不见的边界上。
写完这篇的时候,又有新 bug 在等着我了。