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
@@ -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<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value>;
|
||||
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<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value> {
|
||||
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<Value>(
|
||||
/**
|
||||
* 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<Value>(
|
||||
task: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value> {
|
||||
return new Promise<Value>((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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<FetchAttemptTerminal>;
|
||||
terminalKind(): FetchAttemptTerminal["kind"] | null;
|
||||
terminalKind(): FetchAttemptTerminalKind | null;
|
||||
race<Value>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>>;
|
||||
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<FetchAttemptTerminal>(
|
||||
(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: <Value,>(
|
||||
task: Promise<Value>,
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Handle>(
|
||||
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<Value>(
|
||||
race<Value>(
|
||||
operation: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>> {
|
||||
// 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<Value> | null = null;
|
||||
const settled: Promise<AbortRace<Value>> = 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<Value> =>
|
||||
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<AbortRace<Value>>((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<AbortRace<Value>>((resolve) => {
|
||||
let claimed = false;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const release = () => {
|
||||
if (!onAbort) return;
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
onAbort = undefined;
|
||||
};
|
||||
const claim = (outcome: AbortRace<Value>): 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<Value> | 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");
|
||||
|
||||
@@ -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<never>((_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<never>((_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<never>((_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 } }));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
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<typeof setTimeout>);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user