From e8ffc526d305729667acd18090da008caf1b2e72 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 13:38:57 -0700 Subject: [PATCH 1/2] chore(quest): claim capture-exhausted-error Co-authored-by: Grok 4.7 From efa6ef0efdc157bc64f17e89f706d5aec5466d27 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 13:45:31 -0700 Subject: [PATCH 2/2] fix(publish): report an ended capture that exhausts its retry budget Co-authored-by: Grok 4.7 --- js/publish/src/source/camera.ts | 8 +++-- js/publish/src/source/microphone.ts | 8 +++-- js/publish/src/source/retry.test.ts | 50 +++++++++++++++++++++++++++++ js/publish/src/source/retry.ts | 33 ++++++++++++++++--- quest/m1/README.md | 1 - quest/m1/capture-exhausted-error.md | 16 --------- 6 files changed, 90 insertions(+), 26 deletions(-) delete mode 100644 quest/m1/capture-exhausted-error.md diff --git a/js/publish/src/source/camera.ts b/js/publish/src/source/camera.ts index bdccdb6dfb..30e023b818 100644 --- a/js/publish/src/source/camera.ts +++ b/js/publish/src/source/camera.ts @@ -21,7 +21,7 @@ export interface CameraProps extends Inputs { type CameraOutput = { // The live camera track, or undefined while disabled or denied. source: Signal; - /** A terminal getUserMedia failure, cleared when a new capture attempt begins. */ + /** A terminal capture failure, cleared when a new capture attempt begins. */ error: Signal; }; @@ -81,6 +81,10 @@ export class Camera { const constraints = effect.get(this.constraints); if (!this.#retry.begin(effect, [device, constraints])) { + // `failure` is set only when nothing remains, so a backoff stays quiet. + const failure = this.#retry.spent(); + if (failure) this.#out.error.set(failure); + // Waiting out a backoff, or out of budget entirely, with the same settings. Either way // a change to what is plugged in is new information worth acting on now, so watch the // device list here and not while healthy, where a rerun would restart a working capture @@ -139,7 +143,7 @@ export class Camera { // A track that arrives dead already fired "ended", so nothing would ever rerun us. if (!source || source.readyState === "ended") { - this.#retry.failed(); + this.#retry.ended(); return; } diff --git a/js/publish/src/source/microphone.ts b/js/publish/src/source/microphone.ts index 0cce86ed1d..8c97c953fc 100644 --- a/js/publish/src/source/microphone.ts +++ b/js/publish/src/source/microphone.ts @@ -21,7 +21,7 @@ export interface MicrophoneProps extends Inputs { type MicrophoneOutput = { // The live microphone track, or undefined while disabled or denied. source: Signal; - /** A terminal getUserMedia failure, cleared when a new capture attempt begins. */ + /** A terminal capture failure, cleared when a new capture attempt begins. */ error: Signal; }; @@ -69,6 +69,10 @@ export class Microphone { const constraints = effect.get(this.constraints); if (!this.#retry.begin(effect, [device, constraints])) { + // `failure` is set only when nothing remains, so a backoff stays quiet. + const failure = this.#retry.spent(); + if (failure) this.#out.error.set(failure); + // Waiting out a backoff, or out of budget entirely, with the same settings. Either way // a change to what is plugged in is new information worth acting on now, so watch the // device list here and not while healthy, where a rerun would restart a working capture @@ -126,7 +130,7 @@ export class Microphone { // A track that arrives dead already fired "ended", so nothing would ever rerun us. if (!track || track.readyState === "ended") { - this.#retry.failed(); + this.#retry.ended(); return; } diff --git a/js/publish/src/source/retry.test.ts b/js/publish/src/source/retry.test.ts index 7f30c766c8..592ce01d8b 100644 --- a/js/publish/src/source/retry.test.ts +++ b/js/publish/src/source/retry.test.ts @@ -363,6 +363,56 @@ for (const [kind, create] of [ expect(media.attempts).toBe(2); source.close(); }); + + test(`${kind} reports an ended track that exhausts the retry budget`, async () => { + using _time = fakeTime(); + using media = install(new FakeMediaDevices()); + const enabled = new Signal(false); + const source = create(enabled); + try { + // Settle discovery before starting so it cannot refund this attempt's budget. + await settle(); + enabled.set(true); + await settle(); + expect(published(source.out.source.peek())).toBe(media.latest()); + expect(source.out.error.peek()).toBeUndefined(); + + for (let i = 0; i < Retry.LIMIT; i++) { + media.latest().end(); + await settle(); + expect(source.out.error.peek()).toBeUndefined(); + jest.advanceTimersToNextTimer(); + await settle(); + expect(media.attempts).toBe(i + 2); + expect(published(source.out.source.peek())).toBe(media.latest()); + expect(source.out.error.peek()).toBeUndefined(); + } + + media.latest().end(); + await settle(); + expect(media.attempts).toBe(Retry.LIMIT + 1); + expect(source.out.source.peek()).toBeUndefined(); + const error = source.out.error.peek(); + expect(error?.name).toBe("AbortError"); + expect(error?.message).toBe("The media track ended"); + + jest.advanceTimersByTime(Retry.SETTLED); + await settle(); + expect(media.attempts).toBe(Retry.LIMIT + 1); + expect(source.out.error.peek()).toBe(error); + + enabled.set(false); + await settle(); + expect(source.out.error.peek()).toBeUndefined(); + enabled.set(true); + await settle(); + expect(media.attempts).toBe(Retry.LIMIT + 2); + expect(published(source.out.source.peek())).toBe(media.latest()); + expect(source.out.error.peek()).toBeUndefined(); + } finally { + source.close(); + } + }); } test("a microphone re-opens when its track dies", async () => { diff --git a/js/publish/src/source/retry.ts b/js/publish/src/source/retry.ts index 7475afd85f..6e710730ba 100644 --- a/js/publish/src/source/retry.ts +++ b/js/publish/src/source/retry.ts @@ -9,7 +9,9 @@ import { type Effect, Signal } from "@moq/signals"; * * Every outcome reruns the owning effect, including running out of budget. That rerun is what clears * `out.source` and stops the stream, via the cleanup the previous run registered, so no caller has to - * remember to. Screen capture cannot use this: `getDisplayMedia` needs a user gesture to reopen. + * remember to. The same rerun is when {@link spent} holds the last error; the caller assigns it to + * `out.error`. A backoff rerun leaves it empty, so a retry stays quiet. Screen capture cannot use + * this: `getDisplayMedia` needs a user gesture to reopen. */ export class Retry { /** Consecutive failures tolerated before the capture is left alone. */ @@ -37,6 +39,9 @@ export class Retry { // How long the next attempt still owes the backoff, set by `failed` and paid by `begin`. #wait: DOMHighResTimeStamp | undefined; + // The cause of the latest failure. Only surfaced once the budget is spent, so a retry stays quiet. + #error: Error | undefined; + /** * Subscribe the capture effect and report whether an attempt is worth making right now. * @@ -70,7 +75,8 @@ export class Retry { } /** Spends budget and reruns the effect, returning whether another attempt is allowed. */ - failed(): boolean { + failed(error: Error): boolean { + this.#error = error; this.#failures += 1; const retry = this.#failures <= Retry.LIMIT; // Equal jitter, so a page with several captures doesn't reopen them all on the same tick. @@ -80,6 +86,20 @@ export class Retry { return retry; } + /** + * The error from the attempt that spent the budget. + * Undefined while another attempt is allowed, including during backoff. + */ + spent(): Error | undefined { + if (this.#failures <= Retry.LIMIT) return undefined; + return this.#error; + } + + /** Spend budget because the track ended. Reported via {@link spent} once nothing remains. */ + ended(): boolean { + return this.failed(new DOMException("The media track ended", "AbortError")); + } + /** * Classify a `getUserMedia` rejection, returning whether another attempt is allowed. * @@ -87,9 +107,11 @@ export class Retry { * anything else, including bad constraints, is terminal at once. */ rejected(error: unknown): boolean { - if (error instanceof Error && (error.name === "NotReadableError" || error.name === "AbortError")) { - return this.failed(); + const cause = error instanceof Error ? error : new Error(String(error)); + if (cause.name === "NotReadableError" || cause.name === "AbortError") { + return this.failed(cause); } + this.#error = cause; this.terminal(); return false; } @@ -107,7 +129,7 @@ export class Retry { this.#clear(); }, Retry.SETTLED); - effect.event(track, "ended", () => this.failed()); + effect.event(track, "ended", () => this.ended()); } /** Refund the budget, because something changed that makes another attempt worth trying. */ @@ -123,5 +145,6 @@ export class Retry { this.#failures = 0; this.#wait = undefined; this.#delay = Retry.DELAY.initial; + this.#error = undefined; } } diff --git a/quest/m1/README.md b/quest/m1/README.md index d3e584885e..a6e6e1a47e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -50,7 +50,6 @@ blocks. The quests that gated m0 lines moved under them. - [Delete the live marker](/quest/m1/announce-live-removal.md) - announce streams yield only route events; the `Live` marker and its hold machinery are gone - [WebTransport close backends](/quest/m1/wt-close-backends.md) - web-transport-noq and -quinn keep the session alive while closing, and iroh's client reads a capsule close - [Enabled flag](/quest/m1/catalog-enabled.md) - one `enabled` rendition flag replaces `stalled` and pausing, and the flapping encoder-lag detector is deleted -- [Spent capture budget errors](/quest/m1/capture-exhausted-error.md) - a camera or microphone that runs out of retries sets `out.error` instead of failing silently - [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - every stream group in a lite subscription arrives or is dropped by name, and lite-07 drops its stream count for it - [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges in either order and replaces FETCH; relays fill misses by range, including over moq-transport diff --git a/quest/m1/capture-exhausted-error.md b/quest/m1/capture-exhausted-error.md deleted file mode 100644 index ce056416ac..0000000000 --- a/quest/m1/capture-exhausted-error.md +++ /dev/null @@ -1,16 +0,0 @@ -# [XS] A spent capture retry budget reports its error - -## Goal - -When an ended camera or microphone track uses up the `Retry` budget in -js/publish (`js/publish/src/source/{camera,microphone,retry}.ts`), `out.error` -is set to the last error instead of the capture failing silently. A -regression test covers it. - -## Plan - -An open failure that spends the budget already sets `out.error`. A track that -ends calls `Retry.failed()` from its `ended` handler, and nothing reports it -when that was the last attempt. The gap predates #4827. - -Public API: none. Wire: none.