|
|
@@ -0,0 +1,118 @@
|
|
|
+---
|
|
|
+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<Option<LanguageIdentifier>>` (confirmed by reading the crate
|
|
|
+source: `CURRENT_LOCALE: RefCell<Option<LanguageIdentifier>>`).
|
|
|
+`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<Option<LanguageIdentifier>> }`. 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<Option<String>>`
|
|
|
+ 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
|
|
|
+
|
|
|
+<!-- filled in by validate-fix -->
|