refactor(spf): drive live reload via a RecurringRunner instead of an epoch signal (WIP)

Replace the signal-as-event live-reload scheduler with a runner-driven model.
The `resolveTrack` loader schedules its resolve work on a new `RecurringRunner`
that re-runs the task on an injected `reschedule` policy; the separate
`scheduleTrackReload` behavior and its per-type reload-epoch signals are deleted.

Core (`core/tasks`):
- `Task.run()` is now memoized (runs once, shares the result across calls) and
  gains `clone()` (fresh, pending, structurally identical) — added to `TaskLike`.
- `RecurringRunner`: single-slot, id-keyed (dedup same id / abort-and-replace on
  new id), time-free. Each cycle runs `Promise.all([task.run(), reschedule(task,
  previous, signal)])` and re-runs a `clone()` while reschedule resolves `true`.
- `Reschedule<T> = (task, previous, signal) => PromiseLike<boolean>` — invoked
  concurrently with the run, observes it via the memoized `run()`, owns its delay.
- `delayedReschedule(cadence)` builds a Reschedule from a pure ms-cadence fn,
  start-anchored (subtracts the run's elapsed) so reloads are measured from
  load-start per RFC 8216 §6.3.4, preserving half-on-unchanged.

Supporting:
- `@videojs/utils/time`: add cancellable `sleep(ms, signal)`.
- `media/hls/reload-policy`: `mediaPlaylistReloadDelay` (pure cadence; relocated
  scheduler logic — target-duration, half-on-unchanged, stop-on-ENDLIST, retry).
- `resolve-track`: baked universal completeness gate + injected `reschedule`;
  engine composes `delayedReschedule(mediaPlaylistReloadDelay)`.

WIP: not fully validated end-to-end against a live stream through this path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:24 -07:00
co-authored by Claude Opus 4.8
parent 1d51582d8c
commit 557d8bd72f
16 changed files with 926 additions and 624 deletions
+1
View File
@@ -1 +1,2 @@
export * from './format';
export * from './sleep';
+25
View File
@@ -0,0 +1,25 @@
/**
* Resolve after `ms` milliseconds. Pass a `signal` to make it cancellable: the
* timer is cleared and the promise rejects with the signal's reason as soon as
* the signal aborts (including if it's already aborted).
*/
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { sleep } from '../sleep';
afterEach(() => {
vi.useRealTimers();
});
describe('sleep', () => {
it('resolves after the given delay', async () => {
vi.useFakeTimers();
const resolved = vi.fn();
const promise = sleep(100).then(resolved);
await vi.advanceTimersByTimeAsync(99);
expect(resolved).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await promise;
expect(resolved).toHaveBeenCalledTimes(1);
});
it('rejects with the signal reason and clears the timer when aborted mid-wait', async () => {
vi.useFakeTimers();
const controller = new AbortController();
const reason = new DOMException('Aborted', 'AbortError');
const promise = sleep(100, controller.signal);
controller.abort(reason);
await expect(promise).rejects.toBe(reason);
// Timer was cleared — advancing past the delay does nothing further.
await vi.advanceTimersByTimeAsync(200);
});
it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController();
const reason = new DOMException('Aborted', 'AbortError');
controller.abort(reason);
await expect(sleep(100, controller.signal)).rejects.toBe(reason);
});
});