Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions js/publish/src/source/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface CameraProps extends Inputs<CameraInput> {
type CameraOutput = {
// The live camera track, or undefined while disabled or denied.
source: Signal<Media | undefined>;
/** A terminal getUserMedia failure, cleared when a new capture attempt begins. */
/** A terminal capture failure, cleared when a new capture attempt begins. */
error: Signal<Error | undefined>;
};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
8 changes: 6 additions & 2 deletions js/publish/src/source/microphone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface MicrophoneProps extends Inputs<MicrophoneInput> {
type MicrophoneOutput = {
// The live microphone track, or undefined while disabled or denied.
source: Signal<Media | undefined>;
/** A terminal getUserMedia failure, cleared when a new capture attempt begins. */
/** A terminal capture failure, cleared when a new capture attempt begins. */
error: Signal<Error | undefined>;
};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
50 changes: 50 additions & 0 deletions js/publish/src/source/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
33 changes: 28 additions & 5 deletions js/publish/src/source/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -80,16 +86,32 @@ 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.
*
* Only a busy device (`NotReadableError`) or an aborted request (`AbortError`) spends budget;
* 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;
}
Expand All @@ -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. */
Expand All @@ -123,5 +145,6 @@ export class Retry {
this.#failures = 0;
this.#wait = undefined;
this.#delay = Retry.DELAY.initial;
this.#error = undefined;
}
}
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 0 additions & 16 deletions quest/m1/capture-exhausted-error.md

This file was deleted.

Loading