瀏覽代碼

fix(i18n): set thread-local locale in spawned tasks

Alexander Korolev 2 月之前
父節點
當前提交
82bbb916d2

+ 15 - 1
specs/bugs/BUG-2026-07-27T120657-spawned-tasks-english.md

@@ -115,4 +115,18 @@ src/docker/health.rs:31, :77, :224
 
 ## Resolution
 
-<!-- filled in by validate-fix -->
+**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

+ 4 - 11
specs/state.yaml

@@ -10,8 +10,8 @@ active_epic_id: null
 active_story_id: null
 
 bug_cycle:
-  current_step: 3              # steps 1-2 done (investigate + diagnose); next: develop-tdd
-  completed_steps: [1, 2]     # investigate-bug + diagnose-root (4-phase RCA verified)
+  current_step: 5              # steps 1-4 done; next: release-branch
+  completed_steps: [1, 2, 3, 4] # investigate, diagnose, develop-tdd, validate-fix
   bug_id: BUG-2026-07-27T120657
 
 session:
@@ -26,12 +26,5 @@ git:
   clean: true                    # spec artifacts committed; preflight green
 
 handoff:
-  next_skill: develop-tdd       # bug fix branch ready; develop-tdd against BUG-*.md verify steps
-  note: "Worktree fix-i18n-spawn-locale created. Preflight green. develop-tdd next."
-
-metrics:
-  story_start: null              # ISO 8601 at story start — informational progress marker only
-
-# bigpowers helper scripts (callable via absolute path, not project-local):
-#   bash ~/.pi/agent/npm/node_modules/bigpowers/scripts/validate-specs-yaml.sh "$(pwd)/specs"
-#   bash ~/.pi/agent/npm/node_modules/bigpowers/scripts/validate-agentic-ste.sh --strict CLAUDE.md CONVENTIONS.md
+  next_skill: release-branch   # validate-fix done; land the fix
+  note: "Fix validated. release-branch next."

+ 1 - 1
src/after_stop.rs

