Runs in Production, Crashes Locally — Six Lessons a Rust Project Taught Me
Six problems encountered building LOL-AI-Intelligence: async/blocking boundaries, debug assertion traps, UI architecture refactors, error handling, i18n, and line endings. Each one cost me hours.
Runs in Production, Crashes Locally — Six Lessons a Rust Project Taught Me
I’m building a League of Legends desktop companion called LOL-AI-Intelligence. Tauri + Rust + React, an overlay that shows real-time match data with an AI advisor giving recommendations.
The day I got it mostly working, I ran npm run dev locally — and it crashed.
And it crashed in the weirdest way: the release build worked perfectly. Only local dev exploded.
This post records six pitfalls I stepped into on this project. Every single one cost me at least a few hours.
Lesson One: Don’t Just Drop Blocking Code Into Async
The symptom
After npm run dev, the app crashed immediately:
Cannot drop a runtime in a context where blocking is not allowed
The error message itself was odd — “can’t drop a runtime in a context where blocking isn’t allowed”? I wasn’t dropping any runtime.
The truly bizarre part: cargo build --release produced a binary that ran flawlessly. It worked in production. Only the local dev environment blew up.
Investigation
I traced up the call chain.
My app has three data sources: LCU (the League client’s local API), Tencent API, and CommunityDragon (community data mirror). All three adapters used reqwest::blocking for HTTP requests.
Meanwhile, I had an “auto-accept match” monitor running in an async context. That monitor called those blocking requests.
The chain of failure went like this:
- Tauri command dispatch runs in an async context
- My auto-accept monitor runs in an async context
- They called
reqwest::blocking::Client - reqwest, in debug mode, has an internal assertion — it creates a temporary Tokio runtime to check whether it’s being called from within an async context
- Creating/destroying a temporary runtime inside an existing async context → panic
And in release builds, debug assertions are stripped out entirely by the compiler. That check simply doesn’t exist, so the code works fine.
That’s why production was fine and local was broken. I honestly thought my environment was corrupt.
The fix
A three-pronged approach:
1. Shared blocking client
Stop creating a new client for every request. Use lazy_static for a global one:
lazy_static::lazy_static! {
static ref BLOCKING_CLIENT: reqwest::blocking::Client =
reqwest::blocking::Client::new();
}
This reduces runtime creation/destruction and also improves performance (connection reuse).
2. block_in_place routing
When calling blocking code from async, explicitly tell Tokio: “I know I’m doing a blocking operation, suspend this task and let others run”:
pub async fn fetch_from_lcu() -> Result<Data> {
tokio::task::block_in_place(|| {
BLOCKING_CLIENT.get(url).send()?.json()
})
}
3. Add regression tests
Wrote tests specifically for this scenario — ensuring blocking reqwest no longer panics in an async context. If anyone changes this later, tests will scream.
What I learned
Rust has a hard boundary between async and blocking. You can cross it, but you have to use the right tools (block_in_place, spawn_blocking). Try to shortcut it by calling blocking code directly, and reqwest in debug mode will teach you a lesson.
Also — when dev and release behave differently, 99% of the time it’s a debug assertions issue.
Lesson Two: reqwest’s Debug Assertion Is a Landmine
The symptom
The Lesson One fix handled most cases, but reqwest’s own debug check was still there. Internally, it probably looks something like:
// reqwest internals (pseudocode)
if cfg!(debug_assertions) {
let _ = tokio::runtime::Runtime::new()?;
}
Whenever you build in debug mode, reqwest does a runtime check before every blocking request. And that check itself — when you’re already inside an async context — is the landmine.
The fix
I turned off debug assertions for dev and test profiles directly in Cargo.toml:
[profile.dev]
debug-assertions = false
[profile.test]
debug-assertions = false
The trade-off:
- What I lost:
assert!()anddebug_assert!()no longer fire during development. In theory, some early bugs could slip through. - What I gained: The dev environment no longer gets nuked by an irrelevant, framework-level assertion. Dev behavior matches release.
This isn’t a perfect solution. But for this specific problem, it’s the right trade-off. I have other defenses — the type system, unit tests, integration tests. Not all protection has to come from assertions.
Lesson Three: UI Navigation That Ignored Real-World Usage
The symptom
My app has two usage modes:
- Desk Mode — sitting at a desk, big screen, taking your time
- Live Mode — in a match, glancing at the overlay for quick info
But my navigation structure treated both modes identically. The result in Live Mode was a long, cluttered navigation menu that was completely unusable mid-game.
The fix
1. Grouped sidebar
Grouped nav items by function:
- Primary: Home, Advisor, Chat
- Settings: Settings, Activity
2. Moved Activity out of main nav
Activity (event log) had its own top-level nav slot, but it’s not core flow. I moved it into the Settings page. Main nav is now clean.
3. Repositioned the Home page
The old Dashboard was reborn as a Home page, designed to be task-oriented: quick-setup checklist, current activity status, quick-action buttons. No more hollow dashboard.
4. Live Status Strip
Added a status strip component in Live Mode showing current match state, live ranking, and one-tap actions. No navigation required.
5. Wrote an ADR
I wrote an ADR documenting this decision — why mode-based navigation is needed, the differing requirements of each mode, and how the code distinguishes between them. So nobody later “fixes” this intentional design.
What I learned
The same interface has completely different requirements depending on context. Building a desktop app isn’t just about “does it have all the features?” — it’s about “what situation is the user in when they need this feature?”
Lesson Four: Don’t Swallow Errors
The symptom
Errors were silently swallowed all over the place:
- Can’t fetch season data → blank page
- Rank info fails to load → nothing appears
- Advisor data fetch fails → zero feedback
Users saw “the app is stuck.” What actually happened was an error got eaten by unwrap_or.
The fix
1. ErrorBoundary
Added React ErrorBoundary on the frontend — when rendering crashes, at least show a friendly error page instead of a white screen.
2. Per-page error states
Added explicit error state management in key pages/components: championsError, advisorDataError, etc. When data fails to load, show an error message + retry button.
3. thiserror on the Rust side
#[derive(thiserror::Error)]
pub enum AppError {
#[error("Failed to fetch season: {0}")]
SeasonFetch(String),
#[error("Invalid config: {0}")]
InvalidConfig(#[from] ConfigError),
}
No more unwrap() everywhere. Propagate errors up, preserving the source error information.
4. tracing logs
Use the tracing crate to record errors — no more silent failures. During debugging, you can track the full error chain.
What I learned
Users need feedback. A silently failing system is as bad as a crashing one — maybe worse, because you don’t know it’s broken.
Lesson Five: i18n Is a Grind
The symptom
The project started with all-English hardcoded strings. Then I needed Chinese support.
This isn’t just “add a translation file.” It touches:
- Dozens of pages
- Hundreds of UI strings
- Labels across multiple UI libraries
- Consistent translation of technical terms
The fix
1. Lightweight i18n framework
Chose a simple t() function — no over-engineering:
import { t } from '@/i18n';
<button>{t('advisor.title')}</button>
2. Translate by functional area, iteratively
Didn’t translate everything at once. Pushed it forward commit by commit, one functional area at a time:
- Advisor + Chat Presets + Ranked + Live status
- League client phase labels
- Match Recap window titles
- Rank, region, sort labels
- Chat Presets + admin permission notifications
Challenges
- Keeping translation files in sync — every new feature means updating both en.json and zh.json
- Technical term consistency — the same concept can’t be translated differently in two places
- It’s a grind — this work has zero technical difficulty, just sheer volume
What I learned
If you know from day one that you’ll need multiple languages, use i18n from day one. Even if you start with a single language file, it beats retroactively replacing hundreds of hardcoded strings from scratch.
Lesson Six: Don’t Ignore Line Endings
The symptom
Every commit, Git would warn:
warning: in the working copy of '.claude/settings.local.json',
LF will be replaced by CRLF the next time Git touches it
Root cause
I develop on Windows (CRLF), but project files should be consistently LF (Unix standard). Git’s auto-conversion config was inconsistent, leaving some files with mixed line endings.
The fix
1. Add .gitattributes
* text=auto
*.json text eol=lf
*.rs text eol=lf
*.tsx text eol=lf
*.ts text eol=lf
Force all code files to LF.
2. Clean up existing files
git add --renormalize .
git commit -m "fix: normalize line endings"
Why it matters
- Clean diffs — no line-ending noise polluting code reviews
- Cross-platform consistency — Windows, Mac, Linux all see the same thing
- Tool compatibility — many linters and formatters are sensitive to line endings
What I learned
Configure .gitattributes on day one of a project. It’s as fundamental as .gitignore, but not everyone thinks of it.
Timeline
| Problem | Status |
|---|---|
| Async/Blocking Panic | ✅ Resolved |
| Debug Assertions | ✅ Resolved |
| UI Nav Refactor | ✅ Complete |
| Error Handling | ✅ Improved |
| i18n | 🔄 In Progress |
| Line Endings | ⏳ Pending |
Summary
These six problems share a common thread: none of them are algorithmically or architecturally hard — they’re “boundary condition” hard.
- The boundary between async and blocking
- The boundary between debug and release
- The boundary between two usage modes
- The boundary between error and non-error
- The boundary between single-language and multi-language
- The boundary between Windows and Unix
Writing application code isn’t like writing algorithms. Algorithms are hard in their logic. Applications are hard in the invisible boundaries.
As I finish writing this, there are new bugs waiting for me.