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