fix: put presigned transfer work inside one owned abort scope
TR-RR-05. The shared abortable-operation primitive now distinguishes VALUE, REJECTED and TERMINAL, so a collaborator's own rejection is no longer forged into a cancellation and race() always names the same first owner terminal() reports. The caller signal and scheduler are captured once, so replacing a method after construction cannot change how an in-flight operation is bounded. A scheduler that cannot install the deadline is itself terminal: previously it released the caller listener and left no owner, which made every later abort invisible. Late values are compensated exactly once. Both presigned subsystems, which each carried their own copy of these mechanics, are now projections of that primitive — giving it real production importers rather than a shared helper nobody used. TR-RR-01. close() on an active download aborts the scope instead of only dropping listeners, so a fetch or read already in flight actually stops. The consumer's stream signal joins the operation's ownership before any I/O begins, so an already-aborted consumer no longer causes one network request first. TR-RR-02. The upload abort scope is created before the digest, and the digest races the caller and the deadline like every other step. A non-settling hash can no longer hold put() open, and the vault claim and the network call happen only after the owner is re-checked. TR-RR-03. A capability registration is a versioned exact union validated at registration time: the protocol version, HTTPS only, exact own-data fields, a 2xx expected status, and no ambient credential or cookie header — a presigned URL carries its own authorization, and a session header alongside it would send the user's credentials to that origin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c0f53d1855
commit
46e067e555
@@ -10,6 +10,7 @@ import type {
|
||||
PresignedUploadPartCapability,
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import { createAbortableOperation } from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -637,6 +638,9 @@ function validateCapabilityPayload(
|
||||
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
// TR-RR-03. The registration carries the negotiated protocol version so
|
||||
// the vault validates the same shape the executor later reads.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt,
|
||||
method: context.expected.method,
|
||||
binding,
|
||||
@@ -876,74 +880,87 @@ function statusFailure(
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-capability-scope-ended");
|
||||
|
||||
function compensateLateResponse(task: Promise<unknown>): void {
|
||||
void task
|
||||
.then(async (value) => {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null })
|
||||
?.body;
|
||||
await body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-01 / TR-RR-05. The abort scope is a thin projection of the shared
|
||||
* `createAbortableOperation` primitive into this subsystem's vocabulary.
|
||||
*
|
||||
* Two things changed with the migration. `abort()` exists, so `close()` on a
|
||||
* download can actually stop a pending fetch instead of only dropping
|
||||
* listeners; and additional ownership signals — a consumer's stream signal —
|
||||
* are composed into the same operation *before* any I/O starts, so an
|
||||
* already-aborted consumer never causes a fetch.
|
||||
*/
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
try {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
let timer: unknown;
|
||||
try {
|
||||
timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, timeoutMs);
|
||||
} catch {
|
||||
// BT-PRE-03. A throwing scheduler must not leak the external listener.
|
||||
releaseListener();
|
||||
timer = undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
/** BT-PRE-03. Bounds a fetch that ignores its signal. */
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
task.catch(() => {});
|
||||
if (controller.signal.aborted) {
|
||||
compensateLateResponse(task);
|
||||
return SCOPE_ENDED;
|
||||
}
|
||||
const ended = new Promise<typeof SCOPE_ENDED>((resolve) => {
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(SCOPE_ENDED),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const raced = await Promise.race([task, ended]);
|
||||
if (raced === SCOPE_ENDED) compensateLateResponse(task);
|
||||
return raced;
|
||||
},
|
||||
release() {
|
||||
try {
|
||||
if (timer !== undefined) scheduler.clearTimeout(timer as never);
|
||||
} catch {
|
||||
// Cleanup failures cannot change the classified result.
|
||||
}
|
||||
releaseListener();
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: (callback, delayMs) => scheduler.setTimeout(callback, delayMs),
|
||||
clearTimer: (handle) => {
|
||||
scheduler.clearTimeout(handle as never);
|
||||
},
|
||||
});
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
if (!extra) continue;
|
||||
if (extra.aborted) {
|
||||
operation.close();
|
||||
continue;
|
||||
}
|
||||
const onAbort = () => operation.close();
|
||||
try {
|
||||
extra.addEventListener("abort", onAbort, { once: true });
|
||||
releases.push(() => {
|
||||
try {
|
||||
extra.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// A signal that refuses a listener cannot bound this operation, so the
|
||||
// operation closes rather than running unbounded.
|
||||
operation.close();
|
||||
}
|
||||
}
|
||||
const releaseExtras = () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
};
|
||||
return Object.freeze({
|
||||
signal: operation.signal,
|
||||
timedOut: () => operation.terminal() === "DEADLINE",
|
||||
/** Bounds a fetch that ignores its signal. */
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
const outcome = await operation.race(
|
||||
task,
|
||||
(value) => compensateLateResponseValue(value),
|
||||
);
|
||||
if (outcome.kind === "VALUE") return outcome.value;
|
||||
// A collaborator's own rejection stays a rejection: the caller's existing
|
||||
// catch classifies it, and it is never forged into a cancellation.
|
||||
if (outcome.kind === "REJECTED") throw outcome.reason;
|
||||
return SCOPE_ENDED;
|
||||
},
|
||||
/** TR-RR-01. Ends the physical work, not just the bookkeeping. */
|
||||
abort() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
release() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function compensateLateResponseValue(value: unknown): void {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null } | null)
|
||||
?.body;
|
||||
void body?.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
function transferFailure(
|
||||
|
||||
Reference in New Issue
Block a user