diff --git a/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts index ffe972b..99fd66e 100644 --- a/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts +++ b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts @@ -6,6 +6,11 @@ import { browserDataFailure, browserDataSuccess, } from "../../browser-file-storage/result.ts"; +import { + createAbortableOperation, + snapshotAbortTimers, + type AbortTimerSnapshot, +} from "../../platform/abortable-operation.ts"; import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts"; export type DecodedImageFacade = Readonly<{ @@ -47,7 +52,7 @@ export function createBrowserImageProbe( ? async (image: Blob) => createImageBitmap(image) : undefined); const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const scheduler = snapshotScheduler( + const timers = snapshotAbortTimers( dependencies.scheduler ?? defaultScheduler(), ); if ( @@ -98,10 +103,15 @@ export function createBrowserImageProbe( const scope = createProbeAbortScope( request.signal, timeoutMs, - scheduler, + timers, ); let response: Response | undefined; try { + if (scope.signal.aborted) { + // Nothing physical has started yet: an already aborted caller or a + // deadline that could not be installed ends the probe before fetch. + return signalFailure(request.signal); + } try { const fetchTask = Promise.resolve( fetcher(url.href, { @@ -114,15 +124,11 @@ export function createBrowserImageProbe( signal: scope.signal, }), ); - response = await awaitWithAbort( - fetchTask, - scope.signal, - (lateResponse) => { - cancelResponseBody(lateResponse); - }, - ); + response = await scope.await(fetchTask, (lateResponse) => { + cancelResponseBody(lateResponse); + }); } catch { - return signalFailure(request.signal, scope); + return signalFailure(request.signal); } if ( response.status !== 200 || @@ -150,7 +156,7 @@ export function createBrowserImageProbe( ); } catch (error) { if (request.signal.aborted || scope.timedOut()) { - return signalFailure(request.signal, scope); + return signalFailure(request.signal); } return error instanceof EncodedBodyLimitError ? browserDataFailure( @@ -221,11 +227,7 @@ export function createBrowserImageProbe( type: request.expectedMediaType, }), ); - bitmap = await awaitWithAbort( - decodeTask, - scope.signal, - closeBitmap, - ); + bitmap = await scope.await(decodeTask, closeBitmap); if ( !positiveSafeInteger(bitmap.width) || !positiveSafeInteger(bitmap.height) || @@ -254,7 +256,7 @@ export function createBrowserImageProbe( ); } catch { return request.signal.aborted || scope.timedOut() - ? signalFailure(request.signal, scope) + ? signalFailure(request.signal) : browserDataFailure( "INTEGRITY_FAILED", "IMAGE_RESOLVE", @@ -483,11 +485,7 @@ async function readBoundedBody( try { while (true) { if (signal.aborted) throw abortException(); - const next = await awaitWithAbort( - reader.read(), - signal, - () => undefined, - ); + const next = await readOrAbort(reader.read(), signal); if (next.done) break; if (!(next.value instanceof Uint8Array)) { throw new TypeError("Image response chunk is invalid."); @@ -519,97 +517,99 @@ async function readBoundedBody( type ProbeAbortScope = Readonly<{ signal: AbortSignal; + /** True once a deadline, or a deadline that could not be installed, ended it. */ timedOut(): boolean; + /** + * Awaits `task` under this scope's ownership. A late value is compensated + * exactly once; a terminal owner raises the scope's abort exception. + */ + await( + task: Promise, + onLateValue: (value: Value) => void, + ): Promise; release(): void; }>; +/** + * X-AUDIT-02 / TR-RR-05. The probe scope is the shared abort primitive with + * this subsystem's vocabulary on top. Owning a private copy meant the caller + * listener was attached before the timer was installed, so a scheduler that + * threw rejected the public `probe()` promise and left the listener behind. + */ function createProbeAbortScope( externalSignal: AbortSignal, timeoutMs: number, - scheduler: ImageProbeScheduler, + timers: AbortTimerSnapshot, ): ProbeAbortScope { - const controller = new AbortController(); - let timeoutReached = false; - let released = false; - const onExternalAbort = () => { - controller.abort(externalSignal.reason); - }; - externalSignal.addEventListener("abort", onExternalAbort, { - once: true, + const operation = createAbortableOperation({ + signal: externalSignal, + timeoutMs, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, }); - if (externalSignal.aborted) onExternalAbort(); - const timeoutHandle = scheduler.setTimeout(() => { - if (released) return; - timeoutReached = true; - controller.abort(abortException()); - }, timeoutMs); - return Object.freeze({ - signal: controller.signal, - timedOut: () => timeoutReached, - release() { - if (released) return; - released = true; - try { - scheduler.clearTimeout(timeoutHandle); - } catch { - // A broken optional scheduler cannot change a terminal probe result. - } - externalSignal.removeEventListener("abort", onExternalAbort); + signal: operation.signal, + // `CLOSED` here means the deadline could never be installed, so the probe + // was never bounded: operationally the same unanswered host as a deadline. + timedOut: () => + operation.terminal() !== "CALLER_ABORT" && operation.terminal() !== null, + async await( + task: Promise, + onLateValue: (value: Value) => void, + ): Promise { + const raced = await operation.race(task, onLateValue); + if (raced.kind === "VALUE") return raced.value; + if (raced.kind === "REJECTED") throw raced.reason; + throw abortException(); }, + release: () => operation.close(), }); } -function awaitWithAbort( +/** + * A single read raced against the probe's ownership. A late chunk is dropped: + * the bytes are only ever accumulated by the caller below. + */ +function readOrAbort( task: Promise, signal: AbortSignal, - onLateValue: (value: Value) => void, ): Promise { return new Promise((resolve, reject) => { let settled = false; const onAbort = () => { if (settled) return; settled = true; - signal.removeEventListener("abort", onAbort); reject(abortException()); }; signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); void task.then( (value) => { - if (settled) { - onLateValue(value); - return; - } - settled = true; signal.removeEventListener("abort", onAbort); - resolve(value); + if (!settled) { + settled = true; + resolve(value); + } }, (error: unknown) => { - if (settled) return; - settled = true; signal.removeEventListener("abort", onAbort); - reject(error); + if (!settled) { + settled = true; + reject(error); + } }, ); }); } -function signalFailure( - externalSignal: AbortSignal, - scope: ProbeAbortScope, -) { +function signalFailure(externalSignal: AbortSignal) { + // A caller abort is the caller's own verdict; every other owner — a deadline + // or a deadline that could never be installed — is an unanswered host. return externalSignal.aborted ? browserDataFailure("ABORTED", "IMAGE_RESOLVE") - : scope.timedOut() - ? browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { - retryable: true, - recovery: "RETRY", - }) - : browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { - retryable: true, - recovery: "RETRY", - }); + : browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); } function cancelResponseBody(response: Response): void { @@ -659,22 +659,6 @@ function withinDecodeBudget( ); } -function snapshotScheduler( - scheduler: ImageProbeScheduler, -): ImageProbeScheduler { - if ( - !scheduler || - typeof scheduler.setTimeout !== "function" || - typeof scheduler.clearTimeout !== "function" - ) { - throw new TypeError("Image probe scheduler is invalid."); - } - return Object.freeze({ - setTimeout: scheduler.setTimeout.bind(scheduler), - clearTimeout: scheduler.clearTimeout.bind(scheduler), - }); -} - function defaultScheduler(): ImageProbeScheduler { return Object.freeze({ setTimeout(callback: () => void, milliseconds: number) { diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts index 1cdc699..e8fd973 100644 --- a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts +++ b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts @@ -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, - ), - } satisfies Scheduler); + ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } 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) { diff --git a/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts b/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts index f052351..8dd4a5d 100644 --- a/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts +++ b/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts @@ -10,6 +10,11 @@ import { browserDataFailure, browserDataSuccess, } from "../../browser-file-storage/result.ts"; +import { + createAbortableOperation, + type AbortRace, + type AbortTerminalReason, +} from "../../platform/abortable-operation.ts"; import type { ResumableUploadControlOperation, ResumableUploadJsonTransport, @@ -100,6 +105,12 @@ export function createResumableUploadFetchJsonTransport( ) { throw new TypeError("Upload fetch transport dependency is invalid."); } + // X-AUDIT-02. Reading `scheduler.setTimeout` again at request time made the + // validated dependency and the executed one two different things: replacing + // the method after composition changed how an attempt was bounded. The + // callables are bound to their receiver once, here. + const setTimer = scheduler.setTimeout.bind(scheduler); + const clearTimer = scheduler.clearTimeout.bind(scheduler); const timeoutMs = boundedPositiveInteger( input.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1, @@ -171,8 +182,18 @@ export function createResumableUploadFetchJsonTransport( "NONE", ); } - const attempt = createFetchAttempt(signal, timeoutMs, scheduler); + const attempt = createFetchAttempt( + signal, + timeoutMs, + setTimer, + clearTimer, + ); try { + if (attempt.terminalKind() !== null) { + // The attempt was closed before it could be bounded, so no request is + // ever put on the wire. + return attemptFailure(attempt, operation); + } const fetchPromise = fetcher(endpoint, { method: "POST", headers: headersFor(headers), @@ -186,19 +207,13 @@ export function createResumableUploadFetchJsonTransport( ? "same-origin" : "cors", }); - const raced = await Promise.race([ - fetchPromise.then( - (value) => { - if (attempt.terminalKind()) { - cancelResponseBody(value); - } - return { kind: "RESPONSE" as const, value }; - }, - () => ({ kind: "FAILED" as const }), - ), - attempt.terminal, - ]); - if (raced.kind !== "RESPONSE") { + const raced = await attempt.race( + Promise.resolve(fetchPromise), + (late) => { + cancelResponseBody(late); + }, + ); + if (raced.kind !== "VALUE") { return attemptFailure(attempt, operation); } const response = raced.value; @@ -423,18 +438,8 @@ async function readBoundedJson( let total = 0; try { while (true) { - const raced = await Promise.race([ - reader.read().then( - (value) => ({ kind: "READ" as const, value }), - () => ({ kind: "FAILED" as const }), - ), - attempt.terminal, - ]); - if (raced.kind === "ABORT" || raced.kind === "TIMEOUT") { - cancelReader(reader); - return attemptFailure(attempt, operation); - } - if (raced.kind === "FAILED") { + const raced = await attempt.race(reader.read()); + if (raced.kind !== "VALUE") { cancelReader(reader); return attemptFailure(attempt, operation); } @@ -569,64 +574,54 @@ function failure( return Object.freeze({ ok: false, error }); } -type FetchAttemptTerminal = - | Readonly<{ kind: "ABORT" }> - | Readonly<{ kind: "TIMEOUT" }>; +type FetchAttemptTerminalKind = "ABORT" | "TIMEOUT" | "CLOSED"; type FetchAttempt = Readonly<{ signal: AbortSignal; - terminal: Promise; - terminalKind(): FetchAttemptTerminal["kind"] | null; + terminalKind(): FetchAttemptTerminalKind | null; + race( + task: Promise, + compensate?: (value: Value) => void, + ): Promise>; release(): void; }>; +/** + * X-AUDIT-02 / TR-RR-05. The attempt is the shared abort primitive with this + * subsystem's vocabulary on top. Owning a private copy meant the listener was + * attached before the timer was installed, so a scheduler that threw rejected + * the public `execute()` promise and left the listener on the caller's signal. + */ function createFetchAttempt( parent: AbortSignal, timeoutMs: number, - scheduler: Readonly<{ - setTimeout(callback: () => void, delayMs: number): unknown; - clearTimeout(handle: unknown): void; - }>, + setTimer: (callback: () => void, delayMs: number) => unknown, + clearTimer: (handle: unknown) => void, ): FetchAttempt { - const controller = new AbortController(); - let terminalKind: FetchAttemptTerminal["kind"] | null = null; - let resolveTerminal: - | ((value: FetchAttemptTerminal) => void) - | undefined; - const terminal = new Promise( - (resolve) => { - resolveTerminal = resolve; - }, - ); - const finish = (kind: FetchAttemptTerminal["kind"]) => { - if (terminalKind) return; - terminalKind = kind; - controller.abort(); - resolveTerminal?.(Object.freeze({ kind })); + const operation = createAbortableOperation({ + signal: parent, + timeoutMs, + setTimer, + clearTimer, + }); + const kindOf = ( + reason: AbortTerminalReason | null, + ): FetchAttemptTerminalKind | null => { + if (reason === null) return null; + if (reason === "CALLER_ABORT") return "ABORT"; + return reason === "DEADLINE" ? "TIMEOUT" : "CLOSED"; }; - const abort = () => finish("ABORT"); - parent.addEventListener("abort", abort, { once: true }); - if (parent.aborted) abort(); - const timer = scheduler.setTimeout(() => { - finish("TIMEOUT"); - }, timeoutMs); return Object.freeze({ - signal: controller.signal, - terminal, - terminalKind: () => terminalKind, + signal: operation.signal, + terminalKind: () => kindOf(operation.terminal()), + race: ( + task: Promise, + compensate?: (value: Value) => void, + ) => operation.race(task, compensate), release() { // BT-UP-01. Cleanup is best effort and must never replace the already // classified terminal result with a rejection. - try { - scheduler.clearTimeout(timer); - } catch { - // A hostile scheduler cannot block listener release below. - } - try { - parent.removeEventListener("abort", abort); - } catch { - // A hostile signal facade cannot break the typed result. - } + operation.close(); }, }); } diff --git a/src/adapters/platform/abortable-operation.ts b/src/adapters/platform/abortable-operation.ts index 6466cc7..c577f5d 100644 --- a/src/adapters/platform/abortable-operation.ts +++ b/src/adapters/platform/abortable-operation.ts @@ -56,6 +56,37 @@ export type AbortableOperationInput = Readonly<{ clearTimer?: (handle: unknown) => void; }>; +export type AbortTimerSnapshot = Readonly<{ + setTimer: (callback: () => void, delayMs: number) => unknown; + clearTimer: (handle: unknown) => void; +}>; + +/** + * X-AUDIT-02. Captures a scheduler's timer callables once, bound to their + * receiver. Consumers that kept the scheduler object and re-read `setTimeout` + * per request validated one function and executed another, so replacing a + * method after composition silently changed how work was bounded. + */ +export function snapshotAbortTimers( + scheduler: Readonly<{ + setTimeout(callback: () => void, milliseconds: number): Handle; + clearTimeout(handle: Handle): void; + }>, +): AbortTimerSnapshot { + const set = scheduler?.setTimeout; + const clear = scheduler?.clearTimeout; + if (typeof set !== "function" || typeof clear !== "function") { + throw new TypeError("Timer scheduler is invalid."); + } + return Object.freeze({ + setTimer: set.bind(scheduler) as ( + callback: () => void, + delayMs: number, + ) => unknown, + clearTimer: clear.bind(scheduler) as (handle: unknown) => void, + }); +} + export function createAbortableOperation( input: AbortableOperationInput = {}, ): AbortableOperation { @@ -138,18 +169,10 @@ export function createAbortableOperation( return Object.freeze({ signal: controller.signal, terminal: () => terminalReason, - async race( + race( operation: Promise, compensate?: (value: Value) => void, ): Promise> { - // The outcome is derived once, so a late rejection can never surface as - // an unhandled rejection and a late value is compensated exactly once. - let landed: AbortRace | null = null; - const settled: Promise> = operation.then( - (value) => (landed = Object.freeze({ kind: "VALUE" as const, value })), - (reason: unknown) => - (landed = Object.freeze({ kind: "REJECTED" as const, reason })), - ); let compensated = false; const compensateOnce = (value: Value) => { if (compensated || !compensate) return; @@ -160,15 +183,6 @@ export function createAbortableOperation( // Compensation is best effort and never changes the outcome. } }; - // Attached to the original promise, not to the derived one, so a late - // value is compensated in the same turn it lands rather than a turn - // later. `settled` already absorbs the rejection. - const observeLate = () => { - void operation.then( - (value) => compensateOnce(value), - () => undefined, - ); - }; const terminalRace = (): AbortRace => Object.freeze({ kind: "TERMINAL" as const, @@ -177,52 +191,60 @@ export function createAbortableOperation( }); if (terminalReason !== null) { - observeLate(); - return terminalRace(); + // Already owned before the task was ever raced: nothing it produces can + // be admitted, so a value is compensated and a rejection absorbed. + void operation.then( + (value) => compensateOnce(value), + () => undefined, + ); + return Promise.resolve(terminalRace()); } - let onAbort: (() => void) | undefined; - const terminated = new Promise>((resolve) => { + + // X-AUDIT-01. Task settlement and the terminal event share one settle-once + // state machine, so the outcome is whichever callback actually ran first. + // Draining a fixed number of microtasks to guess whether a promise "had + // already settled" made the answer depend on scheduling rather than on + // observation, and let a rejection overwrite an owner that was fixed + // synchronously before it. + return new Promise>((resolve) => { + let claimed = false; + let onAbort: (() => void) | undefined; + const release = () => { + if (!onAbort) return; + controller.signal.removeEventListener("abort", onAbort); + onAbort = undefined; + }; + const claim = (outcome: AbortRace): boolean => { + if (claimed) return false; + claimed = true; + release(); + resolve(outcome); + return true; + }; + + operation.then( + (value) => { + if (!claim(Object.freeze({ kind: "VALUE" as const, value }))) { + // The work landed after the operation already ended. + compensateOnce(value); + } + }, + (reason: unknown) => { + // A rejection that loses the claim is absorbed here, so it can never + // surface as an unhandled rejection. + claim(Object.freeze({ kind: "REJECTED" as const, reason })); + }, + ); + if (controller.signal.aborted) { - resolve(terminalRace()); + claim(terminalRace()); return; } - onAbort = () => resolve(terminalRace()); + onAbort = () => { + claim(terminalRace()); + }; controller.signal.addEventListener("abort", onAbort, { once: true }); }); - try { - const raced = await Promise.race([settled, terminated]); - if (raced.kind !== "TERMINAL") { - // A value that arrives while a terminal owner already holds the - // operation is late: it is compensated, not admitted. A rejection is - // kept, because it is evidence about the work rather than a claim - // that the work succeeded. - if (raced.kind === "VALUE" && terminalReason !== null) { - compensateOnce(raced.value); - return terminalRace(); - } - return raced; - } - // The terminal owner reached the await first. One microtask drain lets - // an operation that had already settled hand over its outcome; it - // cannot be extended by a collaborator that has not settled. - for (let turn = 0; turn < 4 && landed === null; turn += 1) { - await Promise.resolve(); - } - const settledOutcome = landed as AbortRace | null; - if (settledOutcome !== null) { - if (settledOutcome.kind === "VALUE") { - compensateOnce(settledOutcome.value); - return terminalRace(); - } - return settledOutcome; - } - observeLate(); - return terminalRace(); - } finally { - if (onAbort) { - controller.signal.removeEventListener("abort", onAbort); - } - } }, close() { settle("CLOSED"); diff --git a/tests/unit/abortable-operation.test.ts b/tests/unit/abortable-operation.test.ts index b42a7ae..b246982 100644 --- a/tests/unit/abortable-operation.test.ts +++ b/tests/unit/abortable-operation.test.ts @@ -198,6 +198,165 @@ describe("shared abortable operation mechanics", () => { ).resolves.toEqual({ kind: "TERMINAL", terminal: "CLOSED" }); }); + /** + * X-AUDIT-01. The outcome must be decided by which callback was actually + * observed first, not by how many microtasks the primitive happens to drain + * before it gives up waiting. A terminal owner fixed synchronously owns the + * result even when the task rejects later in the same call stack. + */ + describe("settle-once ownership across callback orderings", () => { + const terminalOwners = [ + { + label: "caller abort", + owner: "CALLER_ABORT" as const, + trigger: (caller: AbortController, operation: { close(): void }, timers: Array<() => void>) => { + void operation; + void timers; + caller.abort(); + }, + }, + { + label: "deadline", + owner: "DEADLINE" as const, + trigger: ( + caller: AbortController, + operation: { close(): void }, + timers: Array<() => void>, + ) => { + void caller; + void operation; + timers.forEach((callback) => callback()); + }, + }, + { + label: "close", + owner: "CLOSED" as const, + trigger: ( + caller: AbortController, + operation: { close(): void }, + timers: Array<() => void>, + ) => { + void caller; + void timers; + operation.close(); + }, + }, + ]; + + const createHarness = () => { + const caller = new AbortController(); + const timers: Array<() => void> = []; + const operation = createAbortableOperation({ + signal: caller.signal, + timeoutMs: 1_000, + setTimer: (callback) => { + timers.push(callback); + return timers.length; + }, + clearTimer: () => {}, + }); + return { caller, timers, operation }; + }; + + for (const { label, owner, trigger } of terminalOwners) { + it(`keeps ${label} as the owner when the task rejects in the same call stack`, async () => { + const { caller, timers, operation } = createHarness(); + let reject: ((reason: unknown) => void) | undefined; + const task = new Promise((_resolve, rejectTask) => { + reject = rejectTask; + }); + const raced = operation.race(task); + + trigger(caller, operation, timers); + expect(operation.terminal()).toBe(owner); + reject?.(new Error("rejected after the terminal owner was fixed")); + + await expect(raced).resolves.toEqual({ kind: "TERMINAL", terminal: owner }); + expect(operation.terminal()).toBe(owner); + }); + + it(`keeps ${label} as the owner regardless of how many microtasks drain`, async () => { + for (const drains of [0, 1, 2, 3, 5, 9]) { + const { caller, timers, operation } = createHarness(); + let reject: ((reason: unknown) => void) | undefined; + const task = new Promise((_resolve, rejectTask) => { + reject = rejectTask; + }); + const raced = operation.race(task); + + trigger(caller, operation, timers); + for (let turn = 0; turn < drains; turn += 1) { + await Promise.resolve(); + } + reject?.(new Error("late rejection")); + + await expect(raced).resolves.toEqual({ + kind: "TERMINAL", + terminal: owner, + }); + } + }); + } + + it("keeps a rejection that was observed before any terminal owner", async () => { + const { caller, operation } = createHarness(); + const reason = new Error("task failed first"); + const raced = operation.race(Promise.reject(reason)); + + // Let the rejection callback actually run before the abort is requested. + await Promise.resolve(); + await Promise.resolve(); + caller.abort(); + + await expect(raced).resolves.toEqual({ kind: "REJECTED", reason }); + }); + + it("keeps a value that was observed before any terminal owner", async () => { + const { caller, operation } = createHarness(); + const compensated: string[] = []; + const raced = operation.race(Promise.resolve("early"), (value) => + compensated.push(value), + ); + + await Promise.resolve(); + await Promise.resolve(); + caller.abort(); + + await expect(raced).resolves.toEqual({ kind: "VALUE", value: "early" }); + expect(compensated).toEqual([]); + }); + + it("never compensates or re-raises a rejection that lands after the owner", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const { caller, operation } = createHarness(); + const compensated: unknown[] = []; + let reject: ((reason: unknown) => void) | undefined; + const task = new Promise((_resolve, rejectTask) => { + reject = rejectTask; + }); + const raced = operation.race(task, (value) => compensated.push(value)); + + caller.abort(); + reject?.(new Error("late rejection")); + await expect(raced).resolves.toEqual({ + kind: "TERMINAL", + terminal: "CALLER_ABORT", + }); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(compensated).toEqual([]); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + }); + it("compensates a late native handle without changing the outcome", async () => { const cancel = vi.fn(async () => {}); compensateLateHandle(Promise.resolve({ body: { cancel } })); diff --git a/tests/unit/image-cdn-runtime.test.ts b/tests/unit/image-cdn-runtime.test.ts index 1f26b3b..e81305f 100644 --- a/tests/unit/image-cdn-runtime.test.ts +++ b/tests/unit/image-cdn-runtime.test.ts @@ -1936,6 +1936,115 @@ describe("browser image probe", () => { expect(close).toHaveBeenCalledOnce(); }); }); + + /** + * X-AUDIT-02. `probe()` promises a `BrowserDataResult`. A scheduler that + * cannot install the probe deadline must close the probe inside that contract + * rather than rejecting it, and must not leave the caller's listener behind. + */ + describe("scheduler boundary", () => { + const trackedSignal = () => { + const controller = new AbortController(); + const added: string[] = []; + const removed: string[] = []; + const add = controller.signal.addEventListener.bind(controller.signal); + const remove = controller.signal.removeEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + added.push(type); + return (add as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + Object.defineProperty(controller.signal, "removeEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + removed.push(type); + return (remove as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + return { controller, added, removed }; + }; + + it("closes the probe when the scheduler cannot install the deadline", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const { controller, added, removed } = trackedSignal(); + const probe = createBrowserImageProbe({ + fetcher: fetcher as unknown as typeof fetch, + createBitmap: vi.fn(), + timeoutMs: 1_000, + scheduler: { + setTimeout: () => { + throw new TypeError("image scheduler install exploded"); + }, + clearTimeout: vi.fn(), + }, + }); + + await expect( + probe.probe({ ...request(), signal: controller.signal }), + ).resolves.toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE", retryable: true, recovery: "RETRY" }, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(added.filter((type) => type === "abort")).toHaveLength(1); + expect(removed.filter((type) => type === "abort")).toHaveLength(1); + }); + + it("starts no timer and no fetch for an already aborted caller", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const setTimeout_ = vi.fn(() => 1); + const controller = new AbortController(); + controller.abort(); + const probe = createBrowserImageProbe({ + fetcher: fetcher as unknown as typeof fetch, + createBitmap: vi.fn(), + timeoutMs: 1_000, + scheduler: { setTimeout: setTimeout_, clearTimeout: vi.fn() }, + }); + + await expect( + probe.probe({ ...request(), signal: controller.signal }), + ).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } }); + expect(fetcher).not.toHaveBeenCalled(); + expect(setTimeout_).not.toHaveBeenCalled(); + }); + + it("keeps the classified outcome when clearing the deadline throws", async () => { + const png = pngBytes(640, 360); + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: publicImageHeaders("image/png", png.byteLength), + })) as typeof fetch, + createBitmap: vi.fn(async () => ({ + width: 640, + height: 360, + close: vi.fn(), + })), + timeoutMs: 1_000, + scheduler: { + setTimeout: (callback: () => void, milliseconds: number) => + setTimeout(callback, milliseconds), + clearTimeout: () => { + throw new TypeError("image scheduler clear exploded"); + }, + }, + }); + + await expect(probe.probe(request())).resolves.toMatchObject({ ok: true }); + }); + }); }); describe("P-256 image capability verifier", () => { diff --git a/tests/unit/resumable-upload-fetch-transport.test.ts b/tests/unit/resumable-upload-fetch-transport.test.ts index 1f183bf..b961cf3 100644 --- a/tests/unit/resumable-upload-fetch-transport.test.ts +++ b/tests/unit/resumable-upload-fetch-transport.test.ts @@ -494,4 +494,158 @@ describe("resumable upload Web Lock", () => { }, ]); }); + + /** + * X-AUDIT-02. The public port promises a `UploadProviderResult`. A scheduler + * that cannot install the attempt deadline must close the attempt inside that + * contract instead of rejecting it, and must not leave the caller listener + * attached to the parent signal. + */ + describe("scheduler boundary", () => { + const trackedSignal = () => { + const controller = new AbortController(); + const added: string[] = []; + const removed: string[] = []; + const add = controller.signal.addEventListener.bind(controller.signal); + const remove = controller.signal.removeEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + added.push(type); + return (add as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + Object.defineProperty(controller.signal, "removeEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + removed.push(type); + return (remove as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + return { controller, added, removed }; + }; + + it("closes the attempt when the scheduler cannot install the deadline", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const { controller, added, removed } = trackedSignal(); + const transport = createTransport(fetcher as unknown as typeof fetch, { + scheduler: { + setTimeout: () => { + throw new TypeError("upload scheduler install exploded"); + }, + clearTimeout: () => {}, + }, + }); + + const result = await transport.execute({ + operation: "GET_STATUS", + body: { sessionId: "session_01" }, + signal: controller.signal, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("UNAVAILABLE"); + expect(result.error.retryable).toBe(true); + } + expect(fetcher).not.toHaveBeenCalled(); + expect(added.filter((type) => type === "abort")).toHaveLength(1); + expect(removed.filter((type) => type === "abort")).toHaveLength(1); + }); + + it("keeps the classified outcome when clearing the deadline throws", async () => { + const fetcher = vi.fn( + async () => + jsonResponseAt(ENDPOINTS.GET_STATUS, { sessionId: "session_01" }), + ); + const { controller, added, removed } = trackedSignal(); + const transport = createTransport(fetcher as unknown as typeof fetch, { + scheduler: { + setTimeout: (callback: () => void, delayMs: number) => + setTimeout(callback, delayMs), + clearTimeout: () => { + throw new TypeError("upload scheduler clear exploded"); + }, + }, + }); + + const result = await transport.execute({ + operation: "GET_STATUS", + body: { sessionId: "session_01" }, + signal: controller.signal, + }); + + expect(result.ok).toBe(true); + expect(added.filter((type) => type === "abort")).toHaveLength(1); + expect(removed.filter((type) => type === "abort")).toHaveLength(1); + }); + + it("uses the scheduler methods captured at construction", async () => { + const fetcher = vi.fn( + async () => + jsonResponseAt(ENDPOINTS.GET_STATUS, { sessionId: "session_01" }), + ); + const scheduler = { + setTimeout: (callback: () => void, delayMs: number) => + setTimeout(callback, delayMs), + clearTimeout: (handle: unknown) => { + clearTimeout(handle as ReturnType); + }, + }; + const transport = createTransport(fetcher as unknown as typeof fetch, { + scheduler, + }); + scheduler.setTimeout = () => { + throw new TypeError("mutated upload setTimeout"); + }; + scheduler.clearTimeout = () => { + throw new TypeError("mutated upload clearTimeout"); + }; + + await expect( + transport.execute({ + operation: "GET_STATUS", + body: { sessionId: "session_01" }, + signal: new AbortController().signal, + }), + ).resolves.toMatchObject({ ok: true }); + }); + + it("starts no timer and no fetch for an already aborted caller", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const setTimeout_ = vi.fn( + (callback: () => void, delayMs: number) => + setTimeout(callback, delayMs) as unknown, + ); + const controller = new AbortController(); + controller.abort(); + const transport = createTransport(fetcher as unknown as typeof fetch, { + scheduler: { + setTimeout: setTimeout_, + clearTimeout: (handle: unknown) => { + clearTimeout(handle as ReturnType); + }, + }, + }); + + const result = await transport.execute({ + operation: "GET_STATUS", + body: { sessionId: "session_01" }, + signal: controller.signal, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe("ABORTED"); + expect(fetcher).not.toHaveBeenCalled(); + expect(setTimeout_).not.toHaveBeenCalled(); + }); + }); });