Files
clean-architecture-frontend…/tests/unit/abortable-operation.test.ts
T
DongHyeonkaandClaude Opus 5 cc91fc6ae0 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>
2026-08-15 01:24:53 +09:00

372 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
compensateLateHandle,
createAbortableOperation,
} from "../../src/adapters/platform/abortable-operation.ts";
/**
* BT-X-01 golden tests. The utility owns abort mechanics only: it must never
* import or imply a subsystem result taxonomy.
*/
describe("shared abortable operation mechanics", () => {
it("records the first terminal owner and never overwrites it", async () => {
const caller = new AbortController();
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
expect(operation.terminal()).toBeNull();
caller.abort();
expect(operation.terminal()).toBe("CALLER_ABORT");
// A later deadline or close cannot rewrite the owner.
timers.forEach((callback) => callback());
operation.close();
expect(operation.terminal()).toBe("CALLER_ABORT");
});
it("reports a deadline owner and aborts the composed signal", async () => {
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
timeoutMs: 5,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
timers[0]?.();
expect(operation.terminal()).toBe("DEADLINE");
expect(operation.signal.aborted).toBe(true);
});
it("race returns a terminal owner instead of a bare value", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
let release: ((value: string) => void) | undefined;
const pending = new Promise<string>((resolve) => {
release = resolve;
});
const raced = operation.race(pending);
caller.abort();
await expect(raced).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
// The late value is observed and discarded.
release?.("late");
await expect(operation.race(pending)).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
});
it("race returns the value when nothing terminal happened", async () => {
const operation = createAbortableOperation();
await expect(operation.race(Promise.resolve(7))).resolves.toEqual({
kind: "VALUE",
value: 7,
});
operation.close();
expect(operation.terminal()).toBe("CLOSED");
});
/**
* TR-RR-05. A collaborator's own rejection is evidence about the work.
* Reporting it as `TERMINAL/CLOSED` erased the reason the operation failed
* and made `race()` disagree with `terminal()`, which still said no owner.
*/
it("keeps a rejection distinct from a terminal owner", async () => {
const operation = createAbortableOperation();
const reason = new Error("upstream failed");
await expect(
operation.race(Promise.reject(reason)),
).resolves.toEqual({ kind: "REJECTED", reason });
expect(operation.terminal()).toBeNull();
});
it("agrees with terminal() on the first owner", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
const pending = operation.race(new Promise<never>(() => {}));
caller.abort();
// A later close cannot overwrite the first owner in either place.
operation.close();
await expect(pending).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
expect(operation.terminal()).toBe("CALLER_ABORT");
});
it("compensates a late value exactly once instead of admitting it", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
const compensated: string[] = [];
let release: ((value: string) => void) | undefined;
const pending = operation.race(
new Promise<string>((resolve) => {
release = resolve;
}),
(value) => compensated.push(value),
);
caller.abort();
expect(await pending).toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
release?.("late-value");
await Promise.resolve();
await Promise.resolve();
expect(compensated).toEqual(["late-value"]);
});
it("ignores a scheduler method replaced after construction", async () => {
const scheduler = {
setTimer: (callback: () => void, delayMs: number) =>
setTimeout(callback, delayMs),
clearTimer: (handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
},
};
const operation = createAbortableOperation({
timeoutMs: 5,
setTimer: scheduler.setTimer,
clearTimer: scheduler.clearTimer,
});
scheduler.setTimer = () => {
throw new TypeError("replaced after construction");
};
await expect(
operation.race(new Promise<never>(() => {})),
).resolves.toEqual({ kind: "TERMINAL", terminal: "DEADLINE" });
});
it("close is idempotent and releases listeners and timers exactly once", () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const clearTimer = vi.fn();
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => "handle",
clearTimer,
});
operation.close();
operation.close();
operation.close();
expect(remove).toHaveBeenCalledTimes(1);
expect(clearTimer).toHaveBeenCalledTimes(1);
});
/**
* TR-RR-05. A scheduler that cannot install the deadline leaves the operation
* unbounded. Removing the caller listener and leaving no terminal owner made
* every later abort invisible, so installation failure is itself terminal and
* closes atomically with the resources already created.
*/
it("closes atomically when the scheduler cannot install the deadline", async () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => {
throw new TypeError("scheduler exploded");
},
clearTimer: () => {},
});
expect(operation.terminal()).toBe("CLOSED");
expect(remove).toHaveBeenCalledTimes(1);
// The operation is bounded, so a caller abort afterwards cannot be lost.
caller.abort();
await expect(
operation.race(new Promise<never>(() => {})),
).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 } }));
await Promise.resolve();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
// A rejecting handle is swallowed.
compensateLateHandle(Promise.reject(new Error("late")));
await Promise.resolve();
});
});