From af2504174a7340c7adee6ee8c10a153774369d9a Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 12:20:48 +0900 Subject: [PATCH 1/9] fix(alerts): coordinate chimes across open tabs --- CHANGELOG.md | 4 + scripts/alert-sound-regression.ts | 75 +++++++++++- src/App.tsx | 15 ++- src/lib/alertSound.test.ts | 27 ++++- src/lib/alertSound.ts | 40 +++++-- src/lib/alertTurns.test.ts | 122 +++++++++++++++++++ src/lib/alertTurns.ts | 187 ++++++++++++++++++++++++++++++ 7 files changed, 455 insertions(+), 15 deletions(-) create mode 100644 src/lib/alertTurns.test.ts create mode 100644 src/lib/alertTurns.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 152de26963..863de565c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Fixed +- Open tabs take turns chiming for the same alert. A waiting question is still heard if the + tab that claimed it closes or never plays it, including on plain-HTTP LAN addresses. + ## [0.4.2] - 2026-10-09 ### Added diff --git a/scripts/alert-sound-regression.ts b/scripts/alert-sound-regression.ts index 695582661c..0071fda810 100644 --- a/scripts/alert-sound-regression.ts +++ b/scripts/alert-sound-regression.ts @@ -1,3 +1,4 @@ +import "./test-herdr.ts"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -112,7 +113,6 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise assert.ok(Date.now() < deadline, "the status subscription includes the new panes"); await report(otherPane, "idle"); await report(otherPane, "working"); - await page.waitForTimeout(50); } // no tap yet: the page may not play, and nothing is kept to sound later await block(otherPane); @@ -131,6 +131,7 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise // with the chime: by the time the card for the pane behind shows, a chime for the pane in // front would have sounded already await page.locator(".droplet-card").waitFor({ state: "visible" }); + await page.waitForFunction(() => (window as unknown as { chimes: number[] }).chimes.length >= 2); assert.deepEqual(await chimes(), [660, 880], "one rising chime, for the pane not in front"); console.log("PASS a pane that waits chimes, the one in front does not"); @@ -179,6 +180,58 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise await page.evaluate(() => { (window as unknown as { rosterFirst: boolean }).rosterFirst = false; }); console.log("PASS a wait the roster showed before its event still chimes"); + // Both pages share the origin and BroadcastChannel, not just the same browser process. + const second = await context.newPage(); + second.on("pageerror", (error) => errors.push(error.message)); + await second.goto(`${origin}/?pane=${encodeURIComponent(openPane)}`); + await second.locator(".conn-live").waitFor(); + // A header gesture unlocks audio without typing into any terminal. + await second.getByRole("button", { name: "Settings", exact: true }).click(); + await second.keyboard.press("Escape"); + await second.getByRole("dialog", { name: "Settings" }).waitFor({ state: "detached" }); + const tabs = [page, second]; + const counts = () => Promise.all(tabs.map(async (tab) => (await chimes(tab)).length)); + const heard = async () => (await counts()).reduce((sum, count) => sum + count, 0); + // Check continuously through the safety-net deadline, not just at the first note. + // Time is the behaviour here: a duplicate must not arrive later in this turn. + const noSecondChime = async (beforeCounts: number[]) => { + await Promise.all(tabs.map(async (tab, index) => { + const expected = (await chimes(tab)).length; + assert.ok(expected <= beforeCounts[index]! + 2, "at most one new chime per tab"); + const deadline = await tab.evaluate(() => performance.now() + 1_200); + await tab.waitForFunction(({ expected, deadline }) => { + const recording = window as unknown as { chimes: number[]; chimeEnd: number }; + if (recording.chimes.length > expected) throw new Error("a second chime arrived"); + return performance.now() >= deadline && performance.now() / 1000 >= recording.chimeEnd; + }, { expected, deadline }, { timeout: 5_000 }); + })); + }; + await Promise.all(tabs.map(quiet)); + await report(thirdPane, "working"); + await Promise.all(tabs.map((tab) => seen(thirdPane, "working", tab))); + const bothBefore = await counts(); + await report(thirdPane, "blocked"); + await Promise.all(tabs.map((tab) => seen(thirdPane, "blocked", tab))); + // Either tab may win: subscribe to the audio recorder in both. + await Promise.any(tabs.map((tab, index) => tab.waitForFunction((count) => + (window as unknown as { chimes: number[] }).chimes.length > count, bothBefore[index], { timeout: 5_000 }))); + await noSecondChime(bothBefore); + const delta = (await heard()) - bothBefore.reduce((sum, count) => sum + count, 0); + console.log(`two-tab shared alert: ${delta / 2} chimes (${delta} notes)`); + assert.equal(delta, 2, "exactly one chime across two tabs for the same alert"); + console.log("PASS two open tabs chime once for a shared alert"); + + await report(otherPane, "working"); + await Promise.all(tabs.map((tab) => seen(otherPane, "working", tab))); + const frontBefore = await counts(); + assert.equal(await page.evaluate(() => document.visibilityState), "visible"); + await report(otherPane, "blocked"); + await Promise.all(tabs.map((tab) => seen(otherPane, "blocked", tab))); + await second.waitForFunction((count) => (window as unknown as { chimes: number[] }).chimes.length >= count + 2, frontBefore[1]); + await noSecondChime(frontBefore); + assert.deepEqual(await counts(), [frontBefore[0], frontBefore[1]! + 2], "tab B tells a question in front of tab A"); + console.log("PASS the other tab tells a question in front of the first tab"); + assert.deepEqual(errors, []); } finally { await context.close(); @@ -187,3 +240,23 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise rmSync(root, { recursive: true, force: true }); } } + +// Also runnable on its own through `bun run check run bun scripts/alert-sound-regression.ts`. +if (import.meta.main) { + const { chromium } = await import("playwright-core"); + const { createServer } = await import("../server/index.ts"); + const { UsageService } = await import("../server/usage.ts"); + const stateDir = mkdtempSync(join(tmpdir(), "herdr-web-ui-sound-server-")); + const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir, usage: new UsageService(undefined, []) }); + const browser = await chromium.launch({ + executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", + headless: true, args: ["--no-sandbox", "--accept-lang=en-US"], + }); + try { + await checkAlertSound(browser, `http://127.0.0.1:${server.port}`); + } finally { + await browser.close(); + server.stop(); + rmSync(stateDir, { recursive: true, force: true }); + } +} diff --git a/src/App.tsx b/src/App.tsx index 553be7df99..d5509b521b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,7 +54,8 @@ import { useScreenWakeLock } from "./lib/wakeLock.ts"; import { watchDrawerSwipe } from "./lib/edgeSwipe.ts"; import { Droplet } from "./components/Droplet.tsx"; import { dropletAllows, endedTurn, seedStatuses, showDroplet, trackTurn, type DropletKind } from "./lib/droplet.ts"; -import { playAlertSound, unlockAlertSound, type AlertSoundKind } from "./lib/alertSound.ts"; +import { canPlayAlertSound, playAlertSound, unlockAlertSound, type AlertSoundKind } from "./lib/alertSound.ts"; +import { createAlertTurnPlayer } from "./lib/alertTurns.ts"; const APP_TITLE = "herdr web ui"; const POLL_MS = 5000; @@ -349,11 +350,19 @@ export function App() { // The alert sound (lib/alertSound.ts): heard also while the tab is hidden and a Focus silences // system notifications; never for the pane open in front of the user. + const alertTurns = useRef | null>(null); + useEffect(() => { + const player = createAlertTurnPlayer({ + play: (kind) => alertsOnRef.current && alertSoundRef.current && playAlertSound(kind), + }); + alertTurns.current = player; + return () => { player.dispose(); alertTurns.current = null; }; + }, []); const chime = useCallback((machine: Machine, pane: HerdrPane, kind: AlertSoundKind) => { - if (!alertsOnRef.current || !alertSoundRef.current) return; + if (!alertsOnRef.current || !alertSoundRef.current || !canPlayAlertSound()) return; const open = selectionRef.current; if (document.visibilityState === "visible" && open.machineId === machine.id && open.paneId === pane.pane_id && !drawerOpenRef.current) return; - playAlertSound(kind); + alertTurns.current?.chime(JSON.stringify([machine.id, pane.pane_id, kind]), kind); }, []); // a page plays audio only after a tap or key on it: each one lets the next chime play. A mouse diff --git a/src/lib/alertSound.test.ts b/src/lib/alertSound.test.ts index a4a0ba8fb1..73876af4c9 100644 --- a/src/lib/alertSound.test.ts +++ b/src/lib/alertSound.test.ts @@ -34,7 +34,10 @@ const LENGTH = 0.16 + 0.3; const saved = (globalThis as { AudioContext?: unknown }).AudioContext; Object.assign(globalThis, { AudioContext: FakeAudioContext }); -afterAll(() => { Object.assign(globalThis, { AudioContext: saved }); }); +afterAll(() => { + for (const audio of made) audio.state = "closed"; + Object.assign(globalThis, { AudioContext: saved }); +}); /** Lets the chime of the tab's last test end, and forgets what it played. */ function quiet(): FakeAudioContext { @@ -131,6 +134,28 @@ describe("one chime at a time", () => { expect(started).toEqual([...CHIME_NOTES.done, ...CHIME_NOTES.done]); }); + it("confirms a queued question only after its audio clock reaches its start", async () => { + const audio = made.at(-1)!; + playAlertSound("done"); + const pending = playAlertSound("blocked"); + expect(pending).toBeInstanceOf(Promise); + let confirmed = false; + void Promise.resolve(pending).then(() => { confirmed = true; }); + await Promise.resolve(); + expect(confirmed).toBe(false); + audio.currentTime += LENGTH; + expect(await pending).toBe(true); + }); + + it("does not confirm a queued question if audio was interrupted before starting", async () => { + const audio = made.at(-1)!; + playAlertSound("done"); + const pending = playAlertSound("blocked"); + audio.state = "interrupted"; + expect(await pending).toBe(false); + await unlockAlertSound(); + }); + it("previews the chime in Settings, after the chime that sounds", () => { const now = made.at(-1)!.currentTime; previewAlertSound(); diff --git a/src/lib/alertSound.ts b/src/lib/alertSound.ts index ada7e99dfd..cd86680d08 100644 --- a/src/lib/alertSound.ts +++ b/src/lib/alertSound.ts @@ -10,9 +10,9 @@ * One chime sounds at a time in a tab. Alerts that come together - several panes finishing at * once - would otherwise sound over each other. An alert that comes while a chime sounds is * already told by it, except a question after a finish: the question's chime starts where the - * finish's ends, so a question is never lost. Open tabs of the app do not take turns: each - * chimes for an alert they all hear, since a tab cannot tell which alert another tab's chime - * was for, and staying quiet on a guess could leave a question told by no tab. + * finish's ends, so a question is never lost. `alertTurns.ts` coordinates eligible tabs by + * machine, pane and kind. A question deferred to another tab stays pending until that tab + * confirms it chimed, or a local deadline/withdrawal lets this tab tell it instead. */ export type AlertSoundKind = "blocked" | "done"; @@ -29,7 +29,7 @@ const PEAK_GAIN = 0.25; let context: AudioContext | null = null; // this tab's chimes that have not ended, on its context's clock: when the last of them ends, // and when the last question's does (a preview queued behind a question does not move that) -let sounding: { audio: AudioContext; until: number; question: number } | null = null; +let sounding: { audio: AudioContext; until: number; question: number; questionStart: number } | null = null; function contextClass(): typeof AudioContext | undefined { return globalThis.AudioContext ?? (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; @@ -53,10 +53,25 @@ export async function unlockAlertSound(): Promise { return context.state === "running"; } -/** An alert: chimes, unless a chime this tab is playing already tells it. */ -export function playAlertSound(kind: AlertSoundKind): void { +export function canPlayAlertSound(): boolean { + return context?.state === "running"; +} + +/** Confirms only audio that started, not a question scheduled behind a finish or preview. */ +export function playAlertSound(kind: AlertSoundKind): boolean | Promise { const audio = context; - if (audio?.state === "running") chime(audio, kind); + if (audio?.state !== "running") return false; + const start = chime(audio, kind); + if (start <= audio.currentTime) return true; + return new Promise((resolve) => { + const check = () => { + if (audio.state !== "running" || context !== audio) { resolve(false); return; } + const remaining = start - audio.currentTime; + if (remaining <= 0) resolve(true); + else setTimeout(check, Math.ceil(remaining * 1_000)); + }; + check(); + }); } /** Settings' preview of the chime: always played, after a chime of this tab that still sounds. */ @@ -66,11 +81,11 @@ export function previewAlertSound(): void { } /** Plays `kind` unless this tab's last chime still sounds; a preview then starts where it ends. */ -function chime(audio: AudioContext, kind: AlertSoundKind, preview = false): void { +function chime(audio: AudioContext, kind: AlertSoundKind, preview = false): number { const now = audio.currentTime; const current = sounding !== null && sounding.audio === audio && sounding.until > now ? sounding : null; // already told by a chime that sounds or waits its turn: a finish by any, a question by a question's - if (current && !preview && (kind === "done" || current.question > now)) return; + if (current && !preview && (kind === "done" || current.question > now)) return kind === "blocked" ? Math.max(now, current.questionStart) : now; const start = current ? current.until : now; const notes = CHIME_NOTES[kind]; notes.forEach((frequency, index) => { @@ -88,5 +103,10 @@ function chime(audio: AudioContext, kind: AlertSoundKind, preview = false): void oscillator.stop(at + NOTE_LENGTH_S); }); const until = start + (notes.length - 1) * NOTE_GAP_S + NOTE_LENGTH_S; - sounding = { audio, until, question: kind === "blocked" && !preview ? until : current?.question ?? 0 }; + sounding = { + audio, until, + question: kind === "blocked" && !preview ? until : current?.question ?? 0, + questionStart: kind === "blocked" && !preview ? start : current?.questionStart ?? 0, + }; + return start; } diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts new file mode 100644 index 0000000000..692ca74e0b --- /dev/null +++ b/src/lib/alertTurns.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "bun:test"; +import { AlertTurns, createAlertTurnPlayer, type AlertTurnMessage } from "./alertTurns.ts"; + +const key = JSON.stringify(["local", "pane-1", "blocked"]); +const claim: AlertTurnMessage = { type: "claim", tab: "a", key, kind: "blocked" }; +const chimed: AlertTurnMessage = { ...claim, type: "chimed" }; +const play = { type: "play", key, kind: "blocked" } as const; + +describe("alert turns with a local clock", () => { + it("lets a lone tab chime after its claim window", () => { + const tab = new AlertTurns("b"); + expect(tab.start(key, "blocked", 0)).toEqual([{ ...claim, tab: "b" }]); + expect(tab.tick(149)).toEqual([]); + expect(tab.tick(150)).toEqual([play]); + }); + + it("lets the lower of two claimants chime and the other defer", () => { + const a = new AlertTurns("a"); + const b = new AlertTurns("b"); + a.start(key, "blocked", 0); + b.start(key, "blocked", 0); + a.receive({ ...claim, tab: "b" }, 1); + b.receive(claim, 1); + expect(a.tick(150)).toEqual([play]); + expect(b.tick(150)).toEqual([]); + b.receive(a.finish(key, true, 150)[0]!, 151); + expect(b.tick(750)).toEqual([]); + }); + + it("drops an alert already chimed within the local lookback", () => { + const tab = new AlertTurns("b"); + tab.receive(chimed, 100); + expect(tab.start(key, "blocked", 200)).toEqual([]); + expect(tab.tick(350)).toEqual([]); + }); + + it("rescues a question when the winning tab never chimes", () => { + const tab = new AlertTurns("b"); + tab.start(key, "blocked", 0); + tab.receive(claim, 1); + expect(tab.tick(150)).toEqual([]); + expect(tab.tick(749)).toEqual([]); + expect(tab.tick(750)).toEqual([play]); + }); + + it("redecides a pending question immediately when its winner withdraws", () => { + const tab = new AlertTurns("b"); + tab.start(key, "blocked", 0); + tab.receive(claim, 1); + tab.tick(150); + expect(tab.receive({ ...claim, type: "withdraw" }, 200)).toEqual([play]); + }); + + it("does not give a finish a question safety net", () => { + const tab = new AlertTurns("b"); + tab.start(key, "done", 0); + tab.receive({ ...claim, kind: "done" }, 1); + expect(tab.tick(150)).toEqual([]); + expect(tab.tick(750)).toEqual([]); + expect(tab.receive({ ...claim, type: "withdraw", kind: "done" }, 800)).toEqual([]); + }); + + it("ignores claims older than the lookback", () => { + const tab = new AlertTurns("b"); + tab.receive(claim, 0); + tab.start(key, "blocked", 1501); + expect(tab.tick(1651)).toEqual([play]); + }); + + it("allows the same pane to alert again after the lookback", () => { + const tab = new AlertTurns("b"); + tab.receive(chimed, 0); + tab.start(key, "blocked", 1501); + expect(tab.tick(1651)).toEqual([play]); + }); + + it("honours a received chime even when its own timer runs very late", () => { + const tab = new AlertTurns("b"); + tab.start(key, "blocked", 0); + tab.receive(chimed, 100); + expect(tab.tick(10_000)).toEqual([]); + }); + + it("cancels the safety net when a chime arrives", () => { + const tab = new AlertTurns("b"); + tab.start(key, "blocked", 0); + tab.receive(claim, 1); + tab.tick(150); + tab.receive(chimed, 749); + expect(tab.tick(10_000)).toEqual([]); + }); + + it("keeps other machines and panes independent", () => { + const tab = new AlertTurns("b"); + tab.receive(chimed, 0); + tab.start("another-machine-pane", "blocked", 1); + expect(tab.tick(151)).toEqual([{ ...play, key: "another-machine-pane" }]); + }); + + it("withdraws rather than claiming success when audio becomes unavailable", () => { + const tab = new AlertTurns("a"); + tab.start(key, "blocked", 0); + tab.tick(150); + expect(tab.finish(key, false, 150)).toEqual([{ ...claim, type: "withdraw" }]); + }); + + it("withdraws and forgets live work when the page leaves", () => { + const tab = new AlertTurns("a"); + tab.start(key, "blocked", 0); + expect(tab.clear()).toEqual([{ ...claim, type: "withdraw" }]); + expect(tab.tick(10_000)).toEqual([]); + expect(tab.start(key, "blocked", 10_001)).toEqual([claim]); + }); + + it("chimes immediately when BroadcastChannel is unavailable", () => { + const heard: string[] = []; + const player = createAlertTurnPlayer({ channel: null, play: (kind) => { heard.push(kind); return true; } }); + player.chime(key, "blocked"); + expect(heard).toEqual(["blocked"]); + player.dispose(); + }); +}); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts new file mode 100644 index 0000000000..cbb1e68c5d --- /dev/null +++ b/src/lib/alertTurns.ts @@ -0,0 +1,187 @@ +import type { AlertSoundKind } from "./alertSound.ts"; + +const WINDOW_MS = 150; +const LOOKBACK_MS = 1_500; +const RESCUE_MS = 600; +const CHANNEL = "herdr-web-ui:alert-turns"; + +export interface AlertTurnMessage { + type: "claim" | "chimed" | "withdraw"; + tab: string; + key: string; + kind: AlertSoundKind; +} +interface Play { type: "play"; key: string; kind: AlertSoundKind } +interface Pending { kind: AlertSoundKind; due: number; rescue: boolean } + +/** Decision state only. Every time is supplied by this tab, never by a sender. */ +export class AlertTurns { + private claims = new Map>(); + private chimed = new Map(); + private pending = new Map(); + constructor(private tab: string) {} + + start(key: string, kind: AlertSoundKind, now: number): AlertTurnMessage[] { + this.prune(now); + if (this.pending.has(key) || this.chimed.has(key)) return []; + this.pending.set(key, { kind, due: now + WINDOW_MS, rescue: false }); + return [{ type: "claim", tab: this.tab, key, kind }]; + } + + receive(message: AlertTurnMessage, now: number): Play[] { + this.prune(now); + if (message.tab === this.tab) return []; + switch (message.type) { + case "claim": { + const claims = this.claims.get(message.key) ?? new Map(); + claims.set(message.tab, now); + this.claims.set(message.key, claims); + return []; + } + case "chimed": + this.chimed.set(message.key, now); + // Cancel the actual pending turn, not just its short-lived lookback. A suspended + // tab may not get its timer back until long after the lookback has expired. + this.pending.delete(message.key); + return []; + case "withdraw": { + this.claims.get(message.key)?.delete(message.tab); + const pending = this.pending.get(message.key); + if (pending?.rescue && Number.isFinite(pending.due) && !this.lowerClaim(message.key)) pending.due = now; + return this.tick(now); + } + } + } + + tick(now: number): Play[] { + this.prune(now); + const plays: Play[] = []; + for (const [key, pending] of this.pending) { + if (pending.due > now) continue; + if (!pending.rescue && this.lowerClaim(key)) { + if (pending.kind === "done") this.pending.delete(key); + else { pending.rescue = true; pending.due = now + RESCUE_MS; } + continue; + } + pending.due = Infinity; // awaiting the local player's result + plays.push({ type: "play", key, kind: pending.kind }); + } + return plays; + } + + finish(key: string, played: boolean, now: number): AlertTurnMessage[] { + this.prune(now); + const pending = this.pending.get(key); + if (!pending) return []; + this.pending.delete(key); + return [{ type: played ? "chimed" : "withdraw", tab: this.tab, key, kind: pending.kind }]; + } + + clear(): AlertTurnMessage[] { + const messages: AlertTurnMessage[] = [...this.pending].map(([key, pending]) => + ({ type: "withdraw", tab: this.tab, key, kind: pending.kind })); + this.pending.clear(); + this.claims.clear(); + this.chimed.clear(); + return messages; + } + + get deadline(): number { + return Math.min(...[...this.pending.values()].map((pending) => pending.due)); + } + + private lowerClaim(key: string): boolean { + return [...(this.claims.get(key)?.keys() ?? [])].some((tab) => tab < this.tab); + } + + private prune(now: number): void { + for (const [key, claims] of this.claims) { + for (const [tab, at] of claims) if (now - at > LOOKBACK_MS) claims.delete(tab); + if (claims.size === 0) this.claims.delete(key); + } + for (const [key, at] of this.chimed) if (now - at > LOOKBACK_MS) this.chimed.delete(key); + } +} + +function isMessage(value: unknown): value is AlertTurnMessage { + if (!value || typeof value !== "object") return false; + return "type" in value && ["claim", "chimed", "withdraw"].includes(String(value.type)) + && "tab" in value && typeof value.tab === "string" + && "key" in value && typeof value.key === "string" + && "kind" in value && (value.kind === "blocked" || value.kind === "done"); +} + +/** Browser wiring. No Web Locks or secure-context-only UUID API: LAN HTTP works too. */ +export function createAlertTurnPlayer(options: { + play: (kind: AlertSoundKind) => boolean | Promise; + channel?: BroadcastChannel | null; +}) { + const turns = new AlertTurns([...crypto.getRandomValues(new Uint32Array(4))].join("-")); + const open = () => options.channel !== undefined ? options.channel + : typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL); + let channel = open(); + let timer: ReturnType | undefined; + let active = true; + const playing = new Map(); + const send = (messages: AlertTurnMessage[]) => { + for (const message of messages) channel?.postMessage(message); + }; + const schedule = () => { + clearTimeout(timer); + if (Number.isFinite(turns.deadline)) timer = setTimeout(() => run(turns.tick(performance.now())), Math.max(0, turns.deadline - performance.now())); + }; + const run = (plays: Play[]) => { + for (const { key, kind } of plays) { + const attempt = {}; + playing.set(key, attempt); + const finish = (played: boolean) => { + // A queued question can start later. Its result belongs to this claim only, + // never to a new claim for the same pane after pagehide or another chime. + if (playing.get(key) !== attempt) return; + playing.delete(key); + send(turns.finish(key, played, performance.now())); + }; + const result = options.play(kind); + if (typeof result === "boolean") finish(result); + else void result.then(finish); + } + schedule(); + }; + const listen = () => { + if (channel) channel.onmessage = (event: MessageEvent) => { + if (!isMessage(event.data)) return; + if (event.data.type === "chimed") playing.delete(event.data.key); + run(turns.receive(event.data, performance.now())); + }; + }; + listen(); + const hide = () => { + active = false; + clearTimeout(timer); + playing.clear(); + send(turns.clear()); + channel?.close(); + channel = null; + }; + const show = () => { + if (active) return; + active = true; + channel = open(); + listen(); + }; + globalThis.addEventListener?.("pagehide", hide); + globalThis.addEventListener?.("pageshow", show); + return { + chime(key: string, kind: AlertSoundKind) { + if (!active) return; + if (!channel) { options.play(kind); return; } + send(turns.start(key, kind, performance.now())); + schedule(); + }, + dispose() { + hide(); + globalThis.removeEventListener?.("pagehide", hide); + globalThis.removeEventListener?.("pageshow", show); + }, + }; +} From cdf69b5df4a8587dd554b733f3d6f509c4015bf1 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 12:37:33 +0900 Subject: [PATCH 2/9] test(alerts): pace the alert-sound primer on its own status events --- scripts/alert-sound-regression.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/alert-sound-regression.ts b/scripts/alert-sound-regression.ts index 0071fda810..5da35fdaa1 100644 --- a/scripts/alert-sound-regression.ts +++ b/scripts/alert-sound-regression.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { Browser, Page } from "playwright-core"; +import { errors as browserErrors, type Browser, type Page } from "playwright-core"; import { herdrRpc, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; import { openSettingsPage } from "./settings-page.ts"; @@ -108,11 +108,19 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise // The collector reopens its subscriptions after the pane set changes. Prime only owned // fixture statuses until working arrives as an event, not merely in a polled snapshot. + // Each attempt waits (bounded) for its own event before reporting again, so setup does not + // flood the subscription it is priming. for (const deadline = Date.now() + 10_000; !(await page.evaluate((pane) => (window as unknown as { soundStatuses: Record }).soundStatuses[pane] === "working", otherPane));) { assert.ok(Date.now() < deadline, "the status subscription includes the new panes"); await report(otherPane, "idle"); + const received = page.waitForFunction((pane) => + (window as unknown as { soundStatuses: Record }).soundStatuses[pane] === "working", + otherPane, { timeout: Math.max(1, Math.min(1_000, deadline - Date.now())) }).catch((error) => { + if (!(error instanceof browserErrors.TimeoutError)) throw error; + }); await report(otherPane, "working"); + await received; } // no tap yet: the page may not play, and nothing is kept to sound later await block(otherPane); From b323cc16d37c72cd8234223af893d97971e62016 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 12:38:22 +0900 Subject: [PATCH 3/9] docs(changelog): link #632 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1073ded7e..091498b50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ### Fixed - Open tabs take turns chiming for the same alert. A waiting question is still heard if the tab that claimed it closes or never plays it, including on plain-HTTP LAN addresses. + ([#632](https://github.com/devswha/herdr-web-ui/pull/632)) - Chat reads the transcript of an omp started with `--profile `, whose sessions live under `~/.omp/profiles//agent/sessions`; such panes showed only the terminal text. ([#618](https://github.com/devswha/herdr-web-ui/pull/618) by @frankjoshua) From bbb9f9f0582ba11630794283df22a2fcd7529469 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 12:41:56 +0900 Subject: [PATCH 4/9] fix(alerts): a repeat question and a third tab after the cross-tab review --- src/lib/alertTurns.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/alertTurns.ts | 20 ++++++++++++++++---- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index 692ca74e0b..40be7789b0 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -90,6 +90,36 @@ describe("alert turns with a local clock", () => { expect(tab.tick(10_000)).toEqual([]); }); + it("tells a second question on the same pane that comes after the first one's chime", () => { + const tab = new AlertTurns("b"); + tab.receive(claim, 1); + tab.receive(chimed, 151); + // the tab that chimed the first question no longer takes part (its pane is in front now) + expect(tab.start(key, "blocked", 800)).toEqual([{ ...claim, tab: "b" }]); + expect(tab.tick(950)).toEqual([play]); + }); + + it("lets a third tab wait for the second one's rescue instead of chiming with it", () => { + const c = new AlertTurns("c"); + c.start(key, "blocked", 0); + c.receive(claim, 1); + c.receive({ ...claim, tab: "b" }, 1); + expect(c.tick(150)).toEqual([]); + expect(c.tick(750)).toEqual([]); + c.receive({ ...chimed, tab: "b" }, 760); + expect(c.tick(10_000)).toEqual([]); + }); + + it("still rescues a question when every lower tab is gone", () => { + const c = new AlertTurns("c"); + c.start(key, "blocked", 0); + c.receive(claim, 1); + c.receive({ ...claim, tab: "b" }, 1); + c.tick(150); + expect(c.tick(1_349)).toEqual([]); + expect(c.tick(1_350)).toEqual([play]); + }); + it("keeps other machines and panes independent", () => { const tab = new AlertTurns("b"); tab.receive(chimed, 0); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index cbb1e68c5d..62fc83c51f 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -3,6 +3,8 @@ import type { AlertSoundKind } from "./alertSound.ts"; const WINDOW_MS = 150; const LOOKBACK_MS = 1_500; const RESCUE_MS = 600; +/** a chime another tab played this long before this tab heard the alert was for the same one */ +const CHIMED_BEFORE_MS = 400; const CHANNEL = "herdr-web-ui:alert-turns"; export interface AlertTurnMessage { @@ -23,7 +25,10 @@ export class AlertTurns { start(key: string, kind: AlertSoundKind, now: number): AlertTurnMessage[] { this.prune(now); - if (this.pending.has(key) || this.chimed.has(key)) return []; + // Only a chime just before this alert told it: the same pane can ask again soon after, + // when the tab that chimed the first question no longer takes part. + const chimedAt = this.chimed.get(key); + if (this.pending.has(key) || (chimedAt !== undefined && now - chimedAt <= CHIMED_BEFORE_MS)) return []; this.pending.set(key, { kind, due: now + WINDOW_MS, rescue: false }); return [{ type: "claim", tab: this.tab, key, kind }]; } @@ -40,6 +45,8 @@ export class AlertTurns { } case "chimed": this.chimed.set(message.key, now); + // that alert is settled: its claims must not defer the pane's next one + this.claims.delete(message.key); // Cancel the actual pending turn, not just its short-lived lookback. A suspended // tab may not get its timer back until long after the lookback has expired. this.pending.delete(message.key); @@ -60,7 +67,8 @@ export class AlertTurns { if (pending.due > now) continue; if (!pending.rescue && this.lowerClaim(key)) { if (pending.kind === "done") this.pending.delete(key); - else { pending.rescue = true; pending.due = now + RESCUE_MS; } + // each lower claimant gets its own turn first, so two deferring tabs never rescue together + else { pending.rescue = true; pending.due = now + RESCUE_MS * this.lowerClaims(key); } continue; } pending.due = Infinity; // awaiting the local player's result @@ -91,7 +99,11 @@ export class AlertTurns { } private lowerClaim(key: string): boolean { - return [...(this.claims.get(key)?.keys() ?? [])].some((tab) => tab < this.tab); + return this.lowerClaims(key) > 0; + } + + private lowerClaims(key: string): number { + return [...(this.claims.get(key)?.keys() ?? [])].filter((tab) => tab < this.tab).length; } private prune(now: number): void { @@ -105,7 +117,7 @@ export class AlertTurns { function isMessage(value: unknown): value is AlertTurnMessage { if (!value || typeof value !== "object") return false; - return "type" in value && ["claim", "chimed", "withdraw"].includes(String(value.type)) + return "type" in value && typeof value.type === "string" && ["claim", "chimed", "withdraw"].includes(value.type) && "tab" in value && typeof value.tab === "string" && "key" in value && typeof value.key === "string" && "kind" in value && (value.kind === "blocked" || value.kind === "done"); From 76450d2fc910aea4e22cdaf6dcee6d0f564758f1 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 12:44:55 +0900 Subject: [PATCH 5/9] fix(alerts): a late duplicate chime does not silence the next question --- src/lib/alertTurns.test.ts | 12 ++++++++++++ src/lib/alertTurns.ts | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index 40be7789b0..2ea7b6ff1d 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -99,6 +99,18 @@ describe("alert turns with a local clock", () => { expect(tab.tick(950)).toEqual([play]); }); + it("is not silenced by a late duplicate of the chime it played itself", () => { + const a = new AlertTurns("a"); + a.start(key, "blocked", 0); + expect(a.tick(150)).toEqual([play]); + a.finish(key, true, 150); + // tab b heard the first question late and chimed it again + a.receive({ ...chimed, tab: "b" }, 1_100); + // the pane asks again; b now has it in front, so a is the only tab to tell it + expect(a.start(key, "blocked", 1_480)).toEqual([claim]); + expect(a.tick(1_630)).toEqual([play]); + }); + it("lets a third tab wait for the second one's rescue instead of chiming with it", () => { const c = new AlertTurns("c"); c.start(key, "blocked", 0); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index 62fc83c51f..8c963642ae 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -21,6 +21,8 @@ export class AlertTurns { private claims = new Map>(); private chimed = new Map(); private pending = new Map(); + /** when this tab last played each alert: another tab's chime of it soon after is a late duplicate */ + private played = new Map(); constructor(private tab: string) {} start(key: string, kind: AlertSoundKind, now: number): AlertTurnMessage[] { @@ -44,6 +46,9 @@ export class AlertTurns { return []; } case "chimed": + // A tab that heard the alert late chimed it again after this one did. That duplicate + // tells nothing new, and taking it for a chime could silence the pane's next question. + if (this.played.has(message.key)) return []; this.chimed.set(message.key, now); // that alert is settled: its claims must not defer the pane's next one this.claims.delete(message.key); @@ -82,6 +87,7 @@ export class AlertTurns { const pending = this.pending.get(key); if (!pending) return []; this.pending.delete(key); + if (played) this.played.set(key, now); return [{ type: played ? "chimed" : "withdraw", tab: this.tab, key, kind: pending.kind }]; } @@ -91,6 +97,7 @@ export class AlertTurns { this.pending.clear(); this.claims.clear(); this.chimed.clear(); + this.played.clear(); return messages; } @@ -112,6 +119,7 @@ export class AlertTurns { if (claims.size === 0) this.claims.delete(key); } for (const [key, at] of this.chimed) if (now - at > LOOKBACK_MS) this.chimed.delete(key); + for (const [key, at] of this.played) if (now - at > LOOKBACK_MS) this.played.delete(key); } } From 18ee46a9431c662fd2ab4785156fc1de689914b7 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 13:53:26 +0900 Subject: [PATCH 6/9] fix(alerts): a peer's late chime does not leave this tab's queued question stuck A peer's chimed message that the tab ignored as a late duplicate still dropped the tab's own playback attempt, so its result never cleared the pending alert and every later alert of that pane and kind was refused in that tab. Drop the attempt only when the chime settled the alert. --- src/lib/alertTurns.test.ts | 32 +++++++++++++++++++++++++++++++- src/lib/alertTurns.ts | 11 +++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index 2ea7b6ff1d..7f11aac40d 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, jest } from "bun:test"; import { AlertTurns, createAlertTurnPlayer, type AlertTurnMessage } from "./alertTurns.ts"; const key = JSON.stringify(["local", "pane-1", "blocked"]); @@ -161,4 +161,34 @@ describe("alert turns with a local clock", () => { expect(heard).toEqual(["blocked"]); player.dispose(); }); + it("still tells the pane's next question after a peer's chime came while its own was queued", async () => { + jest.useFakeTimers(); + const channel = { onmessage: null as ((event: MessageEvent) => void) | null, postMessage() {}, close() {} }; + const heard: string[] = []; + let release: (played: boolean) => void = () => {}; + const results: (boolean | Promise)[] = [true, new Promise((resolve) => { release = resolve; }), true]; + const player = createAlertTurnPlayer({ + channel: channel as unknown as BroadcastChannel, + play: (kind) => { heard.push(kind); return results.shift() ?? true; }, + }); + try { + player.chime(key, "blocked"); + jest.advanceTimersByTime(150); + // the pane asks again; this chime waits behind another sound of the tab + player.chime(key, "blocked"); + jest.advanceTimersByTime(150); + expect(heard).toEqual(["blocked", "blocked"]); + // another tab's chime of the first question arrives meanwhile + channel.onmessage?.({ data: { ...chimed, tab: "b" } } as MessageEvent); + release(true); + await Promise.resolve(); + jest.advanceTimersByTime(2_000); + player.chime(key, "blocked"); + jest.advanceTimersByTime(150); + expect(heard).toEqual(["blocked", "blocked", "blocked"]); + } finally { + player.dispose(); + jest.useRealTimers(); + } + }); }); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index 8c963642ae..a9de76b6d6 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -101,6 +101,10 @@ export class AlertTurns { return messages; } + has(key: string): boolean { + return this.pending.has(key); + } + get deadline(): number { return Math.min(...[...this.pending.values()].map((pending) => pending.due)); } @@ -170,8 +174,11 @@ export function createAlertTurnPlayer(options: { const listen = () => { if (channel) channel.onmessage = (event: MessageEvent) => { if (!isMessage(event.data)) return; - if (event.data.type === "chimed") playing.delete(event.data.key); - run(turns.receive(event.data, performance.now())); + const plays = turns.receive(event.data, performance.now()); + // Only a chime that settled the alert ends this tab's attempt. An ignored late duplicate + // leaves it, or its result could never clear the pending alert and the pane went silent. + if (event.data.type === "chimed" && !turns.has(event.data.key)) playing.delete(event.data.key); + run(plays); }; }; listen(); From 1efe181b37e4582a90a894663323ad45b99bcb7d Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 13:55:10 +0900 Subject: [PATCH 7/9] fix(alerts): a finish the winning tab cannot play is chimed by another tab A tab that deferred a finish to a lower tab dropped it at the claim deadline, so a winner whose audio did not start (suspended, sound turned off meanwhile) or that froze left the finish unheard in every tab. A finish now waits like a question: a withdraw redecides at once and the 600 ms rescue plays it if no chime is confirmed. A winner confirms a finish as soon as it plays, so the rescue does not double it. --- CHANGELOG.md | 4 ++-- src/lib/alertSound.ts | 2 +- src/lib/alertTurns.test.ts | 32 +++++++++++++++++++++++++++++--- src/lib/alertTurns.ts | 7 ++++--- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091498b50f..1ea057e954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Fixed -- Open tabs take turns chiming for the same alert. A waiting question is still heard if the - tab that claimed it closes or never plays it, including on plain-HTTP LAN addresses. +- Open tabs take turns chiming for the same alert. A question or a finish is still heard if + the tab that claimed it closes or cannot play it, including on plain-HTTP LAN addresses. ([#632](https://github.com/devswha/herdr-web-ui/pull/632)) - Chat reads the transcript of an omp started with `--profile `, whose sessions live under `~/.omp/profiles//agent/sessions`; such panes showed only the terminal text. diff --git a/src/lib/alertSound.ts b/src/lib/alertSound.ts index cd86680d08..e36a373401 100644 --- a/src/lib/alertSound.ts +++ b/src/lib/alertSound.ts @@ -11,7 +11,7 @@ * once - would otherwise sound over each other. An alert that comes while a chime sounds is * already told by it, except a question after a finish: the question's chime starts where the * finish's ends, so a question is never lost. `alertTurns.ts` coordinates eligible tabs by - * machine, pane and kind. A question deferred to another tab stays pending until that tab + * machine, pane and kind. An alert deferred to another tab stays pending until that tab * confirms it chimed, or a local deadline/withdrawal lets this tab tell it instead. */ diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index 7f11aac40d..f9dc76931d 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -51,13 +51,39 @@ describe("alert turns with a local clock", () => { expect(tab.receive({ ...claim, type: "withdraw" }, 200)).toEqual([play]); }); - it("does not give a finish a question safety net", () => { + it("chimes a finish in another tab when the winning tab cannot play it", () => { + const a = new AlertTurns("a"); + const b = new AlertTurns("b"); + a.start(key, "done", 0); + b.start(key, "done", 0); + a.receive({ ...claim, tab: "b", kind: "done" }, 1); + b.receive({ ...claim, kind: "done" }, 1); + expect(a.tick(150)).toEqual([{ ...play, kind: "done" }]); + expect(b.tick(150)).toEqual([]); + // a's audio did not start (suspended, or its sound was turned off meanwhile) + expect(b.receive(a.finish(key, false, 160)[0]!, 161)).toEqual([{ ...play, kind: "done" }]); + }); + + it("chimes a finish once when the winning tab played it", () => { + const a = new AlertTurns("a"); + const b = new AlertTurns("b"); + a.start(key, "done", 0); + b.start(key, "done", 0); + a.receive({ ...claim, tab: "b", kind: "done" }, 1); + b.receive({ ...claim, kind: "done" }, 1); + expect(a.tick(150)).toEqual([{ ...play, kind: "done" }]); + expect(b.tick(150)).toEqual([]); + b.receive(a.finish(key, true, 160)[0]!, 161); + expect(b.tick(10_000)).toEqual([]); + }); + + it("rescues a finish when the winning tab never answers", () => { const tab = new AlertTurns("b"); tab.start(key, "done", 0); tab.receive({ ...claim, kind: "done" }, 1); expect(tab.tick(150)).toEqual([]); - expect(tab.tick(750)).toEqual([]); - expect(tab.receive({ ...claim, type: "withdraw", kind: "done" }, 800)).toEqual([]); + expect(tab.tick(749)).toEqual([]); + expect(tab.tick(750)).toEqual([{ ...play, kind: "done" }]); }); it("ignores claims older than the lookback", () => { diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index a9de76b6d6..23b2dd5cdc 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -71,9 +71,10 @@ export class AlertTurns { for (const [key, pending] of this.pending) { if (pending.due > now) continue; if (!pending.rescue && this.lowerClaim(key)) { - if (pending.kind === "done") this.pending.delete(key); - // each lower claimant gets its own turn first, so two deferring tabs never rescue together - else { pending.rescue = true; pending.due = now + RESCUE_MS * this.lowerClaims(key); } + // A finish waits like a question: the winning tab may not be able to play it. + // Each lower claimant gets its own turn first, so two deferring tabs never rescue together. + pending.rescue = true; + pending.due = now + RESCUE_MS * this.lowerClaims(key); continue; } pending.due = Infinity; // awaiting the local player's result From 7c402737228ba71ace983d9716c7286837f63bea Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 14:57:32 +0900 Subject: [PATCH 8/9] fix(alerts): a slow winner or the pane's next question is not chimed twice CI run 37886117774 chimed one question twice across two tabs. The winner now posts 'playing' before its audio starts, and a tab deferring that alert waits for its 'chimed' or 'withdraw' (up to 5 s) instead of rescuing at 600 ms, so a winner whose audio waits behind another chime is not chimed over. A 'chimed' also settles an alert this tab is waiting on even when it chimed the pane's previous one under 1.5 s ago; it is ignored as a late duplicate only when nothing is pending. The browser regression holds every 'chimed' back by a second and expects one chime; it failed on 1efe181. --- scripts/alert-sound-regression.ts | 24 +++++++++++++ src/lib/alertTurns.test.ts | 57 +++++++++++++++++++++++++++++++ src/lib/alertTurns.ts | 30 +++++++++++++--- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/scripts/alert-sound-regression.ts b/scripts/alert-sound-regression.ts index 5da35fdaa1..1eb4330997 100644 --- a/scripts/alert-sound-regression.ts +++ b/scripts/alert-sound-regression.ts @@ -55,6 +55,15 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise } } Object.assign(window, { AudioContext: RecordingAudioContext }); + // `slowChimed` makes this tab confirm its chimes late, as one whose audio waits behind another + const post = BroadcastChannel.prototype.postMessage; + BroadcastChannel.prototype.postMessage = function (message: unknown) { + if ((window as unknown as { slowChimed?: boolean }).slowChimed && (message as { type?: string } | null)?.type === "chimed") { + setTimeout(() => post.call(this, message), 1_000); + return; + } + post.call(this, message); + }; // A visible working badge can come from a roster snapshot before the collector has // subscribed to these newly created panes. Observe a real status event before testing alerts. const statuses: Record = {}; @@ -240,6 +249,21 @@ export async function checkAlertSound(browser: Browser, origin: string): Promise assert.deepEqual(await counts(), [frontBefore[0], frontBefore[1]! + 2], "tab B tells a question in front of tab A"); console.log("PASS the other tab tells a question in front of the first tab"); + // The winning tab's confirmation comes a second late: the other tab waits for it instead of + // chiming the same question again. + await Promise.all(tabs.map(quiet)); + await report(thirdPane, "working"); + await Promise.all(tabs.map((tab) => seen(thirdPane, "working", tab))); + await Promise.all(tabs.map((tab) => tab.evaluate(() => { (window as unknown as { slowChimed: boolean }).slowChimed = true; }))); + const slowBefore = await counts(); + await report(thirdPane, "blocked"); + await Promise.all(tabs.map((tab) => seen(thirdPane, "blocked", tab))); + await Promise.any(tabs.map((tab, index) => tab.waitForFunction((count) => + (window as unknown as { chimes: number[] }).chimes.length > count, slowBefore[index], { timeout: 5_000 }))); + await noSecondChime(slowBefore); + assert.equal((await heard()) - slowBefore.reduce((sum, count) => sum + count, 0), 2, "one chime when the winner confirms late"); + console.log("PASS a winner that confirms late is not chimed over by the other tab"); + assert.deepEqual(errors, []); } finally { await context.close(); diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index f9dc76931d..2b1c7af9ce 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -137,6 +137,63 @@ describe("alert turns with a local clock", () => { expect(a.tick(1_630)).toEqual([play]); }); + it("takes another tab's chime of the pane's next question although it chimed the first one itself", () => { + const a = new AlertTurns("a"); + const b = new AlertTurns("b"); + // b alone chimed the pane's first question + b.start(key, "blocked", 0); + expect(b.tick(150)).toEqual([play]); + b.finish(key, true, 150); + // the pane asks again; both tabs take part now, and a is the lower one + a.start(key, "blocked", 800); + b.start(key, "blocked", 800); + a.receive({ ...claim, tab: "b" }, 801); + b.receive(claim, 801); + expect(a.tick(950)).toEqual([play]); + expect(b.tick(950)).toEqual([]); + b.receive(a.finish(key, true, 950)[0]!, 951); + // a told it: b's rescue must not chime it a second time + expect(b.tick(10_000)).toEqual([]); + }); + + it("waits for a winner that is playing, however long its audio waits, and chimes once", () => { + for (const kind of ["blocked", "done"] as const) { + const a = new AlertTurns("a"); + const b = new AlertTurns("b"); + a.start(key, kind, 0); + b.start(key, kind, 0); + a.receive({ ...claim, tab: "b", kind }, 1); + b.receive({ ...claim, kind }, 1); + expect(a.tick(150)).toEqual([{ ...play, kind }]); + expect(b.tick(150)).toEqual([]); + // a's audio waits behind a chime it still plays: it says so before it starts + b.receive(a.announce(key, kind), 151); + expect(b.tick(750)).toEqual([]); + expect(b.tick(1_400)).toEqual([]); + b.receive(a.finish(key, true, 1_400)[0]!, 1_401); + expect(b.tick(10_000)).toEqual([]); + } + }); + + it("still rescues an alert whose winner said it was playing and then went silent", () => { + const b = new AlertTurns("b"); + b.start(key, "done", 0); + b.receive({ ...claim, kind: "done" }, 1); + b.tick(150); + b.receive({ ...claim, type: "playing", kind: "done" }, 151); + expect(b.tick(5_150)).toEqual([]); + expect(b.tick(5_151)).toEqual([{ ...play, kind: "done" }]); + }); + + it("redecides at once when a winner that said it was playing withdraws", () => { + const b = new AlertTurns("b"); + b.start(key, "blocked", 0); + b.receive(claim, 1); + b.tick(150); + b.receive({ ...claim, type: "playing" }, 151); + expect(b.receive({ ...claim, type: "withdraw" }, 300)).toEqual([play]); + }); + it("lets a third tab wait for the second one's rescue instead of chiming with it", () => { const c = new AlertTurns("c"); c.start(key, "blocked", 0); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index 23b2dd5cdc..406a8275a7 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -3,12 +3,14 @@ import type { AlertSoundKind } from "./alertSound.ts"; const WINDOW_MS = 150; const LOOKBACK_MS = 1_500; const RESCUE_MS = 600; +/** a winner that said it plays can wait behind its own chime this long before another tab rescues */ +const PLAYING_HOLD_MS = 5_000; /** a chime another tab played this long before this tab heard the alert was for the same one */ const CHIMED_BEFORE_MS = 400; const CHANNEL = "herdr-web-ui:alert-turns"; export interface AlertTurnMessage { - type: "claim" | "chimed" | "withdraw"; + type: "claim" | "playing" | "chimed" | "withdraw"; tab: string; key: string; kind: AlertSoundKind; @@ -23,6 +25,8 @@ export class AlertTurns { private pending = new Map(); /** when this tab last played each alert: another tab's chime of it soon after is a late duplicate */ private played = new Map(); + /** when another tab said it plays each alert: its chime can wait behind one it still plays */ + private announced = new Map(); constructor(private tab: string) {} start(key: string, kind: AlertSoundKind, now: number): AlertTurnMessage[] { @@ -45,19 +49,29 @@ export class AlertTurns { this.claims.set(message.key, claims); return []; } + case "playing": { + this.announced.set(message.key, now); + const pending = this.pending.get(message.key); + if (pending?.rescue && Number.isFinite(pending.due)) pending.due = Math.max(pending.due, now + PLAYING_HOLD_MS); + return []; + } case "chimed": // A tab that heard the alert late chimed it again after this one did. That duplicate // tells nothing new, and taking it for a chime could silence the pane's next question. - if (this.played.has(message.key)) return []; + // While this tab waits on the pane's next alert, though, a chime is that alert's, or + // ignoring it would let this tab's rescue chime it a second time. + if (this.played.has(message.key) && !this.pending.has(message.key)) return []; this.chimed.set(message.key, now); // that alert is settled: its claims must not defer the pane's next one this.claims.delete(message.key); + this.announced.delete(message.key); // Cancel the actual pending turn, not just its short-lived lookback. A suspended // tab may not get its timer back until long after the lookback has expired. this.pending.delete(message.key); return []; case "withdraw": { this.claims.get(message.key)?.delete(message.tab); + this.announced.delete(message.key); const pending = this.pending.get(message.key); if (pending?.rescue && Number.isFinite(pending.due) && !this.lowerClaim(message.key)) pending.due = now; return this.tick(now); @@ -74,7 +88,7 @@ export class AlertTurns { // A finish waits like a question: the winning tab may not be able to play it. // Each lower claimant gets its own turn first, so two deferring tabs never rescue together. pending.rescue = true; - pending.due = now + RESCUE_MS * this.lowerClaims(key); + pending.due = now + Math.max(RESCUE_MS * this.lowerClaims(key), this.announced.has(key) ? PLAYING_HOLD_MS : 0); continue; } pending.due = Infinity; // awaiting the local player's result @@ -83,6 +97,11 @@ export class AlertTurns { return plays; } + /** Said before this tab's audio starts: a tab waiting on it then waits for its chime or withdrawal. */ + announce(key: string, kind: AlertSoundKind): AlertTurnMessage { + return { type: "playing", tab: this.tab, key, kind }; + } + finish(key: string, played: boolean, now: number): AlertTurnMessage[] { this.prune(now); const pending = this.pending.get(key); @@ -99,6 +118,7 @@ export class AlertTurns { this.claims.clear(); this.chimed.clear(); this.played.clear(); + this.announced.clear(); return messages; } @@ -125,12 +145,13 @@ export class AlertTurns { } for (const [key, at] of this.chimed) if (now - at > LOOKBACK_MS) this.chimed.delete(key); for (const [key, at] of this.played) if (now - at > LOOKBACK_MS) this.played.delete(key); + for (const [key, at] of this.announced) if (now - at > PLAYING_HOLD_MS) this.announced.delete(key); } } function isMessage(value: unknown): value is AlertTurnMessage { if (!value || typeof value !== "object") return false; - return "type" in value && typeof value.type === "string" && ["claim", "chimed", "withdraw"].includes(value.type) + return "type" in value && typeof value.type === "string" && ["claim", "playing", "chimed", "withdraw"].includes(value.type) && "tab" in value && typeof value.tab === "string" && "key" in value && typeof value.key === "string" && "kind" in value && (value.kind === "blocked" || value.kind === "done"); @@ -159,6 +180,7 @@ export function createAlertTurnPlayer(options: { for (const { key, kind } of plays) { const attempt = {}; playing.set(key, attempt); + send([turns.announce(key, kind)]); const finish = (played: boolean) => { // A queued question can start later. Its result belongs to this claim only, // never to a new claim for the same pane after pagehide or another chime. From ba26b9ff0d88944736dfdd19b2795255d8c93957 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 9 Oct 2026 14:59:57 +0900 Subject: [PATCH 9/9] fix(alerts): rescuers keep their turns while a winner that said it plays is awaited With three tabs, the hold on a winner that posted 'playing' gave the second and third tab the same deadline, so both could rescue a silent winner at once. Each lower claimant past the first still waits its own 600 ms turn after the hold. --- src/lib/alertTurns.test.ts | 13 +++++++++++++ src/lib/alertTurns.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/alertTurns.test.ts b/src/lib/alertTurns.test.ts index 2b1c7af9ce..670fe072d2 100644 --- a/src/lib/alertTurns.test.ts +++ b/src/lib/alertTurns.test.ts @@ -185,6 +185,19 @@ describe("alert turns with a local clock", () => { expect(b.tick(5_151)).toEqual([{ ...play, kind: "done" }]); }); + it("keeps a third tab behind the second one's rescue when a winner that said it was playing goes silent", () => { + const c = new AlertTurns("c"); + c.start(key, "blocked", 0); + c.receive(claim, 1); + c.receive({ ...claim, tab: "b" }, 1); + c.tick(150); + c.receive({ ...claim, type: "playing" }, 151); + // b's rescue comes at 5151: c waits one turn longer + expect(c.tick(5_151)).toEqual([]); + expect(c.tick(5_750)).toEqual([]); + expect(c.tick(5_751)).toEqual([play]); + }); + it("redecides at once when a winner that said it was playing withdraws", () => { const b = new AlertTurns("b"); b.start(key, "blocked", 0); diff --git a/src/lib/alertTurns.ts b/src/lib/alertTurns.ts index 406a8275a7..e2d66137c6 100644 --- a/src/lib/alertTurns.ts +++ b/src/lib/alertTurns.ts @@ -52,7 +52,7 @@ export class AlertTurns { case "playing": { this.announced.set(message.key, now); const pending = this.pending.get(message.key); - if (pending?.rescue && Number.isFinite(pending.due)) pending.due = Math.max(pending.due, now + PLAYING_HOLD_MS); + if (pending?.rescue && Number.isFinite(pending.due)) pending.due = Math.max(pending.due, now + this.hold(message.key)); return []; } case "chimed": @@ -88,7 +88,7 @@ export class AlertTurns { // A finish waits like a question: the winning tab may not be able to play it. // Each lower claimant gets its own turn first, so two deferring tabs never rescue together. pending.rescue = true; - pending.due = now + Math.max(RESCUE_MS * this.lowerClaims(key), this.announced.has(key) ? PLAYING_HOLD_MS : 0); + pending.due = now + Math.max(RESCUE_MS * this.lowerClaims(key), this.announced.has(key) ? this.hold(key) : 0); continue; } pending.due = Infinity; // awaiting the local player's result @@ -130,6 +130,11 @@ export class AlertTurns { return Math.min(...[...this.pending.values()].map((pending) => pending.due)); } + /** the wait on a winner that said it plays: each lower claimant past the first still gets its own turn after it */ + private hold(key: string): number { + return PLAYING_HOLD_MS + RESCUE_MS * Math.max(0, this.lowerClaims(key) - 1); + } + private lowerClaim(key: string): boolean { return this.lowerClaims(key) > 0; }