fix: settle a shared abort operation by observation, not by drain count
The primitive decided a raced outcome by draining a hard-coded four microtasks and then asking whether the task had landed. That made the answer depend on scheduling rather than on what was observed: a caller abort could fix the terminal owner synchronously and a rejection later in the same call stack still won the public result, so `race()` disagreed with `terminal()` and the failure taxonomy a caller received depended on microtask ordering. Task settlement and the terminal event now share one settle-once state machine. Whichever callback actually runs first owns the outcome; a value that loses is compensated exactly once and a rejection that loses is absorbed, so neither can surface late. The three consumers that kept their own copies of these mechanics move onto it. The Image probe and the Resumable fetch transport attached their caller listener before installing the timer, so a scheduler that threw rejected the public `probe()`/`execute()` promise natively and left the listener on the caller's signal; both now close atomically inside their own Result vocabulary and start no fetch. `snapshotAbortTimers` binds the scheduler callables once at construction, so replacing a method after composition can no longer change how work already in flight is bounded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d6d84bfcc
commit
cc91fc6ae0
@@ -10,7 +10,11 @@ import type {
|
||||
PresignedUploadPartCapability,
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import { createAbortableOperation } from "../../platform/abortable-operation.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -145,16 +149,20 @@ export function createPresignedCapabilityHttpProvider(
|
||||
);
|
||||
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
||||
const now = options.now ?? Date.now;
|
||||
const scheduler =
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their
|
||||
// receiver, so replacing a scheduler method after composition cannot change
|
||||
// how work already in flight is bounded.
|
||||
const timers = snapshotAbortTimers(
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler),
|
||||
);
|
||||
const credentials = options.controlPlaneCredentials ?? "same-origin";
|
||||
const observer = options.observer;
|
||||
|
||||
@@ -187,7 +195,7 @@ export function createPresignedCapabilityHttpProvider(
|
||||
if (signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
const scope = createAbortScope(signal, timeoutMs, timers);
|
||||
try {
|
||||
const raced = await scope.race(
|
||||
fetcher(endpoint, {
|
||||
@@ -893,16 +901,14 @@ const SCOPE_ENDED = Symbol("presigned-capability-scope-ended");
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: (callback, delayMs) => scheduler.setTimeout(callback, delayMs),
|
||||
clearTimer: (handle) => {
|
||||
scheduler.clearTimeout(handle as never);
|
||||
},
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
|
||||
Reference in New Issue
Block a user