The RecurringRunner propagates a rejected run as the recurrence's failure
(Promise.all short-circuits before any retry verdict lands), so the
retry-on-error affordance in delayedReschedule and mediaPlaylistReloadDelay
was dead code. Remove it: delayedReschedule awaits the run directly (a
rejection now rejects the reschedule), and mediaPlaylistReloadDelay takes a
non-optional current track. Transient-fetch-failure recovery belongs at the
fetch layer, not in the cadence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Collapse `Reschedule<T>` from `(task, previous, signal) => ...` to
`(task) => PromiseLike<boolean>`. One `delayedReschedule` instance is shared
across the video + audio runners, so per-recurrence state can't live in its
closure — the task is the only per-recurrence carrier, so both dropped params
move onto it:
- `task.signal` exposes the task's composed signal. RecurringRunner drops its
separate `#abort` AbortController: the task is now the sole cancellation
channel and loop ownership is task-identity, not an AbortController token.
- `task.previous` is carried by `clone()` as lineage
(`#previous = this.#value ?? this.#previous`), reproducing the last-*successful*
value semantics (an errored cycle inherits the prior good value) with no
bookkeeping in the runner — `#loop` no longer threads previous/result.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Memoize the run-machinery promise itself (`#promise ??= this.#execute()`) rather
than reassigning `#promise` to fresh `Promise.resolve(value)` / `Promise.reject(error)`
on settle. The reassigned promises were never awaited, so an errored or aborted
task that's not re-run (the norm — the RecurringRunner moves on to a clone) left
an unhandled rejection. The memoized promise is the one callers await, so it's
always handled.
Also closes a sync-throw gap: a `#runFn` that throws synchronously is now captured
as a rejected memoized promise instead of leaving `#promise` unset (which would
re-execute on the next run()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Feedback from @decepulis treated as a fast follow/incremental improvement effort for expediency.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>