From edf245e54a34ad9fa1195d5048691ac317f64f72 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 10:05:40 +0200 Subject: [PATCH 1/4] Recover the Translation Bridge work stranded on claude/bold-banach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19 landed only the first half of that branch: the shared-memory IPC itself. Five later commits never merged, and by the time they were reviewed their other consumer — Injekt — had been archived (#83). This port keeps the parts that survive that removal; nothing here references Injekt. Dictionary engine: - single hash-indexed storage instead of two parallel maps, so a duplicate key updates in place and the entry count stops drifting - 500k-entry cap to bound memory on oversized imports - CSV parser that honours quoted fields and "" escapes - batch lookup, mtime-based hot reload, save/load of a dictionary dir Shared memory: - Response Data becomes a real circular buffer with a C#-advanced tail; a full buffer now marks the slot Error instead of overwriting replies the plugin has not read - Shmem moves behind an Arc co-owned by the server thread, replacing the raw-pointer-as-usize handoff and the unsound Send/Sync on TranslationBridge - header counters are written once per batch from the Rust stats, which removes the read-modify-write race against the plugin Cache misses now feed an mpsc queue drained by translation_bridge_drain_misses for AI fallback. Its non-Windows stub already shipped with e3262e58; this adds the Windows implementation and the missing main.rs registration. The Tauri commands reach the dictionary and the miss queue directly rather than through Mutex, dropping a lock level. Dictionary APIs whose only caller was Injekt are kept behind #[allow(dead_code)] for gs-hook to pick up. Verified: cargo check, cargo test translation_bridge (22 passed, two of them new), clippy, tsc --noEmit, eslint, i18n:check, tauri:check-cmds, dead:check. Co-Authored-By: Claude Opus 5 --- lib/translation-bridge.ts | 66 +++- src-tauri/src/commands/translation_bridge.rs | 76 ++-- src-tauri/src/main.rs | 1 + .../translation_bridge/dictionary_engine.rs | 342 +++++++++++++----- src-tauri/src/translation_bridge/protocol.rs | 20 +- .../translation_bridge/shared_memory_ipc.rs | 300 +++++++++++---- 6 files changed, 629 insertions(+), 176 deletions(-) diff --git a/lib/translation-bridge.ts b/lib/translation-bridge.ts index 140059ac..0b6c22ab 100644 --- a/lib/translation-bridge.ts +++ b/lib/translation-bridge.ts @@ -44,6 +44,40 @@ export interface TranslationPair { */ export class TranslationBridgeClient { private isConnected: boolean = false; + private maxRetries: number = 3; + private retryDelayMs: number = 500; + + /** + * Retry wrapper: retries a Tauri invoke call only on transient failures. + * Non-transient errors (validation, not found, etc.) are thrown immediately. + */ + private async withRetry(fn: () => Promise, retries = this.maxRetries): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + // Only retry on transient errors (timeouts, IPC failures, network issues). + // Check structured error properties first, fall back to string matching. + const err = error as Record; + const isTransient = + err?.code === 'ETIMEDOUT' || err?.code === 'ECONNRESET' + || err?.code === 'ECONNREFUSED' || err?.code === 'ERR_IPC_CHANNEL_CLOSED' + || (() => { + const msg = String(error).toLowerCase(); + return msg.includes('timeout') || msg.includes('ipc') + || msg.includes('connection') || msg.includes('unavailable') + || msg.includes('channel closed'); + })(); + if (!isTransient || attempt >= retries) { + throw error; + } + await new Promise(r => setTimeout(r, this.retryDelayMs * (attempt + 1))); + } + } + throw lastError; + } /** * Start the Translation Bridge server @@ -92,7 +126,9 @@ export class TranslationBridgeClient { */ async getStats(): Promise { try { - const response = await invoke>('translation_bridge_stats'); + const response = await this.withRetry(() => + invoke>('translation_bridge_stats') + ); return response.data; } catch (error: unknown) { clientLogger.error(`[TranslationBridge] Failed to get stats: ${String(error)}`); @@ -105,7 +141,9 @@ export class TranslationBridgeClient { */ async getDictionaryStats(): Promise { try { - const response = await invoke>('translation_bridge_dictionary_stats'); + const response = await this.withRetry(() => + invoke>('translation_bridge_dictionary_stats') + ); return response.data; } catch (error: unknown) { clientLogger.error(`[TranslationBridge] Failed to get dictionary stats: ${String(error)}`); @@ -188,9 +226,11 @@ export class TranslationBridgeClient { */ async getTranslation(text: string): Promise { try { - const response = await invoke>('translation_bridge_get_translation', { - text, - }); + const response = await this.withRetry(() => + invoke>('translation_bridge_get_translation', { + text, + }) + ); return response.data; } catch (error: unknown) { clientLogger.error(`[TranslationBridge] Failed to get translation: ${String(error)}`); @@ -213,6 +253,22 @@ export class TranslationBridgeClient { } } + /** + * Drain cache misses (untranslated texts) for AI fallback. + * Returns up to `max` unique texts that were not found in the dictionary. + */ + async drainMisses(max: number = 100): Promise { + try { + const response = await invoke>('translation_bridge_drain_misses', { + max, + }); + return response.data ?? []; + } catch (error: unknown) { + clientLogger.error(`[TranslationBridge] Failed to drain misses: ${String(error)}`); + return []; + } + } + /** * Clear all dictionaries */ diff --git a/src-tauri/src/commands/translation_bridge.rs b/src-tauri/src/commands/translation_bridge.rs index 72b04ba3..f1245e89 100644 --- a/src-tauri/src/commands/translation_bridge.rs +++ b/src-tauri/src/commands/translation_bridge.rs @@ -7,19 +7,30 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use tauri::State; +use parking_lot::RwLock; use crate::translation_bridge::TranslationBridge; use crate::translation_bridge::shared_memory_ipc::BridgeStats; -use crate::translation_bridge::dictionary_engine::DictionaryStats; +use crate::translation_bridge::dictionary_engine::{DictionaryEngine, DictionaryStats}; -/// Stato globale del Translation Bridge +/// Stato globale del Translation Bridge. +/// `dictionary` e `miss_receiver` sono esposti direttamente per evitare double-locking: +/// le operazioni sul dizionario usano `RwLock` e il drain dei miss usa il proprio Mutex, +/// entrambi senza passare dal `Mutex`. pub struct TranslationBridgeState { pub bridge: Arc>, + pub dictionary: Arc>, + pub miss_receiver: Arc>>, } impl TranslationBridgeState { pub fn new() -> Self { + let bridge = TranslationBridge::new(); + let dictionary = Arc::clone(bridge.dictionary()); + let miss_receiver = Arc::clone(bridge.miss_receiver()); Self { - bridge: Arc::new(Mutex::new(TranslationBridge::new())), + bridge: Arc::new(Mutex::new(bridge)), + dictionary, + miss_receiver, } } } @@ -102,8 +113,7 @@ pub async fn translation_bridge_stats( pub async fn translation_bridge_dictionary_stats( state: State<'_, TranslationBridgeState>, ) -> Result, String> { - let bridge = state.bridge.lock(); - let dict = bridge.dictionary().read(); + let dict = state.dictionary.read(); Ok(BridgeResponse::ok(dict.get_stats())) } @@ -127,14 +137,13 @@ pub async fn translation_bridge_load_translations( state: State<'_, TranslationBridgeState>, params: LoadTranslationsParams, ) -> Result, String> { - let bridge = state.bridge.lock(); - let translations: Vec<(String, String)> = params.translations .into_iter() .map(|p| (p.original, p.translated)) .collect(); - - let count = bridge.load_dictionary(¶ms.source_lang, ¶ms.target_lang, translations); + + let mut dict = state.dictionary.write(); + let count = dict.load_translations(¶ms.source_lang, ¶ms.target_lang, translations); Ok(BridgeResponse::ok(count)) } @@ -144,9 +153,8 @@ pub async fn translation_bridge_load_json( state: State<'_, TranslationBridgeState>, path: String, ) -> Result, String> { - let bridge = state.bridge.lock(); - - match bridge.load_dictionary_from_json(&path) { + let mut dict = state.dictionary.write(); + match dict.load_from_json(&path) { Ok(count) => Ok(BridgeResponse::ok(count)), Err(e) => Ok(BridgeResponse::err(e)), } @@ -159,8 +167,7 @@ pub async fn translation_bridge_set_languages( source: String, target: String, ) -> Result, String> { - let bridge = state.bridge.lock(); - let mut dict = bridge.dictionary().write(); + let mut dict = state.dictionary.write(); dict.set_active_languages(&source, &target); Ok(BridgeResponse::ok(format!("Lingue attive: {} -> {}", source, target))) } @@ -172,8 +179,7 @@ pub async fn translation_bridge_add_translation( original: String, translated: String, ) -> Result, String> { - let bridge = state.bridge.lock(); - let mut dict = bridge.dictionary().write(); + let mut dict = state.dictionary.write(); dict.add_translation(original.clone(), translated); Ok(BridgeResponse::ok(format!("Aggiunta traduzione: {}", original))) } @@ -184,12 +190,9 @@ pub async fn translation_bridge_get_translation( state: State<'_, TranslationBridgeState>, text: String, ) -> Result>, String> { - let bridge = state.bridge.lock(); - let dict = bridge.dictionary().read(); - + let dict = state.dictionary.read(); let hash = crate::translation_bridge::protocol::TranslationRequest::compute_hash(&text); let result = dict.get_translation(hash, &text); - Ok(BridgeResponse::ok(result)) } @@ -199,9 +202,7 @@ pub async fn translation_bridge_export_json( state: State<'_, TranslationBridgeState>, path: String, ) -> Result, String> { - let bridge = state.bridge.lock(); - let dict = bridge.dictionary().read(); - + let dict = state.dictionary.read(); match dict.export_to_json(&path) { Ok(_) => Ok(BridgeResponse::ok(format!("Esportato in {}", path))), Err(e) => Ok(BridgeResponse::err(e)), @@ -213,8 +214,33 @@ pub async fn translation_bridge_export_json( pub async fn translation_bridge_clear( state: State<'_, TranslationBridgeState>, ) -> Result, String> { - let bridge = state.bridge.lock(); - let mut dict = bridge.dictionary().write(); + let mut dict = state.dictionary.write(); dict.clear_all(); Ok(BridgeResponse::ok("Dizionari puliti".to_string())) } + +/// Drena i cache miss (testi non tradotti) per AI fallback. +/// Il frontend può usare questi testi per chiamare l'API di traduzione e +/// poi reinserirli nel dizionario con `translation_bridge_add_translation`. +/// Usa il receiver diretto (non passa dal Mutex). +#[tauri::command] +pub async fn translation_bridge_drain_misses( + state: State<'_, TranslationBridgeState>, + max: Option, +) -> Result>, String> { + let max = max.unwrap_or(100); + let receiver = state.miss_receiver.lock(); + let mut texts = Vec::new(); + let mut seen = std::collections::HashSet::new(); + while texts.len() < max { + match receiver.try_recv() { + Ok(text) => { + if seen.insert(text.clone()) { + texts.push(text); + } + } + Err(_) => break, + } + } + Ok(BridgeResponse::ok(texts)) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f313c8ff..7fa49c43 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -776,6 +776,7 @@ fn main() { commands::translation_bridge::translation_bridge_get_translation, commands::translation_bridge::translation_bridge_export_json, commands::translation_bridge::translation_bridge_clear, + commands::translation_bridge::translation_bridge_drain_misses, // Translation API (DeepL, Google, LibreTranslate) commands::translation_api::translate_deepl, diff --git a/src-tauri/src/translation_bridge/dictionary_engine.rs b/src-tauri/src/translation_bridge/dictionary_engine.rs index bda9f760..41b0c1d5 100644 --- a/src-tauri/src/translation_bridge/dictionary_engine.rs +++ b/src-tauri/src/translation_bridge/dictionary_engine.rs @@ -8,9 +8,10 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +use std::time::SystemTime; use serde::{Deserialize, Serialize}; -use tracing::info; +use tracing::{info, warn}; use super::protocol::TranslationRequest; @@ -27,15 +28,16 @@ pub struct TranslationEntry { pub verified: bool, } -/// Dizionario per una coppia di lingue +/// Limite massimo di entry per dizionario (previene OOM con file enormi) +const MAX_DICTIONARY_ENTRIES: usize = 500_000; + +/// Dizionario per una coppia di lingue. +/// Storage singolo indicizzato per hash FNV-1a; il testo originale è dentro l'entry +/// per risolvere collisioni hash senza duplicare l'intera mappa. #[derive(Debug, Default)] pub struct LanguageDictionary { - /// Traduzioni indicizzate per hash - translations_by_hash: HashMap, - /// Traduzioni indicizzate per testo originale (fallback) - translations_by_text: HashMap, - /// Numero di traduzioni - count: usize, + /// Traduzioni indicizzate per hash — unico storage + entries: HashMap, } impl LanguageDictionary { @@ -43,48 +45,63 @@ impl LanguageDictionary { pub fn new() -> Self { Self::default() } - - /// Aggiunge una traduzione - pub fn add(&mut self, original: String, translated: String) { + + /// Aggiunge una traduzione. Sovrascrive se la chiave esiste già (nessun count drift). + /// Ritorna false se il dizionario ha raggiunto il limite massimo. + pub fn add(&mut self, original: String, translated: String) -> bool { + use std::collections::hash_map::Entry; let hash = TranslationRequest::compute_hash(&original); - let entry = TranslationEntry { - original: original.clone(), - translated, - context: None, - verified: false, - }; - - self.translations_by_hash.insert(hash, entry.clone()); - self.translations_by_text.insert(original, entry); - self.count += 1; + let at_capacity = self.entries.len() >= MAX_DICTIONARY_ENTRIES; + + match self.entries.entry(hash) { + Entry::Occupied(mut e) => { + let entry = e.get_mut(); + entry.original = original; + entry.translated = translated; + true + } + Entry::Vacant(e) => { + if at_capacity { return false; } + e.insert(TranslationEntry { + original, + translated, + context: None, + verified: false, + }); + true + } + } } - - /// Cerca traduzione per hash (veloce) - pub fn get_by_hash(&self, hash: u64) -> Option<&TranslationEntry> { - self.translations_by_hash.get(&hash) + + /// Cerca traduzione per hash (O(1), verifica collisioni) + pub fn get_by_hash(&self, hash: u64, original_text: &str) -> Option<&TranslationEntry> { + self.entries.get(&hash).filter(|e| e.original == original_text) } - - /// Cerca traduzione per testo (fallback) - pub fn get_by_text(&self, text: &str) -> Option<&TranslationEntry> { - self.translations_by_text.get(text) + + /// Controlla se un hash esiste (per diagnostica collisioni) + pub fn has_hash(&self, hash: u64) -> bool { + self.entries.contains_key(&hash) } - + /// Numero di traduzioni pub fn len(&self) -> usize { - self.count + self.entries.len() } - + #[allow(dead_code)] pub fn is_empty(&self) -> bool { - self.count == 0 + self.entries.is_empty() } - + /// Pulisce il dizionario #[allow(dead_code)] pub fn clear(&mut self) { - self.translations_by_hash.clear(); - self.translations_by_text.clear(); - self.count = 0; + self.entries.clear(); + } + + /// Iteratore sulle entry (per export) + pub fn iter(&self) -> impl Iterator { + self.entries.values() } } @@ -97,9 +114,9 @@ pub struct DictionaryEngine { active_source: String, /// Lingua target attiva active_target: String, - /// Path per hot-reload #[allow(dead_code)] - watch_paths: Vec, + /// Path per hot-reload con ultimo modification time noto + watch_paths: HashMap>, } impl DictionaryEngine { @@ -108,7 +125,7 @@ impl DictionaryEngine { dictionaries: HashMap::new(), active_source: "en".to_string(), active_target: "it".to_string(), - watch_paths: Vec::new(), + watch_paths: HashMap::new(), } } @@ -185,81 +202,103 @@ impl DictionaryEngine { Err("Formato JSON non riconosciuto".to_string()) } - /// Carica traduzioni da file CSV + /// Carica traduzioni da file CSV. + /// Supporta campi quotati con virgole interne e escape `""` → `"`. #[allow(dead_code)] pub fn load_from_csv(&mut self, path: &str, source_col: usize, target_col: usize) -> Result { let path = Path::new(path); - + if !path.exists() { return Err(format!("File non trovato: {}", path.display())); } - + let content = fs::read_to_string(path) .map_err(|e| format!("Errore lettura file: {}", e))?; - + let mut translations = Vec::new(); - + for (line_num, line) in content.lines().enumerate() { - // Salta header - if line_num == 0 { - continue; - } - - let cols: Vec<&str> = line.split(',').collect(); - - if cols.len() > source_col && cols.len() > target_col { - let original = cols[source_col].trim().trim_matches('"').to_string(); - let translated = cols[target_col].trim().trim_matches('"').to_string(); - + if line_num == 0 { continue; } // skip header + + let cols = Self::parse_csv_line(line); + let max_col = source_col.max(target_col); + + if cols.len() > max_col { + let original = cols[source_col].trim().to_string(); + let translated = cols[target_col].trim().to_string(); + if !original.is_empty() && !translated.is_empty() { translations.push((original, translated)); } } } - + Ok(self.load_translations(&self.active_source.clone(), &self.active_target.clone(), translations)) } + + /// Parse di una riga CSV con supporto campi quotati e escape `""`. + fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '"' if !in_quotes && current.is_empty() => { + in_quotes = true; + } + '"' if in_quotes => { + // "" escape → literal " + if chars.peek() == Some(&'"') { + current.push('"'); + chars.next(); + } else { + in_quotes = false; + } + } + ',' if !in_quotes => { + fields.push(current.clone()); + current.clear(); + } + _ => { + current.push(ch); + } + } + } + fields.push(current); + fields + } /// Cerca una traduzione (usa la coppia di lingue attiva) pub fn get_translation(&self, hash: u64, original_text: &str) -> Option { let key = Self::get_key(&self.active_source, &self.active_target); - + if let Some(dict) = self.dictionaries.get(&key) { - // Prima prova con hash (più veloce) - if let Some(entry) = dict.get_by_hash(hash) { - // Verifica che l'hash non sia una collisione - if entry.original == original_text { - return Some(entry.translated.clone()); - } - } - - // Fallback su ricerca testuale - if let Some(entry) = dict.get_by_text(original_text) { + if let Some(entry) = dict.get_by_hash(hash, original_text) { return Some(entry.translated.clone()); } + // Hash presente ma original diverso = collisione FNV-1a (estremamente rara) + if dict.has_hash(hash) { + info!("[DictionaryEngine] Collisione hash FNV-1a per '{}' (hash={})", original_text, hash); + } } - + None } - + /// Cerca traduzione con coppia di lingue specifica #[allow(dead_code)] pub fn get_translation_for(&self, source: &str, target: &str, original_text: &str) -> Option { let key = Self::get_key(source, target); let hash = TranslationRequest::compute_hash(original_text); - + if let Some(dict) = self.dictionaries.get(&key) { - if let Some(entry) = dict.get_by_hash(hash) { - if entry.original == original_text { - return Some(entry.translated.clone()); - } - } - - if let Some(entry) = dict.get_by_text(original_text) { + if let Some(entry) = dict.get_by_hash(hash, original_text) { return Some(entry.translated.clone()); } } - + None } @@ -270,6 +309,70 @@ impl DictionaryEngine { dict.add(original, translated); } + /// Batch translation lookup — traduce più testi in una sola chiamata + #[allow(dead_code)] + pub fn batch_translate(&self, texts: &[String]) -> Vec> { + let key = Self::get_key(&self.active_source, &self.active_target); + + if let Some(dict) = self.dictionaries.get(&key) { + texts.iter().map(|text| { + let hash = TranslationRequest::compute_hash(text); + dict.get_by_hash(hash, text).map(|e| e.translated.clone()) + }).collect() + } else { + vec![None; texts.len()] + } + } + + /// Registra un file JSON per hot-reload. Carica immediatamente e memorizza il mtime. + #[allow(dead_code)] + pub fn watch_file(&mut self, path: &str) -> Result { + let count = self.load_from_json(path)?; + let mtime = fs::metadata(path).ok().and_then(|m| m.modified().ok()); + self.watch_paths.insert(path.to_string(), mtime); + info!("[DictionaryEngine] Watching file: {} ({} traduzioni)", path, count); + Ok(count) + } + + /// Controlla tutti i file watched per modifiche e ricarica quelli cambiati. + /// Ritorna il numero totale di traduzioni ricaricate. + #[allow(dead_code)] + pub fn check_and_reload(&mut self) -> usize { + // Fase 1: raccogli i path da ricaricare (borrow immutabile) + let mut to_reload: Vec<(String, SystemTime)> = Vec::new(); + + for (path, last_mtime) in &self.watch_paths { + if let Ok(meta) = fs::metadata(path) { + if let Ok(current_mtime) = meta.modified() { + let changed = match last_mtime { + Some(prev) => current_mtime > *prev, + None => true, + }; + if changed { + to_reload.push((path.clone(), current_mtime)); + } + } + } + } + + // Fase 2: ricarica i file modificati (borrow mutabile) + let mut reloaded = 0; + for (path, mtime) in to_reload { + match self.load_from_json(&path) { + Ok(count) => { + info!("[DictionaryEngine] Hot-reload: {} ({} traduzioni)", path, count); + reloaded += count; + self.watch_paths.insert(path, Some(mtime)); + } + Err(e) => { + warn!("[DictionaryEngine] Hot-reload errore {}: {}", path, e); + } + } + } + + reloaded + } + /// Ottieni statistiche pub fn get_stats(&self) -> DictionaryStats { let mut total_entries = 0; @@ -288,6 +391,67 @@ impl DictionaryEngine { } } + /// Salva tutti i dizionari su disco in formato JSON + #[allow(dead_code)] + pub fn save_to_dir(&self, dir: &str) -> Result { + let dir_path = Path::new(dir); + fs::create_dir_all(dir_path) + .map_err(|e| format!("Impossibile creare directory: {}", e))?; + + let mut saved = 0; + for (key, dict) in &self.dictionaries { + if dict.is_empty() { continue; } + let file_path = dir_path.join(format!("{}.json", key)); + let translations: HashMap = dict.iter() + .map(|entry| (entry.original.clone(), entry.translated.clone())) + .collect(); + let json = serde_json::to_string_pretty(&translations) + .map_err(|e| format!("Errore serializzazione {}: {}", key, e))?; + fs::write(&file_path, json) + .map_err(|e| format!("Errore scrittura {}: {}", file_path.display(), e))?; + saved += translations.len(); + } + info!("[DictionaryEngine] Salvate {} traduzioni in {}", saved, dir); + Ok(saved) + } + + /// Carica tutti i dizionari da una directory + #[allow(dead_code)] + pub fn load_from_dir(&mut self, dir: &str) -> Result { + let dir_path = Path::new(dir); + if !dir_path.exists() { return Ok(0); } + + let mut loaded = 0; + let entries = fs::read_dir(dir_path) + .map_err(|e| format!("Errore lettura directory: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } + + let stem = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + // Il nome file è "source_target.json" (es. "en_it.json") + let parts: Vec<&str> = stem.splitn(2, '_').collect(); + if parts.len() != 2 { continue; } + + let content = fs::read_to_string(&path) + .map_err(|e| format!("Errore lettura {}: {}", path.display(), e))?; + let map: HashMap = serde_json::from_str(&content) + .map_err(|e| format!("Errore parsing {}: {}", path.display(), e))?; + + let translations: Vec<(String, String)> = map.into_iter().collect(); + let count = translations.len(); + self.load_translations(parts[0], parts[1], translations); + loaded += count; + } + + info!("[DictionaryEngine] Caricate {} traduzioni da {}", loaded, dir); + Ok(loaded) + } + /// Pulisce tutti i dizionari pub fn clear_all(&mut self) { self.dictionaries.clear(); @@ -311,9 +475,8 @@ impl DictionaryEngine { let dict = self.dictionaries.get(&key) .ok_or_else(|| "Dizionario non trovato".to_string())?; - let translations: HashMap = dict.translations_by_text - .iter() - .map(|(k, v)| (k.clone(), v.translated.clone())) + let translations: HashMap = dict.iter() + .map(|entry| (entry.original.clone(), entry.translated.clone())) .collect(); let json = serde_json::to_string_pretty(&translations) @@ -378,6 +541,21 @@ mod tests { assert_eq!(stats.total_entries, 2); } + #[test] + fn test_duplicate_key_no_count_drift() { + let mut engine = DictionaryEngine::new(); + engine.set_active_languages("en", "it"); + + engine.add_translation("Hello".to_string(), "Ciao".to_string()); + engine.add_translation("Hello".to_string(), "Salve".to_string()); // update, not new + + let stats = engine.get_stats(); + assert_eq!(stats.total_entries, 1, "duplicate key should not increment count"); + + let hash = TranslationRequest::compute_hash("Hello"); + assert_eq!(engine.get_translation(hash, "Hello"), Some("Salve".to_string())); + } + #[test] fn test_multiple_language_pairs() { let mut engine = DictionaryEngine::new(); diff --git a/src-tauri/src/translation_bridge/protocol.rs b/src-tauri/src/translation_bridge/protocol.rs index 27e069bd..8d21b664 100644 --- a/src-tauri/src/translation_bridge/protocol.rs +++ b/src-tauri/src/translation_bridge/protocol.rs @@ -11,6 +11,17 @@ //! Il plugin C# scrive le stringhe originali nell'area Request Data e imposta lo //! slot a PendingRequest. Il server Rust legge, cerca nel dizionario, scrive la //! traduzione nell'area Response Data e imposta PendingResponse. +//! +//! L'area Response Data è un circular buffer: `response_data_head` (scritto da Rust) +//! e `response_data_tail` (scritto da C# dopo aver letto) delimitano lo spazio occupato. +//! Rust rifiuta di scrivere se non c'è spazio sufficiente (slot → Error). +//! +//! **Wrap convention**: quando una traduzione non entra nello spazio contiguo tra head +//! e la fine del buffer, Rust wrappa a offset 0 (verificando spazio prima di tail). +//! I byte tra il vecchio head e RESPONSE_DATA_SIZE diventano dead space. +//! Il C# NON deve leggere sequenzialmente dal buffer — ogni slot contiene +//! `translated_offset` e `translated_len` che puntano direttamente alla traduzione. +//! Il C# avanza `response_data_tail` all'offset massimo consumato per liberare spazio. use serde::{Deserialize, Serialize}; @@ -112,8 +123,14 @@ pub struct SharedMemoryHeader { pub cache_misses: u64, /// Write head nell'area richieste (offset relativo a REQUEST_DATA_OFFSET) pub request_data_head: u32, - /// Write head nell'area risposte (offset relativo a RESPONSE_DATA_OFFSET) + /// Write head nell'area risposte (offset relativo a RESPONSE_DATA_OFFSET). + /// Avanzato da Rust dopo ogni scrittura di traduzione. pub response_data_head: u32, + /// Read tail nell'area risposte (offset relativo a RESPONSE_DATA_OFFSET). + /// Avanzato dal C# dopo aver letto una traduzione. + /// Rust non scrive mai nell'intervallo [tail..head) per evitare di sovrascrivere + /// risposte non ancora lette. + pub response_data_tail: u32, } impl SharedMemoryHeader { @@ -131,6 +148,7 @@ impl SharedMemoryHeader { cache_misses: 0, request_data_head: 0, response_data_head: 0, + response_data_tail: 0, } } diff --git a/src-tauri/src/translation_bridge/shared_memory_ipc.rs b/src-tauri/src/translation_bridge/shared_memory_ipc.rs index 0ecf5279..49f03f33 100644 --- a/src-tauri/src/translation_bridge/shared_memory_ipc.rs +++ b/src-tauri/src/translation_bridge/shared_memory_ipc.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::mpsc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -22,6 +23,32 @@ use tracing::{debug, info, warn}; use super::dictionary_engine::DictionaryEngine; use super::protocol::*; +/// Wrapper thread-safe per `Shmem`. +/// +/// `Shmem` non implementa `Send`/`Sync` perché contiene un raw pointer. +/// Questo wrapper è sicuro perché: +/// - Il puntatore è valido per tutta la vita dell'`Arc` +/// - Tutti gli accessi usano `volatile` read/write (necessario per cross-process) +/// - La `Shmem` viene rilasciata solo quando l'ultimo `Arc` viene droppato, +/// quindi il server thread non può mai usare un puntatore dangling +struct SharedShmem(Shmem); + +// SAFETY: Shmem è un mapping di memoria del sistema operativo. Il puntatore +// interno è valido finché l'oggetto Shmem esiste. Tutti gli accessi alla +// shared memory usano ptr::read_volatile / ptr::write_volatile. +unsafe impl Send for SharedShmem {} +unsafe impl Sync for SharedShmem {} + +impl SharedShmem { + fn as_ptr(&self) -> *mut u8 { + self.0.as_ptr() + } + + fn len(&self) -> usize { + self.0.len() + } +} + /// Statistiche del bridge — esposte al frontend via Tauri commands #[derive(Debug, Clone, Default, Serialize)] pub struct BridgeStats { @@ -71,8 +98,10 @@ struct InternalStats { pub struct TranslationBridge { /// Dictionary engine per le traduzioni (thread-safe, condiviso con server thread) dictionary: Arc>, - /// Shared memory region (mantiene il mapping vivo) - shmem: Option, + /// Shared memory region — condivisa con il server thread via Arc. + /// Il server thread mantiene un clone dell'Arc, garantendo che la Shmem + /// resti viva anche se TranslationBridge viene droppato durante un panic. + shmem: Option>, /// Server thread che processa le richieste IPC server_thread: Option>, /// Flag atomico per controllare il server thread @@ -85,15 +114,11 @@ pub struct TranslationBridge { shmem_name: String, /// Dimensione della shared memory allocata shmem_size: usize, + /// Coda di testi non tradotti (cache miss) — drenata dall'AI fallback + miss_sender: mpsc::Sender, + miss_receiver: Arc>>, } -// SAFETY: TranslationBridge contiene una Shmem il cui puntatore raw viene passato -// al server thread come usize. La Shmem resta viva (owned da TranslationBridge) -// per tutta la durata del server thread. Il puntatore non viene mai usato dopo -// che stop() rilascia la Shmem. -unsafe impl Send for TranslationBridge {} -unsafe impl Sync for TranslationBridge {} - impl TranslationBridge { /// Crea un nuovo Translation Bridge con il nome shared memory di default pub fn new() -> Self { @@ -102,6 +127,7 @@ impl TranslationBridge { /// Crea un bridge con nome shared memory custom (per test isolation) pub fn with_name(name: &str) -> Self { + let (miss_sender, miss_receiver) = mpsc::channel(); Self { dictionary: Arc::new(RwLock::new(DictionaryEngine::new())), shmem: None, @@ -111,6 +137,8 @@ impl TranslationBridge { start_time: None, shmem_name: name.to_string(), shmem_size: 0, + miss_sender, + miss_receiver: Arc::new(parking_lot::Mutex::new(miss_receiver)), } } @@ -125,20 +153,19 @@ impl TranslationBridge { } // 1. Crea o apri la shared memory - let shmem = self.create_shared_memory()?; - let shmem_ptr = shmem.as_ptr() as usize; + let shmem = Arc::new(SharedShmem(self.create_shared_memory()?)); let shmem_len = shmem.len(); // 2. Inizializza header e slot Self::initialize_shared_memory(shmem.as_ptr(), shmem_len)?; // 3. Salva riferimenti - self.shmem = Some(shmem); + self.shmem = Some(Arc::clone(&shmem)); self.shmem_size = shmem_len; self.running.store(true, Ordering::SeqCst); self.start_time = Some(Instant::now()); - // 4. Avvia server thread + // 4. Avvia server thread (co-owner della shmem via Arc) let dictionary = Arc::clone(&self.dictionary); let running = Arc::clone(&self.running); let stats = Arc::clone(&self.stats); @@ -147,7 +174,7 @@ impl TranslationBridge { .name("translation-bridge-ipc".to_string()) .spawn(move || { info!("[TranslationBridge] Server IPC thread avviato"); - Self::server_loop(shmem_ptr, dictionary, running, stats); + Self::server_loop(&shmem, dictionary, running, stats); info!("[TranslationBridge] Server IPC thread terminato"); }) .map_err(|e| format!("Errore avvio server thread: {}", e))?; @@ -179,14 +206,18 @@ impl TranslationBridge { } } - // Attendi che il thread termini (timeout implicito: il thread esce dal loop) + // Attendi che il thread termini (timeout implicito: il thread esce dal loop). + // Il thread detiene un Arc, quindi la shmem resta viva finché + // il thread non termina — nessun rischio di dangling pointer. if let Some(thread) = self.server_thread.take() { if let Err(e) = thread.join() { warn!("[TranslationBridge] Server thread join error: {:?}", e); } } - // Rilascia shared memory (chiude il mapping Windows) + // Rilascia il nostro Arc — se il thread è già terminato, questo è l'ultimo + // riferimento e la Shmem viene chiusa. Se il thread è ancora vivo (join fallito), + // la Shmem resta viva finché il thread non termina. self.shmem = None; self.shmem_size = 0; self.start_time = None; @@ -195,6 +226,7 @@ impl TranslationBridge { } /// Carica un dizionario di traduzioni (thread-safe, puo' essere chiamato a server attivo) + #[allow(dead_code)] pub fn load_dictionary( &self, source_lang: &str, @@ -211,6 +243,7 @@ impl TranslationBridge { } /// Carica traduzioni da file JSON (thread-safe) + #[allow(dead_code)] pub fn load_dictionary_from_json(&self, path: &str) -> Result { let mut dict = self.dictionary.write(); dict.load_from_json(path) @@ -237,6 +270,8 @@ impl TranslationBridge { stats.cache_hits += 1; } else { stats.cache_misses += 1; + // Accoda per AI fallback (best-effort, ignora errore se coda piena) + let _ = self.miss_sender.send(text.to_string()); } // Welford's online algorithm per media stabile let n = stats.total_requests as f64; @@ -274,6 +309,11 @@ impl TranslationBridge { &self.dictionary } + /// Ottieni accesso al receiver dei cache miss (per Tauri commands, evita double-locking) + pub fn miss_receiver(&self) -> &Arc>> { + &self.miss_receiver + } + // ─── Internals ──────────────────────────────────────────────── /// Crea la shared memory nominata via OS @@ -348,12 +388,12 @@ impl TranslationBridge { /// - Carico medio: thread yield /// - Idle: sleep 50µs (CPU ~0%) fn server_loop( - shmem_ptr: usize, + shmem: &Arc, dictionary: Arc>, running: Arc, stats: Arc>, ) { - let base_ptr = shmem_ptr as *mut u8; + let base_ptr = shmem.as_ptr(); let mut idle_count: u32 = 0; let start_time = Instant::now(); @@ -490,45 +530,87 @@ impl TranslationBridge { ); local_errors += 1; } else { - // Alloca spazio nell'area risposte con wrap-around + // Circular buffer: calcola spazio disponibile + // head = dove Rust scrive, tail = dove C# ha consumato let resp_head = std::ptr::read_volatile(&(*header).response_data_head) as usize; - - let write_offset = if resp_head + translated_len <= RESPONSE_DATA_SIZE { - resp_head + let resp_tail = + std::ptr::read_volatile(&(*header).response_data_tail) as usize; + + // Spazio libero totale nel circular buffer (contando entrambi + // i segmenti: [head..END] e [0..tail]). Il -1 evita che + // head == tail venga interpretato come "vuoto" quando pieno. + let free_space = if resp_head >= resp_tail { + (RESPONSE_DATA_SIZE - resp_head) + resp_tail - 1 } else { - 0 // Wrap around all'inizio del buffer + resp_tail - resp_head - 1 }; - // Copia la traduzione nell'area risposte - std::ptr::copy_nonoverlapping( - translated_bytes.as_ptr(), - response_data.add(write_offset), - translated_len, - ); - - // Aggiorna metadata dello slot - (*slot).translated_offset = write_offset as u32; - (*slot).translated_len = translated_len as u32; - - // Aggiorna write head - let new_head = (write_offset + translated_len) % RESPONSE_DATA_SIZE; - std::ptr::write_volatile( - &mut (*header).response_data_head, - new_head as u32, - ); - - // Aggiorna stats nella shared memory (visibili al C#) - let hits = std::ptr::read_volatile(&(*header).cache_hits); - std::ptr::write_volatile(&mut (*header).cache_hits, hits + 1); - - // Marca come completato - std::ptr::write_volatile( - &mut (*slot).state, - SlotState::PendingResponse as u8, - ); - - local_hits += 1; + if free_space < translated_len { + // Buffer risposte pieno — C# non ha ancora consumato. + // Marca come errore; il C# riproverà al prossimo ciclo. + warn!( + "[TranslationBridge] Response buffer pieno (free={}, need={}), slot in errore", + free_space, translated_len + ); + std::ptr::write_volatile( + &mut (*slot).state, + SlotState::Error as u8, + ); + local_errors += 1; + } else { + // Scegli offset: scrivi a head se c'è spazio contiguo, + // altrimenti wrappa a 0. Quando si wrappa, i byte tra + // resp_head e RESPONSE_DATA_SIZE diventano dead space — + // il C# non li legge perché ogni slot ha il proprio + // translated_offset esplicito. Aggiorniamo head a 0 + // così il free_space accounting rimane corretto. + let write_offset = if resp_head + translated_len <= RESPONSE_DATA_SIZE { + resp_head + } else { + // Wrap: verifica che ci sia spazio dall'inizio al tail + if translated_len >= resp_tail { + warn!( + "[TranslationBridge] Response buffer: wrap fallito (need={}, tail={})", + translated_len, resp_tail + ); + std::ptr::write_volatile( + &mut (*slot).state, + SlotState::Error as u8, + ); + local_errors += 1; + current_idx = current_idx.wrapping_add(1); + continue; + } + 0 + }; + + // Copia la traduzione nell'area risposte + std::ptr::copy_nonoverlapping( + translated_bytes.as_ptr(), + response_data.add(write_offset), + translated_len, + ); + + // Aggiorna metadata dello slot + (*slot).translated_offset = write_offset as u32; + (*slot).translated_len = translated_len as u32; + + // Aggiorna write head + let new_head = (write_offset + translated_len) % RESPONSE_DATA_SIZE; + std::ptr::write_volatile( + &mut (*header).response_data_head, + new_head as u32, + ); + + // Marca come completato + std::ptr::write_volatile( + &mut (*slot).state, + SlotState::PendingResponse as u8, + ); + + local_hits += 1; + } } } None => { @@ -536,9 +618,6 @@ impl TranslationBridge { (*slot).translated_len = 0; (*slot).translated_offset = 0; - let misses = std::ptr::read_volatile(&(*header).cache_misses); - std::ptr::write_volatile(&mut (*header).cache_misses, misses + 1); - std::ptr::write_volatile( &mut (*slot).state, SlotState::PendingResponse as u8, @@ -548,10 +627,6 @@ impl TranslationBridge { } } - // Aggiorna contatore richieste nella shared memory - let total = std::ptr::read_volatile(&(*header).total_requests); - std::ptr::write_volatile(&mut (*header).total_requests, total + 1); - response_times.push(request_start.elapsed().as_micros() as f64); processed += 1; } @@ -559,11 +634,12 @@ impl TranslationBridge { current_idx = current_idx.wrapping_add(1); } - // Aggiorna read_index nella shared memory + // Aggiorna read_index e stats nella shared memory (batch, una sola volta) if processed > 0 { std::ptr::write_volatile(&mut (*header).read_index, current_idx); - // Batch update delle stats Rust (singola acquisizione del lock) + // Batch update delle stats Rust (singola acquisizione del lock). + // Le stats Rust sono la source of truth — i totali assoluti. let mut s = stats.write(); s.cache_hits += local_hits; s.cache_misses += local_misses; @@ -575,6 +651,14 @@ impl TranslationBridge { let n = s.total_requests as f64; s.avg_response_time_us += (elapsed_us - s.avg_response_time_us) / n; } + + // Copia i totali assoluti nella shared memory (visibili al C#). + // Singola write volatile per campo — nessun read-modify-write, nessun + // rischio TOCTOU. Il C# legge questi valori come snapshot diagnostici. + // Su x86 le write allineate u64 sono atomiche (non torn). + std::ptr::write_volatile(&mut (*header).total_requests, s.total_requests); + std::ptr::write_volatile(&mut (*header).cache_hits, s.cache_hits); + std::ptr::write_volatile(&mut (*header).cache_misses, s.cache_misses); } processed @@ -678,9 +762,9 @@ mod tests { bridge.start().unwrap(); // Verifica che la shared memory sia stata inizializzata correttamente - if let Some(ref shmem) = bridge.shmem { + if let Some(ref shmem_arc) = bridge.shmem { unsafe { - let header = shmem.as_ptr() as *const SharedMemoryHeader; + let header = shmem_arc.as_ptr() as *const SharedMemoryHeader; assert_eq!((*header).magic, MAGIC_NUMBER); assert_eq!((*header).version, PROTOCOL_VERSION); assert_eq!((*header).server_active, 1); @@ -853,6 +937,96 @@ mod tests { bridge.stop(); } + #[test] + fn test_response_buffer_full_returns_error() { + let name = unique_shmem_name(); + let mut bridge = TranslationBridge::with_name(&name); + + // Traduzione da 60KB (dentro MAX_STRING_SIZE=64KB) per riempire il buffer velocemente + let big_translation = "A".repeat(60_000); + bridge.load_dictionary( + "en", "it", + vec![ + ("fill".to_string(), big_translation), + ("extra".to_string(), "Qualcosa".to_string()), + ], + ); + + bridge.start().unwrap(); + + let shmem_ptr = bridge.shmem.as_ref().unwrap().as_ptr(); + + // Helper: invia una richiesta nello slot `slot_idx` e attendi la risposta + let submit_and_wait = |slot_idx: usize, text: &str, write_idx: u32| -> SlotState { + let text_bytes = text.as_bytes(); + let data_offset = slot_idx * 64; // spazio separato per ogni richiesta + unsafe { + let header = shmem_ptr as *mut SharedMemoryHeader; + let slots_base = shmem_ptr.add(SLOTS_OFFSET) as *mut TranslationSlot; + let request_data = shmem_ptr.add(REQUEST_DATA_OFFSET); + + std::ptr::copy_nonoverlapping( + text_bytes.as_ptr(), + request_data.add(data_offset), + text_bytes.len(), + ); + + let slot = slots_base.add(slot_idx); + (*slot).original_offset = data_offset as u32; + (*slot).original_len = text_bytes.len() as u32; + (*slot).original_hash = TranslationRequest::compute_hash(text); + (*slot).translated_offset = 0; + (*slot).translated_len = 0; + std::ptr::write_volatile(&mut (*slot).state, SlotState::PendingRequest as u8); + std::ptr::write_volatile(&mut (*header).write_index, write_idx); + } + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let state = unsafe { + let slot = shmem_ptr.add(SLOTS_OFFSET) as *const TranslationSlot; + SlotState::from(std::ptr::read_volatile(&(*slot.add(slot_idx)).state)) + }; + if state == SlotState::PendingResponse || state == SlotState::Error { + return state; + } + if Instant::now() > deadline { panic!("Timeout slot {}", slot_idx); } + thread::sleep(Duration::from_millis(1)); + } + }; + + // 1. Riempi il buffer con 60KB di traduzione (tail resta 0, non consumiamo) + let state0 = submit_and_wait(0, "fill", 1); + assert_eq!(state0, SlotState::PendingResponse, "Primo slot deve avere successo"); + + // Verifica che head sia avanzato di ~60K + let head_after = unsafe { + let header = shmem_ptr as *const SharedMemoryHeader; + (*header).response_data_head + }; + assert!(head_after >= 59_000, "Head deve essere avanzato: {}", head_after); + + // 2-N. Continua a inviare "fill" finché il buffer non è pieno. + // Con RESPONSE_DATA_SIZE = 2MB e 60KB per richiesta, servono ~34 richieste. + // Il tail resta 0 (non simuliamo il C# che consuma) quindi a un certo punto + // lo spazio finisce e il server deve restituire Error. + let mut got_error = false; + for i in 1..50 { + let state = submit_and_wait(i % MAX_SLOTS, "fill", (i + 1) as u32); + if state == SlotState::Error { + got_error = true; + break; + } + } + + assert!(got_error, "Il server deve restituire Error quando il buffer risposte è pieno"); + + let stats = bridge.get_stats(); + assert!(stats.errors > 0, "Deve avere almeno un errore"); + + bridge.stop(); + } + #[test] fn test_drop_stops_server() { let name = unique_shmem_name(); From d5446412071ecbcde594759bb08204eb051c123f Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 10:17:06 +0200 Subject: [PATCH 2/4] Bump PROTOCOL_VERSION to 2 for the new header field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovered circular buffer adds response_data_tail to SharedMemoryHeader, which changes the layout every client must agree on, but the branch left PROTOCOL_VERSION at 1. Nothing breaks today — the only would-be client is GameStringer.Satellite, whose QueryBackend is still a TODO returning null — and that is exactly why the bump is free now and expensive later. is_valid() already rejects a version mismatch, so a stale plugin built against the v1 layout fails the handshake instead of misreading offsets. Co-Authored-By: Claude Opus 5 --- src-tauri/src/translation_bridge/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/translation_bridge/protocol.rs b/src-tauri/src/translation_bridge/protocol.rs index 8d21b664..1b7355b3 100644 --- a/src-tauri/src/translation_bridge/protocol.rs +++ b/src-tauri/src/translation_bridge/protocol.rs @@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize}; pub const MAGIC_NUMBER: u32 = 0x47535452; /// Versione del protocollo -pub const PROTOCOL_VERSION: u8 = 1; +pub const PROTOCOL_VERSION: u8 = 2; /// Dimensione massima di una singola stringa (64KB) pub const MAX_STRING_SIZE: usize = 65536; From 20505f6eab654fff6a63175e60f01f19fc738fc2 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 10:35:27 +0200 Subject: [PATCH 3/4] Measure both IPC transports before choosing between them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open question was whether a Named Pipe round trip holds up at rendering frequency, or whether the shared memory bridge is worth finishing. Measured instead of guessed: same workload on both, 3000 iterations after warmup, release build. Named Pipe p50 9.2us p95 18.9us p99 30.4us shared memory p50 0.4us p95 0.5us p99 0.8us So the pipe is ~20x slower per string — and it does not matter. GSTranslator::Translate() checks a process-local cache first and only calls IPC on a miss, and that cache is persisted across sessions. The ~88 strings/frame the pipe affords at p95 is not a rendering budget, it is a budget of strings never seen before, which decays to zero within seconds of play. Measuring also surfaced that no request/response path is complete on both sides: the overlay pipe is one-way by design, the DLL's translator pipe has no Rust server, ipc_bridge's server is a loop that sleeps, and the shared memory plugin's QueryBackend still returns null. The DLL even looks for a pipe name Rust never declares. Recorded in the log with the rest. ipc_bench.rs compiles only under cfg(test), so it stays reproducible without entering the binary. Co-Authored-By: Claude Opus 5 --- docs/METODI-DI-TRADUZIONE.md | 58 +++++++++ src-tauri/src/ipc_bench.rs | 225 +++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 284 insertions(+) create mode 100644 src-tauri/src/ipc_bench.rs diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index 17c1eaa6..6b96b25b 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -165,6 +165,64 @@ nulla — quel gioco non la spedisce — ma non è il caso generale. --- +## Traduzione in tempo reale (IPC) + +### La Named Pipe costa ~19us a stringa, e non è sul percorso caldo + +Il round trip su Named Pipe è ~20x più lento della shared memory, e non +importa: la DLL chiama l'IPC solo quando **non** ha già la stringa in cache, +cioè la prima volta che la vede. In regime la traduzione in-game non fa IPC. + +**Come è stato misurato.** Stesso carico sui due trasporti — lookup in +dizionario di 16 stringhe di gioco realistiche (da `New Game` a una riga di +dialogo da 120 caratteri), una richiesta alla volta, 3000 iterazioni dopo 300 di +warmup, latenza end-to-end lato chiamante: + +```text +cargo test --release --lib ipc_bench -- --nocapture --test-threads=1 +``` + +| trasporto | p50 | p95 | p99 | max | +|---|---|---|---|---| +| Named Pipe | 9.2us | 18.9us | 30.4us | 68.2us | +| shared memory | 0.4us | 0.5us | 0.8us | 22.2us | + +Due esecuzioni indipendenti sono rientrate entro il 5% su ogni percentile. + +Spendendo il 10% di un frame a 60fps (1667us): ~88 stringhe/frame sulla pipe, +~3333 sulla shared memory. In debug il divario è ancora più netto (37us contro +1.0us di p50) perché la shmem beneficia dell'ottimizzazione, la pipe è +dominata dalle syscall. + +**Perché non conta.** `GSTranslator::Translate()` in +`unreal-translator/hook-dll/src/translator.cpp:46` cerca prima nella cache locale +del processo, e solo su miss chiama `IPC::SendTranslateRequest`. La cache è +persistita su disco tra le sessioni (`LoadCache`/`SaveCache`), e `source_gdi.cpp` +fa dedup per riga. Quindi 88 stringhe/frame non è il budget di rendering: è il +budget di stringhe **mai viste prima**, che dopo i primi secondi di gioco tende a +zero. + +**La trappola.** Sembra una scelta di architettura da fare col cronometro — pipe +o shared memory. Non lo è: con una cache davanti, il trasporto è irrilevante +per la frequenza di frame, e vince quello che è finito, non quello che è +veloce. Vedi lo stato dei due sotto. + +**Stato reale dei trasporti** (misurato il 21 agosto 2026): + +| pipe / regione | lato Rust | lato client | +|---|---|---| +| pipe `GameStringerOverlay` | reale, `src-tauri/src/overlay_ipc.rs` | reale, `gs-hook/src/gs_overlay_ipc.cpp` — **sola scrittura**, fire-and-forget | +| pipe `GameStringerTranslator` | **nessun server** | reale, `unreal-translator/hook-dll/src/ipc.cpp` | +| pipe `GameStringerUETranslator` | **stub**: `start_windows_pipe_server` dorme in un loop (`ue_translator/ipc_bridge.rs:130`) | — | +| shmem `GameStringer_TranslationBridge_v1` | reale, `translation_bridge/shared_memory_ipc.rs` | **TODO**: `QueryBackend` ritorna `null` (`plugins/GameStringer.Satellite/Plugin.cs`) | + +Nessun percorso richiesta/risposta è completo su entrambi i lati. L'unica cosa +che funziona end-to-end è l'overlay, che è unidirezionale e non ha bisogno di +round trip. Il nome che la DLL cerca (`GameStringerTranslator`) non combacia +nemmeno con quello che il Rust dichiara (`GameStringerUETranslator`). + +--- + ## Come si aggiunge una voce Quando trovi un modo nuovo di estrarre o reiniettare testo, o capisci perché un diff --git a/src-tauri/src/ipc_bench.rs b/src-tauri/src/ipc_bench.rs new file mode 100644 index 00000000..afa0dbf1 --- /dev/null +++ b/src-tauri/src/ipc_bench.rs @@ -0,0 +1,225 @@ +//! Misura il costo per stringa dei due trasporti IPC. +//! +//! Named Pipe (il trasporto di gs-hook / overlay_ipc) contro shared memory +//! (il Translation Bridge). Stesso carico su entrambi: lookup in dizionario di +//! stringhe di gioco realistiche, una richiesta alla volta, latenza end-to-end. +//! +//! Compila solo sotto `cargo test`: non entra nel binario. +//! Numeri e interpretazione in `docs/METODI-DI-TRADUZIONE.md`. +//! +//! ```text +//! cargo test --release --lib ipc_bench -- --nocapture --test-threads=1 +//! ``` + +#![cfg(all(test, windows))] + +use std::io::{Read, Write}; +use std::time::Instant; + +const ITERATIONS: usize = 3000; +const WARMUP: usize = 300; + +/// Stringhe di gioco realistiche: label UI corte e righe di dialogo lunghe. +fn corpus() -> Vec<(String, String)> { + let pairs: &[(&str, &str)] = &[ + ("New Game", "Nuova partita"), + ("Continue", "Continua"), + ("Options", "Opzioni"), + ("Quit to Desktop", "Esci al desktop"), + ("Save", "Salva"), + ("Load", "Carica"), + ("Inventory", "Inventario"), + ("Health", "Salute"), + ("Stamina", "Resistenza"), + ("Press any key to continue", "Premi un tasto per continuare"), + ("You have obtained a Rusty Key.", "Hai ottenuto una Chiave Arrugginita."), + ("The door is locked. Perhaps there is a key somewhere nearby.", + "La porta e' chiusa a chiave. Forse c'e' una chiave qui vicino."), + ("I have been waiting for you for a very long time, traveller. Sit down, and let me tell you what happened to this village.", + "Ti aspettavo da moltissimo tempo, viaggiatore. Siediti, e lascia che ti racconti cosa e' successo a questo villaggio."), + ("Autosaving...", "Salvataggio automatico..."), + ("Level Up!", "Livello superiore!"), + ("Are you sure you want to abandon this quest?", + "Sei sicuro di voler abbandonare questa missione?"), + ]; + pairs.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +fn report(label: &str, mut samples_us: Vec) { + samples_us.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = samples_us.len(); + let pct = |p: f64| samples_us[((n as f64 * p) as usize).min(n - 1)]; + let mean = samples_us.iter().sum::() / n as f64; + + println!( + "\n{label}\n n={n} media={mean:.1}us p50={:.1}us p95={:.1}us p99={:.1}us max={:.1}us", + pct(0.50), pct(0.95), pct(0.99), pct(1.0) + ); + // Un frame a 60fps dura 16667us. Quante stringhe ci stanno se ne spendiamo il 10%? + let budget_us = 16_667.0 * 0.10; + println!( + " a 60fps, col 10% del frame (1667us): ~{:.0} stringhe/frame (p95), ~{:.0} (p99)", + budget_us / pct(0.95), + budget_us / pct(0.99) + ); +} + +// ─── Named Pipe ─────────────────────────────────────────────────── + +/// Server: frame `[u32 LE len][payload UTF-8]`, lo stesso wire format di +/// `overlay_ipc.rs`. Payload grezzo, non JSON — e' il caso migliore per la pipe. +#[test] +fn bench_named_pipe_roundtrip() { + use std::collections::HashMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::windows::named_pipe::ServerOptions; + + let pipe_name = format!(r"\\.\pipe\gs_bench_{}", std::process::id()); + let dict: HashMap = corpus().into_iter().collect(); + + let server_name = pipe_name.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let mut pipe = ServerOptions::new().create(&server_name).unwrap(); + pipe.connect().await.unwrap(); + loop { + let mut len_buf = [0u8; 4]; + if pipe.read_exact(&mut len_buf).await.is_err() { + return; // client chiuso + } + let len = u32::from_le_bytes(len_buf) as usize; + let mut payload = vec![0u8; len]; + pipe.read_exact(&mut payload).await.unwrap(); + let text = std::str::from_utf8(&payload).unwrap(); + + let reply = dict.get(text).cloned().unwrap_or_default(); + let bytes = reply.as_bytes(); + pipe.write_all(&(bytes.len() as u32).to_le_bytes()).await.unwrap(); + pipe.write_all(bytes).await.unwrap(); + pipe.flush().await.unwrap(); + } + }); + }); + + // Client: blocking, come una DLL che chiama dal thread di rendering. + let mut client = loop { + match std::fs::OpenOptions::new().read(true).write(true).open(&pipe_name) { + Ok(f) => break f, + Err(_) => std::thread::sleep(std::time::Duration::from_millis(5)), + } + }; + + let corpus = corpus(); + let mut samples = Vec::with_capacity(ITERATIONS); + + for i in 0..(WARMUP + ITERATIONS) { + let (original, expected) = &corpus[i % corpus.len()]; + let bytes = original.as_bytes(); + + let t0 = Instant::now(); + client.write_all(&(bytes.len() as u32).to_le_bytes()).unwrap(); + client.write_all(bytes).unwrap(); + client.flush().unwrap(); + + let mut len_buf = [0u8; 4]; + client.read_exact(&mut len_buf).unwrap(); + let len = u32::from_le_bytes(len_buf) as usize; + let mut reply = vec![0u8; len]; + client.read_exact(&mut reply).unwrap(); + let elapsed = t0.elapsed().as_nanos() as f64 / 1000.0; + + assert_eq!(std::str::from_utf8(&reply).unwrap(), expected); + if i >= WARMUP { + samples.push(elapsed); + } + } + + drop(client); + let _ = server.join(); + report("NAMED PIPE — round trip per stringa", samples); +} + +// ─── Shared memory ──────────────────────────────────────────────── + +/// Client: apre la shmem per nome e guida gli slot esattamente come dovrebbe +/// fare il plugin C#, tail del circular buffer incluso. +#[test] +fn bench_shared_memory_roundtrip() { + use crate::translation_bridge::protocol::*; + use crate::translation_bridge::TranslationBridge; + use shared_memory::ShmemConf; + + let name = format!("gs_bench_shmem_{}", std::process::id()); + let mut bridge = TranslationBridge::with_name(&name); + bridge.load_dictionary("en", "it", corpus()); + bridge.start().expect("bridge start"); + + let shmem = ShmemConf::new().os_id(&name).open().expect("open shmem"); + let base = shmem.as_ptr(); + + let corpus = corpus(); + let mut samples = Vec::with_capacity(ITERATIONS); + let mut write_index: u32 = 0; + + unsafe { + let header = base as *mut SharedMemoryHeader; + let slots = base.add(SLOTS_OFFSET) as *mut TranslationSlot; + let request_data = base.add(REQUEST_DATA_OFFSET); + let response_data = base.add(RESPONSE_DATA_OFFSET); + + for i in 0..(WARMUP + ITERATIONS) { + let (original, expected) = &corpus[i % corpus.len()]; + let bytes = original.as_bytes(); + let slot_idx = i % MAX_SLOTS; + let slot = slots.add(slot_idx); + + let t0 = Instant::now(); + + std::ptr::copy_nonoverlapping(bytes.as_ptr(), request_data, bytes.len()); + (*slot).original_offset = 0; + (*slot).original_len = bytes.len() as u32; + (*slot).original_hash = TranslationRequest::compute_hash(original); + (*slot).translated_offset = 0; + (*slot).translated_len = 0; + std::ptr::write_volatile(&mut (*slot).state, SlotState::PendingRequest as u8); + write_index = write_index.wrapping_add(1); + std::ptr::write_volatile(&mut (*header).write_index, write_index); + + // Spin come farebbe il plugin nel thread di rendering. + let state = loop { + let s = SlotState::from(std::ptr::read_volatile(&(*slot).state)); + if s == SlotState::PendingResponse || s == SlotState::Error { + break s; + } + std::hint::spin_loop(); + }; + let elapsed = t0.elapsed().as_nanos() as f64 / 1000.0; + + assert_eq!(state, SlotState::PendingResponse, "iterazione {i}"); + + let off = (*slot).translated_offset as usize; + let len = (*slot).translated_len as usize; + let got = std::slice::from_raw_parts(response_data.add(off), len); + assert_eq!(std::str::from_utf8(got).unwrap(), expected); + + // Ruolo del C#: libera lo spazio consumato e rilascia lo slot. + std::ptr::write_volatile( + &mut (*header).response_data_tail, + ((off + len) % RESPONSE_DATA_SIZE) as u32, + ); + std::ptr::write_volatile(&mut (*slot).state, SlotState::Empty as u8); + + if i >= WARMUP { + samples.push(elapsed); + } + } + } + + drop(shmem); + bridge.stop(); + report("SHARED MEMORY — round trip per stringa", samples); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 303c3034..2f2f0f66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod commands; pub mod anti_cheat; pub mod engine_detector; pub mod translation_bridge; +mod ipc_bench; pub mod activity_history; #[cfg(windows)] pub mod ue_translator; From bd31cc694808500e902883fbd5cd8c5bd869ef88 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 10:48:23 +0200 Subject: [PATCH 4/4] Correct the pipe-name claim: two channels, not one mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous entry said the DLL looks for a pipe name Rust never declares, and that the two would not meet even if the server existed. That was wrong. Reading the UTF-16 strings out of the prebuilt DLLs the repo ships settles it: gs-hook.dll (x64, x86) GameStringerOverlay + GameStringerTranslator unity_auto_translator.dll GameStringerUETranslator They are two separate channels with two separate clients. unity_injector.rs and the Unity DLL agree on GameStringerUETranslator and are fine as they are; gs-hook is the one with no server and no Rust constant at all. Renaming either into the other would disconnect a prebuilt binary — the name lives in a C++ header and the DLL would have to be rebuilt, not just the Rust constant edited. No code changes: there was no misnamed pipe to repair. The real defect is unchanged and already recorded — both channels are missing their server. Co-Authored-By: Claude Opus 5 --- docs/METODI-DI-TRADUZIONE.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index 6b96b25b..9051f494 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -213,13 +213,35 @@ veloce. Vedi lo stato dei due sotto. |---|---|---| | pipe `GameStringerOverlay` | reale, `src-tauri/src/overlay_ipc.rs` | reale, `gs-hook/src/gs_overlay_ipc.cpp` — **sola scrittura**, fire-and-forget | | pipe `GameStringerTranslator` | **nessun server** | reale, `unreal-translator/hook-dll/src/ipc.cpp` | -| pipe `GameStringerUETranslator` | **stub**: `start_windows_pipe_server` dorme in un loop (`ue_translator/ipc_bridge.rs:130`) | — | +| pipe `GameStringerUETranslator` | **stub**: `start_windows_pipe_server` dorme in un loop (`ue_translator/ipc_bridge.rs:130`) | reale, `unity-translator-dll/src/ipc_client.h` | | shmem `GameStringer_TranslationBridge_v1` | reale, `translation_bridge/shared_memory_ipc.rs` | **TODO**: `QueryBackend` ritorna `null` (`plugins/GameStringer.Satellite/Plugin.cs`) | Nessun percorso richiesta/risposta è completo su entrambi i lati. L'unica cosa che funziona end-to-end è l'overlay, che è unidirezionale e non ha bisogno di -round trip. Il nome che la DLL cerca (`GameStringerTranslator`) non combacia -nemmeno con quello che il Rust dichiara (`GameStringerUETranslator`). +round trip. + +**I due nomi di pipe non sono un disallineamento**, per quanto si somiglino: sono +due canali distinti, ciascuno col suo client. Verificato leggendo le stringhe +UTF-16 dentro le DLL precompilate che il repo spedisce: + +| DLL | nome incorporato | iniettata da | +|---|---|---| +| `resources/gs-hook/{x64,x86}/gs-hook.dll` | `GameStringerOverlay` + `GameStringerTranslator` | `gs_hook_injector.rs` | +| `resources/unity-translator/unity_auto_translator.dll` | `GameStringerUETranslator` | `unity_injector.rs` | + +```bash +python -c "import io;b=io.open('src-tauri/resources/gs-hook/x64/gs-hook.dll','rb').read();print([n for n in ['GameStringerTranslator','GameStringerUETranslator'] if n.encode('utf-16-le') in b])" +``` + +Quindi `unity_injector.rs` e la DLL Unity si accordano correttamente su +`GameStringerUETranslator`; a gs-hook manca un server e in Rust non esiste +nemmeno una costante per `GameStringerTranslator`. **Rinominare l'una nell'altra +scollegherebbe la DLL Unity**, che è un binario precompilato nel repo: il nome +va cambiato nell'header C++ e la DLL ricompilata, non solo in Rust. + +**La trappola.** Due nomi che differiscono di due lettere sembrano un refuso da +sistemare. Prima di allinearli, leggi cosa c'è dentro i binari: qui erano due +canali sani, e l'unico difetto vero era il server che manca a entrambi. ---