refactor(spf): memoize Task.run via a named #execute, fixing orphan rejections

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>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:24 -07:00
co-authored by Claude Opus 4.8
parent 557d8bd72f
commit 73f0ef711a
+19 -15
View File
@@ -105,24 +105,28 @@ export class Task<TValue = void, TError = unknown> implements TaskLike<TValue, T
}
run(): Promise<TValue> {
// Memoized: run the work once; repeated calls share the same promise (which
// resolves/rejects immediately once settled). Re-running needs a `clone()`.
this.#promise ??= (async () => {
this.#status = 'running';
try {
const result = await this.#runFn(this.#signal);
this.#value = result; // value before status — ordering guarantee
this.#status = 'done';
return result;
} catch (e) {
this.#error = e as TError; // error before status — ordering guarantee
this.#status = 'error';
throw e;
}
})();
// Memoized: run the work once, sharing the same promise across calls. The
// memoized promise IS what callers await, so it's always handled (no orphan
// `Promise.resolve/reject`); a sync-throwing `#runFn` is captured as a
// rejection rather than re-run.
this.#promise ??= this.#execute();
return this.#promise;
}
async #execute(): Promise<TValue> {
this.#status = 'running';
try {
const result = await this.#runFn(this.#signal);
this.#value = result; // value before status — ordering guarantee
this.#status = 'done';
return result;
} catch (e) {
this.#error = e as TError; // error before status — ordering guarantee
this.#status = 'error';
throw e;
}
}
abort(): void {
this.#abortController.abort();
}