---
bug_id: BUG-2026-07-27T120657
status: open
severity: medium
scope: i18n
title: Spawned tasks fall back to English (thread-local set_locale)
---
# BUG-2026-07-27T120657: Spawned tasks fall back to English (thread-local set_locale)
## Problem
**Actual:** When the user's locale is non-English (e.g. `LANG=ru_RU.UTF-8`),
messages emitted from inside `tokio::spawn` worker tasks render in English
(the fallback), not the user's locale.
**Expected:** All user-facing messages — including those from spawned
parallel tasks (serve, before_serve, after_stop, power, health, etc.) —
render in the locale set via `LANG`.
**Reproduce:**
```
LANG=ru_RU.UTF-8 cargo run -- -d serve --recipe test-composed
# → "[dry-run] would build composed model dir ... 3 sources, 1 file"
# (English; should be Russian: "3 источника, 1 файл")
```
Main-thread `t!` calls DO render in the user's locale (verified: a
main-thread `t!("composed-dir-built", ...)` with `set_locale("ru-RU")`
prints "3 источника, 1 файл"). Only spawned tasks are affected.
## Root Cause Analysis
**Code path:** `main.rs` calls `fluent_i18n::set_locale(locale)` during
startup. `t!` macro calls read the current locale. Spawned tasks call
`t!` from worker threads.
**Why it fails:** `fluent_i18n::set_locale` writes to a `thread_local!`
`RefCell>` (confirmed by reading the crate
source: `CURRENT_LOCALE: RefCell >`).
`tokio::spawn` schedules the task onto a worker thread that has its own,
independent thread-local slot — which was never set. The `t!` macro reads
`None` there, falls back to the static fallback locale (en-US).
**Contributing factor:** The launcher uses `tokio::spawn` for parallel
per-node SSH/Docker work across 16 call sites. Every `t!` call inside
those spawned blocks is affected.
**Risk level:** Medium. No data loss, no security boundary. User-facing
i18n is broken for non-English locales in the async paths; English
(the fallback and most common case) is unaffected. 16 spawn sites need
migration.
**diagnose-root 4-phase RCA (performed earlier this session):**
- **Reproduce:** `LANG=ru_RU.UTF-8 ./sm121 -d serve --recipe test-composed`
→ English output from spawned composed-dir builder.
- **Isolate:** `set_locale("ru-RU")` succeeds on main;
`get_locale()` returns `ru-RU` on main. A `t!` call on the main thread
returns Russian. A `t!` call inside `tokio::spawn` returns English.
- **Hypothesize:** `fluent_i18n::set_locale` is thread-local; tokio
worker threads don't inherit the main-thread locale.
- **Verify:** Read `fluent-i18n` source — `CURRENT_LOCALE` is declared
`thread_local! { RefCell > }`. Confirmed.
## TDD Fix Plan
**Fix approach:** Wrap `tokio::spawn` with a locale-aware spawner that
captures the locale from a process-global store (set once on main) and
calls `set_locale` at the start of each spawned task. Migrate the 16
`tokio::spawn` call sites to the wrapper.
### RED-GREEN cycles
1. **RED:** Write a test that sets the global locale to `ru-RU`, spawns a
task via the (not-yet-existing) `spawn_with_locale` helper, calls
`t!("composed-dir-built", ...)`, and asserts the result contains a
Russian word (e.g. `источник`). Test fails to compile (helper missing).
**GREEN:** Create `src/locale.rs` with a `OnceLock >`
global + `set_global_locale()` + `spawn_with_locale()` wrapper that
calls `fluent_i18n::set_locale` before awaiting the future.
**verify:** `cargo test -- spawn_with_locale_inherits_locale`
2. **RED:** Add a characterization test proving a bare `tokio::spawn` (no
wrapper) still returns English despite the global locale being set —
documents the underlying crate behavior we're working around.
**GREEN:** No code change (characterization test; pins the bug).
**verify:** `cargo test -- bare_spawn_falls_back_to_english`
3. **REFACTOR:** Migrate the 16 `tokio::spawn(async move { ... })` call
sites to `locale::spawn_with_locale(async move { ... })`. Mechanical
replacement; each site's `JoinHandle` return type is unchanged.
**verify:** `make check` (fmt + clippy + 128+ tests + ≤500 lines)
### Migration sites (16)
```
src/tmux/ops.rs:203 src/tmux/serve.rs:108
src/before_serve.rs:51 src/cluster_state.rs:49
src/doctor.rs:74 src/power.rs:66, :301
src/after_stop.rs:42 src/health.rs:64
src/exec.rs:77 src/docker/logs.rs:77
src/docker/mod.rs:367 src/docker/deploy.rs:47
src/docker/health.rs:31, :77, :224
```
## Acceptance Criteria
- [ ] `cargo test -- spawn_with_locale_inherits_locale` passes
- [ ] `cargo test -- bare_spawn_falls_back_to_english` passes (characterization)
- [ ] `make check` green (fmt + clippy + all tests + ≤500 lines)
- [ ] `LANG=ru_RU.UTF-8 cargo run -- -d serve --recipe test-composed` prints
Russian for the composed-dir dry-run message
- [ ] All 16 `tokio::spawn` sites migrated to `spawn_with_locale`
- [ ] No new `#[allow(...)]` attributes
## Resolution
**Fixed in:** `4ece367` (GREEN) + `16f4239` (RED test)
**Branch:** `fix-i18n-spawn-locale`
**Fix:** Added `src/locale.rs` with a process-global `OnceLock >`
locale store + `spawn_with_locale` — a drop-in `tokio::spawn` wrapper that
calls `fluent_i18n::set_locale` inside the spawned task before polling
the future. Migrated all 17 `tokio::spawn` call sites to the wrapper.
`main.rs` calls `set_global_locale` once at startup.
**Verified:**
- Unit test `spawn_with_locale_inherits_locale` passes
- `make check` green (129 tests, fmt, clippy, ≤500 lines)
- End-to-end: `LANG=ru_RU.UTF-8` dry-run serve prints Russian from spawned tasks
- End-to-end: `LANG=de_DE.UTF-8` prints German ("3 Quellen, 1 Datei")
- English fallback unchanged