← Commit history

v1.9.31 — anti-replay: a finished clip polls as idle so a reopened/reloaded page can't replay it (the flapping-surface double-play); req-id in logs

John Lauer ·1f00918e14 ·2mo ago ·parent ea556f4
5 files changed +24−27
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.30"+version = "1.9.31" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.30+1.9.31
dev-skills/adom-tts-dev/SKILL.md+12−1
@@ -106,9 +106,20 @@ without the snapshot a later overwrite corrupts/duplicates a queued clip) and carry name/label/words metadata. FIFO by timestamped filename. Every enqueue answers with the clip's queue position. +**ANTI-REPLAY on reopen (distinct from enqueue dedup).** The page plays any+clip whose polled `id != curClipId`, and a FRESH page (after a reopen) starts+at `curClipId=0` — so a reloaded page would replay a clip the drainer already+finished. During a flapping surface (e.g. 265 open_surface_failed → constant+reopens) this played clips twice. Enqueue dedup can't catch it (it's the same+queued clip replayed by a reloaded page, not a re-enqueue). FIX: once a clip+is `done`, `/poll` reports it as **idle (id 0)** so no page — current or freshly+loaded — ever starts it again; a new clip gets a fresh id. Any new "replay on+reload" symptom → check this guard first.+ **DEDUPLICATION — a resend must be structurally impossible to double-play.** Every enqueue fingerprints the mp3 bytes (FNV-1a 64, the request id, stored in-the queue entry AND every History row). Identical content already queued, or+the queue entry AND every History row; logged as `req=` on enqueue + clip_start+so a double-play is greppable by request id). Identical content already queued, or played within the last 15 min, is REFUSED (exit 7) with a teaching hint. Hints alone were proven insufficient: a thread resent during a slow window-open despite the "do NOT re-send" warning, and the human heard the
page.json−21
@@ -12,7 +12,6 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],@@ -65,25 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
src/play.rs+10−3
@@ -581,7 +581,7 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> {     std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;     let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())         .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);-    log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth }));+    log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth, "req": &hash }));     let clip = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip");     if std::env::var("ADOM_TTS_NO_HINTS").is_err() {         // Broker-style acknowledgement: tell the calling thread exactly where@@ -838,7 +838,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 id, bytes: Arc::new(bytes), title: name.clone(), meta,                                 label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),                             });-                            log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));+                            log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "req": &chash }));                             let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms);                             // Interrupted by a human replay tap? Not a failure —                             // keep the entry untouched; it restarts after the replay.@@ -1589,10 +1589,17 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s             let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();             let cmd = shared.cmd_str.lock().map(|s| s.clone()).unwrap_or_default();             let words: serde_json::Value = serde_json::from_str(&clip.words_json).unwrap_or_else(|_| serde_json::json!([]));+            // ANTI-REPLAY: a page plays whenever the polled id != the id it last+            // played, and a FRESH page (after a reopen) starts at id 0 — so a+            // reloaded page would replay a clip the drainer already finished+            // (the "played twice" during a flapping surface). Once a clip is+            // done, report it as idle (id 0): no page — current or freshly+            // loaded — will ever start it again. A new clip gets a fresh id.+            let live_id = if shared.done.load(Ordering::SeqCst) { 0 } else { clip.id };             let body = serde_json::json!({                 "seq": shared.cmd_seq.load(Ordering::SeqCst),                 "cmd": cmd,-                "id": clip.id,+                "id": live_id,                 "title": clip.title,                 "meta": clip.meta,                 "label": clip.label,