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.

· 9 min read

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:

  1. Tauri command dispatch runs in an async context
  2. My auto-accept monitor runs in an async context
  3. They called reqwest::blocking::Client
  4. 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
  5. 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!() and debug_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

ProblemStatus
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.