← Commit history

v1.9.32 — fix pup wedge: stable player port across drainer respawns; surface-unavailable names hydrogen; check reads History fallback; skills default to hydrogen

John Lauer ·56346a2179 ·2mo ago ·parent 18bd813
5 files changed +63−27
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.31"+version = "1.9.32" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.31+1.9.32
dev-skills/adom-tts-dev/SKILL.md+11
@@ -79,6 +79,17 @@ manually — deploy a new version and let supersede do the handoff. If you must - **Honest `status`.** `0s/0s` with no live page (poll_age > 5s) reads as   `⚠ STALLED — no audio sink`, NOT "now playing" (which read as healthy while   the surface was never open and misled a reporting thread).+- **PORT STABILITY (pup wedge).** Drainers cycle (idle-close + respawn); an+  ephemeral port each time STRANDS the open player page on a dead port while+  new clips queue to the new port — "player :A polls A forever while clips go+  to :B". `bind_player_server` REUSES the last port (`read_player_port`, retry+  briefly for a superseding drainer to release it) so a stale page reconnects+  to the fresh drainer with zero re-navigation. Hydrogen survived this via+  open-or-refresh re-navigation; pup did not. Also: `--surface pup` is fragile+  (ambiguous window with >1 desktop, stale port) — SKILLS DEFAULT TO HYDROGEN+  and only use pup on explicit request. A `surface-unavailable` failure names+  the working hydrogen surface, and `check`/`--confirm` say the clip is fine,+  only the surface failed. - **`adom-tts restart`** = the real reset: kills the drainer, clears   lock+portfile, respawns a FRESH drainer of the SAME surface, pinned from the   current env. `reopen` only re-offers the tab and keeps a broken player;
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+50−4
@@ -317,6 +317,18 @@ pub fn check_clip(clip: &str) -> Result<()> {             _ => {}         }     }+    // events.jsonl rotates (newest 3000) and a flapping surface floods it with+    // open_surface_failed — a real attempt's clip_end can scroll out, leaving+    // `check` to wrongly say UNKNOWN. History persists the last 40 outcomes, so+    // fall back to it to distinguish "sent and failed" from "never sent".+    if end_verdict.is_none() {+        if let Some(v) = read_play_history(40).into_iter()+            .find(|e| e.get("name").and_then(|n| n.as_str()) == Some(clip))+            .and_then(|e| e.get("verdict").and_then(|x| x.as_str()).map(str::to_string)) {+            end_verdict = Some(v);+            started = true;+        }+    }     // Is it still in the shared queue (and where)?     let dir = tts_home()?.join(QUEUE_SUBDIR);     let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()@@ -350,6 +362,12 @@ pub fn check_clip(clip: &str) -> Result<()> {             println!("✓ PLAYED — \"{clip}\" played fully to the human. Safe to tell them you spoke it.");             Ok(())         }+        Some(v) if v == "surface-unavailable" => {+            println!("✗ FAILED — \"{clip}\" never played: the requested player SURFACE could not open (not the clip's fault).");+            println!("# hint: the DEFAULT 'hydrogen' webview surface is reliable — re-send WITHOUT `--surface pup`.");+            println!("#       (For pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart, then re-send.)");+            std::process::exit(5);+        }         Some(v) if v == "expired" => {             println!("⌛ EXPIRED — \"{clip}\" waited past the freshness TTL (default 2h; buffered or pause-held) and was");             println!("   skipped so the human wouldn't hear stale news. It sits in the player History, flagged.");@@ -464,8 +482,12 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result<                 // race from field feedback).                 last_fail = Some((verdict.clone(), reached, dur));                 if !clip_pending_or_playing(clip) {-                    eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s; retries exhausted)");+                    eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s)");                     eprintln!("  surface: {}", surface_evidence());+                    if verdict == "surface-unavailable" {+                        eprintln!("# The requested player SURFACE could not open — the clip, queue, and synthesis are FINE.");+                        eprintln!("#   Re-send WITHOUT `--surface pup` (the default hydrogen webview is reliable).");+                    }                     eprintln!("# hint: the human did NOT hear this. Do NOT tell them it played. Fix the surface first");                     eprintln!("#       (`adom-tts reopen` if the page is dead), then re-send ONCE with `--confirm`.");                     std::process::exit(5);@@ -827,8 +849,13 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                     if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) { let _ = std::fs::remove_file(&mp3); }                                     let _ = std::fs::remove_file(&entry);                                     if std::env::var("ADOM_TTS_NO_HINTS").is_err() {-                                        eprintln!("# clip \"{name}\" skipped — no audio surface (unpinned/ambiguous desktop target, or dead bridge).");-                                        eprintln!("#   pin it: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart");+                                        eprintln!("# clip \"{name}\" skipped — the '{}' surface could not open a player.", surface.as_str());+                                        if surface == Surface::Pup {+                                            eprintln!("#   The DEFAULT 'hydrogen' webview surface is reliable — RETRY WITHOUT `--surface pup`.");+                                            eprintln!("#   (If you truly need pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart.)");+                                        } else {+                                            eprintln!("#   Try `adom-tts restart`, or `adom-tts logs` for the open error.");+                                        }                                     }                                     continue;                                 }@@ -1473,8 +1500,27 @@ fn player_page_html() -> String { /// Start the persistent player HTTP server on a stable loopback port. It serves /// the page ONCE and, for the rest of its life, swaps which clip that page /// plays via /poll — no per-clip tab navigation.+/// Bind the player HTTP server, PREFERRING the last-used port. Drainers cycle+/// (idle-close + respawn); an ephemeral port every time strands the already-open+/// player page on a DEAD port while new clips queue to the new port — the exact+/// "player :A polls A forever while clips go to :B" wedge. Reusing the port lets+/// a stale page reconnect to the fresh drainer with zero re-navigation. Retries+/// briefly in case the superseded drainer is still releasing it.+fn bind_player_server() -> Result<Server> {+    if let Some(p) = read_player_port() {+        for _ in 0..6 {+            if let Ok(sv) = Server::http(format!("127.0.0.1:{p}")) {+                log_event("port_reused", serde_json::json!({ "port": p }));+                return Ok(sv);+            }+            thread::sleep(Duration::from_millis(350));+        }+    }+    Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))+}+ fn start_player_server() -> Result<Player> {-    let server = Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;+    let server = bind_player_server()?;     let port = server.server_addr().to_ip().ok_or_else(|| anyhow!("non-ip server addr"))?.port();     let server = Arc::new(server);     let shared = Arc::new(PlayerShared {