BUG-2026-07-27T120657-spawned-tasks-english.md 5.7 KB


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

Fixed in: 4ece367 (GREEN) + 16f4239 (RED test) Branch: fix-i18n-spawn-locale

Fix: Added src/locale.rs with a process-global OnceLock<Option<String>> 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