refactor(spf): make RecurringRunner a self-recursive schedule(), require reschedule

Replace the imperative `#loop()` with a single chained promise: each cycle's
`.then` returns the next cycle's `schedule(clone())`, so the recurrence is the
method calling itself. The slot is released just before re-scheduling the same-id
clone so the call advances rather than dedup-returning; ownership is tracked by
`#active === task`.

Error handling moves downstream — the runner no longer invents a retry policy:
- A genuine run/reschedule failure rejects schedule()'s promise (propagates to
  the caller); no swallowing.
- The runner's own cancellation (abort/supersede/destroy) is not a failure, so an
  aborted recurrence settles quietly — callers don't `.catch` routine teardown.

Consequences:
- `reschedule` is now required; `runOnce` expresses run-exactly-once explicitly
  (a missing reschedule is a bug, not a silent run-once).
- Reschedule-driven retry-on-transient-error is dropped (a rejected run is
  terminal). The retry logic in delayedReschedule / mediaPlaylistReloadDelay is
  now vestigial — to be cleaned up or relocated to the fetch layer next.
- `resolve-track` catches schedule()'s promise (abort settles quietly; genuine
  resolve failures end the recurrence, TODO surface to state).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:25 -07:00
co-authored by Claude Opus 4.8
parent 5154c424f9
commit 2520373f2f
4 changed files with 94 additions and 73 deletions
+63 -46
View File
@@ -366,6 +366,13 @@ export class SerialRunner {
*/
export type Reschedule<TValue> = (task: TaskLike<TValue>) => PromiseLike<boolean>;
/**
* A {@link Reschedule} that never recurs — the task runs exactly once. Pass it to
* a {@link RecurringRunner} for non-recurring, run-once work (e.g. resolving a
* complete VoD playlist that can never go stale).
*/
export const runOnce: Reschedule<unknown> = () => Promise.resolve(false);
/**
* Runs a task, then re-runs it whenever a {@link Reschedule} function says to,
* until it says stop (or it's aborted) — the recurring sibling of
@@ -373,8 +380,8 @@ export type Reschedule<TValue> = (task: TaskLike<TValue>) => PromiseLike<boolean
* {@link TaskLike} to run.
*
* The runner has no notion of time: it just awaits whatever `reschedule`
* returns (a promise → re-run when it resolves; `null` → stop). With no
* reschedule it runs the task exactly once (the non-recurring default).
* returns (resolves `true` → re-run; `false` → stop). A `reschedule` is required;
* pass {@link runOnce} for non-recurring, run-exactly-once work.
*
* Single-slot, keyed by task **id**: there is always at most one identified
* active task for re-running. Scheduling a task whose id matches the active one
@@ -396,64 +403,74 @@ export type Reschedule<TValue> = (task: TaskLike<TValue>) => PromiseLike<boolean
* frees the slot, so a later schedule of the same id starts fresh.
*/
export class RecurringRunner<TValue = unknown> {
readonly #reschedule: Reschedule<TValue> | undefined;
readonly #reschedule: Reschedule<TValue>;
// The identified active task — the one being (re)run. Held across cycles
// (including the inter-cycle wait); null once the recurrence stops/aborts. Also
// serves as the loop's ownership token: a loop frees the slot only while
// serves as the ownership token: a cycle advances or frees the slot only while
// `#active` still points at its own task (a supersede/abortAll swaps it).
#active: TaskLike<TValue, unknown> | null = null;
#destroyed = false;
constructor(reschedule?: Reschedule<TValue>) {
constructor(reschedule: Reschedule<TValue>) {
this.#reschedule = reschedule;
}
schedule(task: TaskLike<TValue, unknown>): void {
if (this.#destroyed) return;
/**
* Run `task` and recur per the `reschedule` verdict, as a single promise.
* Resolves with the *final* cycle's value when the recurrence stops; **rejects**
* if a run (or reschedule) genuinely fails — the rejection propagates to the
* caller, who owns error handling; the runner only frees its slot (no
* swallowing). The runner's *own* cancellation (abort/supersede/destroy) is not
* a failure, so an aborted recurrence settles quietly rather than rejecting —
* callers don't have to `.catch` routine teardown.
*
* Each cycle runs the task and consults `reschedule` concurrently (so the delay
* can be measured from the run's start); when both settle and this cycle still
* owns the slot, a `true` verdict re-schedules a `clone()` whose promise is
* *returned* — so the recurrence is the method calling itself, threaded into one
* promise, no separate loop. The clone shares the id, so the slot's identity is
* stable across cycles; it's released just before the re-schedule so the call
* advances rather than dedup-returning.
*
* Note: because each cycle's promise adopts the next, the chain retains every
* prior cycle for the life of the recurrence — bounded for finite recurrences,
* an unbounded (small per-cycle) cost for a long-lived one (e.g. live reload).
*/
schedule(task: TaskLike<TValue, unknown>): Promise<TValue> {
if (this.#destroyed) return Promise.resolve() as Promise<TValue>;
// Dedup by id: this id is already the active re-run target, so the existing
// recurrence continues uninterrupted (don't restart it).
if (this.#active?.id === task.id) return;
// Different id supersedes: abort the prior recurrence, then take over as the
// one identified active task.
// recurrence continues uninterrupted (don't restart it) — hand back its
// in-flight run.
if (this.#active?.id === task.id) return this.#active.run();
// Different id supersedes: abort the prior recurrence, then take over.
this.#cancel();
this.#active = task;
void this.#loop(task);
}
async #loop(task: TaskLike<TValue, unknown>): Promise<void> {
let current = task;
while (true) {
let again = false;
try {
// Start the run and the reschedule together: reschedule observes the run
// (via the memoized `current.run()`) and owns the inter-run delay, so the
// interval can be measured from the run's *start*. `Promise.all` waits
// for both — the run (so the clone can carry its value forward as
// `previous`) and the verdict + delay. The run's own result is swallowed
// here; reschedule reads it (and decides retry-vs-stop on error).
[, again] = await Promise.all([
current.run().then(
() => {},
() => {}
),
this.#reschedule ? this.#reschedule(current) : Promise.resolve(false),
]);
} catch {
return; // reschedule rejected (aborted during its delay) — stop without freeing
// Drive the run (in case `reschedule` doesn't observe it — e.g. `runOnce`)
// and the verdict together; a rejected run rejects the whole cycle.
return Promise.all([task.run(), this.#reschedule(task)]).then(
([value, again]) => {
// Only act while we still own the slot — a supersede/abortAll swapped
// `#active`, in which case this stale cycle does nothing.
if (this.#active === task && again && !task.signal.aborted) {
// Release first so the same-id clone advances (isn't dedup-returned),
// then chain the next cycle into this promise.
this.#active = null;
return this.schedule(task.clone());
}
if (this.#active === task) this.#active = null; // natural stop / superseded
return value;
},
(error) => {
// The recurrence ended on a rejection; free the slot if we still own it.
if (this.#active === task) this.#active = null;
// The runner's own cancellation isn't a failure — settle quietly so
// routine teardown (abort/supersede/destroy) needs no caller `.catch`.
// A genuine run/reschedule failure propagates to the caller.
if (task.signal.aborted) return undefined as TValue;
throw error;
}
// The task is the cancellation channel; an aborted task means we were
// superseded/aborted even if reschedule still resolved true.
if (current.signal.aborted || !again) break;
// Re-run the same work as a fresh task — `run()` is memoized, so re-running
// `current` wouldn't re-execute. The clone keeps the id (stable slot
// identity) and carries `previous`; track it as active so an abort hits the
// in-flight instance.
current = current.clone();
this.#active = current;
}
// Loop ended on its own terms (stop / aborted-but-not-cancelled): free the
// slot iff this loop still owns it (a supersede/abortAll swapped `#active`).
if (this.#active === current) this.#active = null;
);
}
#cancel(): void {
+11 -11
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { ConcurrentRunner, RecurringRunner, type Reschedule, SerialRunner, Task } from '../task';
import { ConcurrentRunner, RecurringRunner, type Reschedule, runOnce, SerialRunner, Task } from '../task';
// =============================================================================
// Task
@@ -670,10 +670,10 @@ describe('RecurringRunner', () => {
/** Flush pending macrotasks so "did NOT happen" assertions are meaningful. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
it('runs the task once when no reschedule is supplied', async () => {
it('runs the task exactly once with the runOnce reschedule', async () => {
let runs = 0;
const task = new Task<number>(async () => ++runs, { id: 'x' });
const runner = new RecurringRunner<number>();
const runner = new RecurringRunner<number>(runOnce);
runner.schedule(task);
await vi.waitFor(() => expect(runs).toBe(1));
@@ -714,21 +714,21 @@ describe('RecurringRunner', () => {
]);
});
it('keeps the loop alive across a transient error (observed value is undefined)', async () => {
it('rejects and stops the recurrence when a run errors (no swallowing/retry)', async () => {
let n = 0;
const task = new Task<number>(async () => {
n += 1;
if (n === 1) throw new Error('boom');
return n;
});
// Retry on error (observed value undefined); keep going until a value reaches 3.
const runner = new RecurringRunner<number>(async (t) => {
const current = await t.run().catch(() => undefined);
return current === undefined || current < 3;
});
// A reschedule that would otherwise keep going — but a run error ends it.
const runner = new RecurringRunner<number>(async () => true);
runner.schedule(task);
await vi.waitFor(() => expect(n).toBe(3));
// The first cycle's error propagates out of schedule(); the recurrence stops.
await expect(runner.schedule(task)).rejects.toThrow('boom');
await flush();
expect(n).toBe(1); // no retry — the errored run was terminal
runner.destroy();
});
@@ -1,7 +1,7 @@
import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
import { RecurringRunner, type Reschedule, Task } from '../../core/tasks/task';
import { RecurringRunner, type Reschedule, runOnce, Task } from '../../core/tasks/task';
import { NON_FMP4_CONTAINER_MIMES, parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
import type { MaybeResolvedPresentation, PartiallyResolvedTrack, ResolvedTrack } from '../../media/types';
import { deriveStreamType, getMediaPlaylistMetadata, isResolvedPresentation, isResolvedTrack } from '../../media/types';
@@ -107,10 +107,11 @@ function setupTrackResolution<K extends SelectedTrackKey>({
config: TrackResolutionConfig<K>;
}) {
// The runner owns recurrence: with a `reschedule` (live) it re-runs the resolve
// task whenever reschedule resolves, until it returns null; with none (VoD) it
// runs once. Single-slot — a selection change re-schedules (abort-and-replace),
// and the reactor's state-exit aborts it on source change.
const runner = new RecurringRunner<ResolvedTrack>(reschedule);
// task whenever reschedule resolves true, until it returns false; with none
// configured (VoD) `runOnce` makes it resolve exactly once. Single-slot — a
// selection change re-schedules (abort-and-replace), and the reactor's
// state-exit aborts it on source change.
const runner = new RecurringRunner<ResolvedTrack>(reschedule ?? runOnce);
// The resolve task for `trackId`. The `RecurringRunner` re-runs this same task
// each reload cycle, so its run fn re-reads the current snapshot each time —
@@ -205,7 +206,11 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// is the runner's job, driven by `reschedule`.
if (!track || !shouldLoadTrack(track)) return;
runner.schedule(createResolveTask(trackId));
// Abort (selection/source change, via `abortAll`) settles quietly;
// `schedule` rejects only on a genuine resolve failure. We don't
// surface those to state yet, so end quietly.
// TODO: surface unrecoverable resolve errors.
runner.schedule(createResolveTask(trackId)).catch(() => {});
},
],
},
@@ -498,24 +498,23 @@ http://example.com/seg0.m4s`;
reactor.destroy();
});
it('retries an unresolved track after a transient failure (errored run → reschedule retry)', async () => {
it('stops resolving on a fetch failure (an errored run is terminal — no retry)', async () => {
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
let calls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
calls += 1;
if (calls === 1) throw new TypeError('Failed to fetch');
return new Response(LIVE_PLAYLIST);
throw new TypeError('Failed to fetch');
});
// Retry while the observed run errored (undefined); stop once a result lands.
const reschedule = async (task: TaskLike<ResolvedTrack>) => {
const current = await task.run().catch(() => undefined);
return current === undefined;
};
// Even a reschedule that would keep going can't revive an errored run — the
// rejected run ends the recurrence (retry, if wanted, belongs in the fetch layer).
const reschedule = async () => true;
const reactor = resolveVideoTrack.setup({ state, config: { reschedule } });
await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true));
expect(calls).toBe(2);
await vi.waitFor(() => expect(calls).toBe(1));
await flush();
expect(calls).toBe(1); // no retry
expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(false);
reactor.destroy();
});