← Commit history

v1.9.54: one surface open per drain (10s debounce), re-offers logged with their reason, T13 asserts a single hbw open, fake page no longer orphans (issue #7)

John Lauer ·8dd119431e ·1mo ago ·parent dc9e5e6
6 files changed +41−27
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.53"+version = "1.9.54" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.53+1.9.54
dev-skills/adom-tts-test/SKILL.md+1−1
@@ -41,7 +41,7 @@ and the incident behind each: | T9 port stability | "the port differs on every invocation": a page must reconnect to the same port | | T11 script markers | issue #4 demo narration: `cue` plays exactly one marker as its own clip, marker 2 never auto-advances, a re-cue is accepted (not dedup-refused), `/poll` advertises prefetch, `/seg` serves bytes, `cue-status` is honest | | T12 queue management | issue #4 §2: unscoped `queue clear` refused (exit 5), `--mine` scopes by thread, `queue drop` one, `--all` empties, all dropped clips in History, `control skip` ends the playing clip with verdict `skipped` and no retry |-| T13 hydrogen to desktop fall-through | issue #5: with a fake `adom-cli` (no browser session) and a fake `adom-bridge`, a drainer start must log `surface_fallthrough` to desktop, send `hd_api POST /hbw/open` to the pinned target, record `surface: desktop` in the portfile, and every `clip_end` names the surface that took it |+| T13 hydrogen to desktop fall-through | issue #5: with a fake `adom-cli` (no browser session) and a fake `adom-bridge`, a drainer start must log `surface_fallthrough` to desktop, send `hd_api POST /hbw/open` to the pinned target, record `surface: desktop` in the portfile, every `clip_end` names the surface that took it, and (issue #7) the drain opens the desktop window EXACTLY ONCE: the audience wait's first offer lands as `surface_open_skipped`, never a second `/hbw/open`; later offers log `surface_reoffer` with the reason | | T10 second clip survives the swap | "two threads sent clips and you stopped the playing one": stale telemetry from clip A killed clip B on arrival (falsely "ok"). The `stale-done` fake-page mode replays the blob-download window; B must live >4s and never inherit A's `reached` |  Extending: add a `say "TN ..."` block copying an existing test. Trigger 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+26−1
@@ -1233,14 +1233,28 @@ fn queue_depth(dir: &Path) -> usize {  /// Open (or refresh) the single player surface. Idempotent — used both to open /// the tab once at startup and to RE-OPEN it if the page later disappears.+/// Wall-clock ms of the last SUCCESSFUL surface open in this drainer. Opens+/// are debounced against it: the startup open and the audience wait's first+/// offer used to land a second apart, which is harmless for a webview tab+/// (open-or-refresh by name) and a pup session (navigate), but opens a SECOND+/// Hydrogen Desktop window every drain (issue #7). One open per 10s, always.+static LAST_SURFACE_OPEN_MS: AtomicU64 = AtomicU64::new(0);+const SURFACE_OPEN_DEBOUNCE_MS: u64 = 10_000;+ fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfig) -> bool {+    let since = now_ms().saturating_sub(LAST_SURFACE_OPEN_MS.load(Ordering::SeqCst));+    if since < SURFACE_OPEN_DEBOUNCE_MS {+        log_event("surface_open_skipped", serde_json::json!({ "surface": surface.as_str(), "opened_ms_ago": since,+            "reason": "a surface open succeeded moments ago; one open per 10s (issue #7)" }));+        return true;+    }     let opened = match surface {         Surface::Hydrogen => server::open_webview_tab_named(url, PLAYER_TAB_NAME),         Surface::Pup => open_pup_window(url, &play_cfg.pup_session, &play_cfg.pup_profile),         Surface::Desktop => open_desktop_window(url),     };     match opened {-        Ok(()) => { write_player_surface(surface); true }+        Ok(()) => { write_player_surface(surface); LAST_SURFACE_OPEN_MS.store(now_ms(), Ordering::SeqCst); true }         Err(e) => {             log_event("open_surface_failed", serde_json::json!({ "surface": surface.as_str(), "error": e.to_string() }));             // FALL-THROUGH (issue #5): the hydrogen surface needs a LIVE cloud@@ -1254,6 +1268,7 @@ fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfi                         log_event("surface_fallthrough", serde_json::json!({ "from": "hydrogen", "to": "desktop", "reason": e.to_string() }));                         eprintln!("# no live Hydrogen editor session: opened the player in Hydrogen Desktop's own window instead (surface desktop)");                         write_player_surface(Surface::Desktop);+                        LAST_SURFACE_OPEN_MS.store(now_ms(), Ordering::SeqCst);                         return true;                     }                     Err(e2) => {@@ -1381,6 +1396,7 @@ fn wait_for_audience(player: &Player, surface: Surface, url: &str,     let started = Instant::now();     let mut last_offer = Instant::now() - Duration::from_secs(60);     let mut open_ok_ever = false;+    let mut offers = 0u32;     while started.elapsed() < Duration::from_secs(AUDIENCE_WAIT_SECS) {         if !i_own_lock() { return Audience::Superseded; }         if player.shared.poll_age_ms() < LIVE_MS && sustained_live(&player.shared) {@@ -1389,6 +1405,15 @@ fn wait_for_audience(player: &Player, surface: Surface, url: &str,             return Audience::Ready;         }         if last_offer.elapsed() > Duration::from_secs(15) || started.elapsed() < Duration::from_millis(100) {+            // Every offer after the first is a RETRY and says so in the log,+            // with the reason, so two opens never read as two opens of the+            // same page (issue #7, ask 2). The debounce in open_player_surface+            // turns an offer that follows the startup open into a no-op.+            offers += 1;+            if offers > 1 {+                log_event("surface_reoffer", serde_json::json!({ "surface": surface.as_str(), "attempt": offers,+                    "reason": format!("page has not polled for {}s", started.elapsed().as_secs()) }));+            }             if open_player_surface(surface, url, play_cfg) { open_ok_ever = true; }             last_offer = Instant::now();         }
tools/ralph-test.sh+12−2
@@ -45,8 +45,11 @@ sys.stdout.buffer.write(b'ID3' + bytes([4,0,0,0,0,0,0]) + bytes(random.randrange  # ---- fake page: polls, downloads, reports progress/pause/done --------------- # fakepage <port> <mode>   modes: play (progress→done) | pause-mid | just-poll+# exec: the background job's pid must BE the python page, or `kill $FP` only+# kills the wrapper subshell and an orphan page keeps polling a port the next+# drainer reuses (which hid issue #7 from T13 for two runs). fakepage() {-  python3 - "$1" "$2" <<'PY'+  exec python3 - "$1" "$2" <<'PY' import sys,time,json,urllib.request port,mode=sys.argv[1],sys.argv[2] base=f"http://127.0.0.1:{port}"@@ -329,10 +332,17 @@ python3 -c "import json,sys; sys.exit(0 if json.load(open('$T/.adom/tts/player.j # a clip's verdict names the surface that took it mkmp3 $T/t13.mp3 seed-t13 4000 rig play $T/t13.mp3 >/dev/null 2>&1-fakepage $(port) play >/dev/null 2>&1 & FP=$!+# A real Hydrogen Desktop window takes seconds to load and poll; a page that+# answers instantly hides issue #7 (the audience wait never re-offers). Delay it.+(sleep 6; fakepage $(port) play >/dev/null 2>&1) & FP=$! for i in $(seq 1 30); do ev clip_end | grep -q '"t13.mp3"' && break; sleep 1; done kill $FP 2>/dev/null ev clip_end | grep '"t13.mp3"' | grep -q '"surface":"desktop"' && ok "clip_end carries surface=desktop" || bad "clip_end surface: $(ev clip_end | grep t13 | tail -1 | head -c 200)"+# issue #7: the startup open and the audience wait's first offer must not both+# open a Hydrogen Desktop window. Exactly ONE hd_api /hbw/open per drain.+N=$(grep -c '/hbw/open' $FAKE_LOG 2>/dev/null)+[ "$N" -eq 1 ] && ok "desktop window opened exactly once per drain (hd_api /hbw/open x$N)" || bad "hd_api /hbw/open called $N times in one drain (issue #7)"+ev surface_open_skipped | grep -q 'issue #7' && ok "the repeat offer was logged as skipped (not a second open)" || bad "no surface_open_skipped event"  # teardown DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null)