上线能跑,本地一跑就崩——一个 Rust 项目给我上的六堂课

LOL-AI-Intelligence 开发过程中遇到的六个问题:async/blocking 边界、debug assertion 陷阱、UI 架构重构、错误处理、国际化和 line ending。每个都是踩出来的。

· 4 min read

上线能跑,本地一跑就崩——一个 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 请求。

问题链条是这样的:

  1. Tauri 的命令分发在 async 上下文运行
  2. 我的自动接受监视器也在 async 上下文
  3. 它们调用了 reqwest::blocking::Client
  4. reqwest 在 debug 模式下有一个内部 assertion——它会创建一个临时 Tokio runtime 来检查自己是不是在 async 上下文里被调用了
  5. 在已有 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_placespawn_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. 每个页面的错误状态

在关键的页面/组件里加了明确的错误状态管理:championsErroradvisorDataError 等等。数据加载失败时显示错误提示 + 重试按钮。

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 在等着我了。