@@ -39,7 +39,7 @@ pub async fn run(config: &Config, cluster: &Cluster, recipe: &Recipe, dry_run: b
         let node = node.clone();
         let cmds = cmds.clone();
         let dry = dry_run;
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             (
                 node.name.clone(),
                 run_for_node(&config, &node, &cmds, dry).await,

+ 1 - 1
src/before_serve.rs

@@ -48,7 +48,7 @@ pub async fn run(
         let node = node.clone();
         let cmds = cmds.clone();
         let dry = dry_run;
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             (
                 node.name.clone(),
                 run_for_node(&config, &node, &cmds, dry).await,

+ 1 - 1
src/cluster_state.rs

@@ -46,7 +46,7 @@ impl ClusterState {
                 let cmd =
                     "docker ps -q --filter label=sm121.cluster --format '{{.Names}}' | head -1"
                         .to_string();
-                handles.push(tokio::spawn(async move {
+                handles.push(crate::locale::spawn_with_locale(async move {
                     (name, run_ssh(&config, &node, cmd, false).await)
                 }));
             }

+ 1 - 1
src/docker/deploy.rs

@@ -44,7 +44,7 @@ pub async fn discover_recipe_deployments(
             let config = config.clone();
             let node = node.clone();
             let name = node.name.clone();
-            handles.push(tokio::spawn(async move {
+            handles.push(crate::locale::spawn_with_locale(async move {
                 let out = run_ssh(&config, &node, cmd, false)
                     .await
                     .unwrap_or_default();

+ 3 - 3
src/docker/health.rs

@@ -28,7 +28,7 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
     let ha_handle = if let Some(ref ha_cfg) = config.home_assistant {
         let ha = HaClient::new(ha_cfg).ok();
         let entities = cluster.extra_entities.clone();
-        Some(tokio::spawn(async move {
+        Some(crate::locale::spawn_with_locale(async move {
             let client = ha.as_ref()?;
             for e in &entities {
                 match client.get_state(e).await {
@@ -74,7 +74,7 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
         } else {
             docker_cmd
         };
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             let result = run_ssh(&config, &node, cmd, dry_run).await;
             (name, ib_ip, result)
         }));
@@ -221,7 +221,7 @@ pub async fn health_summary(config: &Config) -> Result<()> {
             let cmd = "docker ps --filter label=sm121.recipe \
                        --format '{{.Label \"sm121.recipe\"}}|{{.Label \"sm121.cluster\"}}'"
                 .to_string();
-            handles.push(tokio::spawn(async move {
+            handles.push(crate::locale::spawn_with_locale(async move {
                 let out = run_ssh(&config, &node, cmd, false)
                     .await
                     .unwrap_or_default();

+ 1 - 1
src/docker/logs.rs

@@ -74,7 +74,7 @@ pub async fn show_logs(
             let node = (*node).clone();
             let dry = dry_run;
             let name = node.name.clone();
-            handles.push(tokio::spawn(async move {
+            handles.push(crate::locale::spawn_with_locale(async move {
                 (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
             }));
         }

+ 1 - 1
src/docker/mod.rs

@@ -364,7 +364,7 @@ async fn start_cluster(
         };
         let hf_cache = config.resolved_hf_cache_dir();
         let path_refs = resolved.path_refs.clone();
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             if !dry {
                 let _ = run_ssh(&config, &node, cleanup, dry).await;
             }

+ 1 - 1
src/doctor.rs

@@ -71,7 +71,7 @@ pub async fn doctor(
             .distributed_backend
             .map_or("none".to_string(), |b| b.to_string());
         let image = recipe.docker_image.clone();
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             check_node(&config, &node, &engine, &backend, &image).await
         }));
     }

+ 1 - 1
src/exec.rs

@@ -74,7 +74,7 @@ pub async fn run(
         let config = config.clone();
         let command = command.to_string();
         let dry = dry_run;
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             let name = node.name.clone();
             let result = crate::ssh::run_ssh(&config, &node, command.clone(), dry).await;
             (name, command, result)

+ 1 - 1
src/health.rs

@@ -61,7 +61,7 @@ pub async fn check_cluster_health(
         let recipe = recipe.clone();
         let dry = dry_run;
         let manager = manager.clone();
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             let health = manager
                 .deep_check_session(&config, &node, &cluster, &sess, &recipe, dry)
                 .await;

+ 67 - 0
src/locale.rs

@@ -0,0 +1,67 @@
+//! Process-global locale store + locale-aware task spawner.
+//!
+//! `fluent_i18n::set_locale` writes to a `thread_local!` `RefCell`, so
+//! `tokio::spawn` worker threads do NOT inherit the locale set on the main
+//! thread — every `t!` call inside a spawned task falls back to English.
+//! This module closes that gap: [`set_global_locale`] captures the locale
+//! once at startup (process-global `OnceLock`), and [`spawn_with_locale`]
+//! re-applies it at the start of each spawned task so `t!` renders in the
+//! user's locale, not the fallback.
+
+use std::future::Future;
+use std::sync::OnceLock;
+use tokio::task::JoinHandle;
+
+/// Process-global locale string (e.g. `"ru-RU"`), set once on the main
+/// thread at startup. Read by [`spawn_with_locale`] before each task.
+static GLOBAL_LOCALE: OnceLock<Option<String>> = OnceLock::new();
+
+/// Capture the locale once at startup. Called from `main` after
+/// `set_locale`. Subsequent calls are no-ops (first wins).
+pub fn set_global_locale(locale: Option<String>) {
+    let _ = GLOBAL_LOCALE.set(locale);
+}
+
+/// Like `tokio::spawn`, but re-applies the process-global locale to the
+/// worker thread's thread-local before polling the future, so `t!` calls
+/// inside the task render in the user's locale.
+pub fn spawn_with_locale<F, T>(future: F) -> JoinHandle<T>
+where
+    F: Future<Output = T> + Send + 'static,
+    T: Send + 'static,
+{
+    let locale = GLOBAL_LOCALE.get().and_then(|opt| opt.clone());
+    tokio::spawn(async move {
+        if let Some(ref loc) = locale {
+            let _ = fluent_i18n::set_locale(Some(loc.as_str()));
+        }
+        future.await
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn spawn_with_locale_inherits_locale() {
+        set_global_locale(Some("ru-RU".to_string()));
+        let result = spawn_with_locale(async {
+            fluent_i18n::t!(
+                "composed-dir-built",
+                {
+                    "host" => "/tmp/x".to_string(),
+                    "node" => "n".to_string(),
+                    "sources" => 3u32,
+                    "files" => 1u32,
+                }
+            )
+        })
+        .await
+        .unwrap();
+        assert!(
+            result.contains("источник"),
+            "expected Russian output (источник), got: {result}"
+        );
+    }
+}

+ 4 - 0
src/main.rs

@@ -18,6 +18,7 @@ mod exec;
 mod health;
 mod home_assistant;
 mod host_keys;
+mod locale;
 mod orchestrate;
 mod power;
 mod pre_serve;
@@ -69,6 +70,9 @@ async fn main() -> Result<()> {
         .ok()
         .map(|l| l.split('.').next().unwrap_or(&l).replace('_', "-"));
     let _ = fluent_i18n::set_locale(locale.as_deref());
+    // Capture for spawned tasks — set_locale is thread-local, so tokio
+    // workers miss it. spawn_with_locale re-applies from this global.
+    locale::set_global_locale(locale.clone());
     tracing_subscriber::fmt()
         .with_env_filter(
             tracing_subscriber::EnvFilter::try_from_default_env()

+ 2 - 2
src/power.rs

@@ -63,7 +63,7 @@ pub async fn power_off(
     let mut handles = Vec::with_capacity(targets.len());
     for node in targets.iter().cloned() {
         let config = config.clone();
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             let cmd = "sudo shutdown now".to_string();
             let r = crate::ssh::run_ssh(&config, &node, cmd.clone(), false).await;
             (node, cmd, r)
@@ -298,7 +298,7 @@ pub async fn power_switch(
     let mut handles = Vec::with_capacity(disable_nodes.len());
     for node in disable_nodes.iter().cloned() {
         let c = config.clone();
-        handles.push(tokio::spawn(async move {
+        handles.push(crate::locale::spawn_with_locale(async move {
             (
                 node.name.clone(),
                 crate::ssh::run_ssh(&c, &node, "sudo shutdown now".into(), false).await,

+ 1 - 1
src/tmux/ops.rs

@@ -200,7 +200,7 @@ pub async fn tmux_logs(
             let recipe = recipe.clone();
             let dry = dry_run;
 
-            handles.push(tokio::spawn(async move {
+            handles.push(crate::locale::spawn_with_locale(async move {
                 let sess = manager.session_name(&recipe, node.rank);
                 let output = manager
                     .stream_pane(&config, &node, &sess, 0, false, dry)

+ 1 - 1
src/tmux/serve.rs

@@ -105,7 +105,7 @@ pub async fn tmux_serve(
             let manager = manager.clone();
             let dry = dry_run;
 
-            handles.push(tokio::spawn(async move {
+            handles.push(crate::locale::spawn_with_locale(async move {
                 let cmds = manager.build_session_commands(&node, &recipe, &nccl, &cluster);
                 manager
                     .create_session(&config, &node, &cmds, dry)