fix: bound browser transfer leases and effect reporting

BT-X-01: add the shared abortable-operation utility with golden tests for first
terminal owner, idempotent close, listener and timer cleanup under a throwing
scheduler, observed late rejection and late-handle compensation. It carries no
subsystem result taxonomy.

BT-PRE-01: make the presigned download lease lazy and single-start. open() now
validates, claims and consumes the capability without any network I/O; the
fetch, the transfer deadline and the expiry recheck happen at first stream
consumption. The source gained close(), which discards an unused lease with no
I/O and otherwise cancels the body and releases the scope exactly once.

BT-UP-01: require removeEventListener in the AbortSignal structural guard and
isolate release cleanup so a hostile facade cannot replace a typed terminal
result with a rejection.

BT-UP-03: deleteDatabase cannot be cancelled after dispatch, so a blocked
deadline now returns PENDING with effect UNKNOWN instead of a failure that reads
as NOT_APPLIED. A realm-scoped (factory, databaseName) registry prevents
recreating the partition until the native request settles.

BT-UP-04: reject non-finite and negative upload clocks as a dependency failure
instead of letting them bypass every capability expiry comparison.

BT-IMG-02: replace the naive Cache-Control quote stripping with a quote- and
escape-aware tokenizer, so max-age="60 or 60" is no longer read as 60 and a
comma inside a quoted extension is not a directive boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:13:54 +09:00
co-authored by Claude Opus 5
parent 8f67974f68
commit cc4e875c2d
13 changed files with 831 additions and 134 deletions
@@ -116,23 +116,23 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
| --- | --- | --- | --- | --- | --- | --- |
| BT-PRE-01 | Presigned `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | download lease leak | — |
| BT-PRE-01 | Presigned `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: lazy presigned download leases` | `FIXED_NOT_RELEASED` | download lease leak | Red lazy-lease cases → green 29/29; `open()` performs no network I/O and `close()` is idempotent |
| BT-PRE-02 | Wire contract gap | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | provider `POLICY_REJECTED` spike | — |
| BT-PRE-03 | Presigned provider | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | timeout not bounding fetch | — |
| BT-PRE-04 | Presigned vault | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | issuer/consumer split break | — |
| BT-PRE-05 | Provider path decoding | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | legitimate key rejection | — |
| BT-UP-01 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | — | `NOT_STARTED` | signal facade rejection | — |
| BT-UP-01 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | signal facade rejection | `isAbortSignal` now requires `removeEventListener` and release cleanup is isolated |
| BT-UP-02 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | — | `NOT_STARTED` | clock injection break | — |
| BT-UP-03 | Resumable checkpoint store | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | — | `NOT_STARTED` | pending-delete registry growth | — |
| BT-UP-04 | Presigned part executor | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | — | `NOT_STARTED` | expiry check rejection | — |
| BT-UP-03 | Resumable checkpoint store | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: report unknown IndexedDB delete effects` | `FIXED_NOT_RELEASED` | pending-delete registry growth | Red blocked-deadline case → green `PENDING`/`UNKNOWN`; a realm-scoped registry blocks recreating the partition |
| BT-UP-04 | Presigned part executor | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | expiry check rejection | Non-finite and negative clocks return `UNAVAILABLE`/`RESUME` instead of bypassing expiry |
| BT-UP-05 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — |
| BT-UP-06 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | drain not quiescent | — |
| BT-UP-07 | Documented gap (Web Locks matrix) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
| BT-IMG-01 | Type-contract change | `corepack pnpm check:types:test` fixture | — | `NOT_STARTED` | caller compile break | — |
| BT-IMG-02 | Image probe | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_STARTED` | Cache-Control parse rejection | — |
| BT-IMG-02 | Image probe | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | `fix: parse Cache-Control with quote awareness` | `FIXED_NOT_RELEASED` | Cache-Control parse rejection | Red unmatched-quote cases → green 25/25 |
| BT-IMG-03 | Refactor | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — |
| BT-IMG-04 | Documented gap (descriptor provider) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
| BT-X-01 | Shared abort mechanics | `corepack pnpm exec vitest run tests/unit/abortable-operation.test.ts` | — | `NOT_STARTED` | late-result compensation regression | — |
| BT-X-01 | Shared abort mechanics | `corepack pnpm exec vitest run tests/unit/abortable-operation.test.ts` | `fix: share abort and deadline mechanics` | `FIXED_NOT_RELEASED` | late-result compensation regression | Golden suite 8/8: first terminal owner, idempotent close, throwing scheduler, observed late rejection, late-handle compensation |
### Service Worker and Web Push (`docs/reviews/adapters/05-service-worker-and-web-push.md`)
@@ -336,6 +336,77 @@ function validResponseHeaders(
);
}
/**
* BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer.
*
* A naive comma split plus `replace(/^"|"$/g, "")` accepted `max-age="60` and
* `max-age=60"` as the number 60, so a malformed policy could be approved as an
* immutable public response. A comma inside a quoted extension value is also
* not a directive boundary.
*/
function splitCacheControlDirectives(value: string): string[] | null {
const parts: string[] = [];
let current = "";
let inQuotes = false;
let escaped = false;
for (const character of value) {
if (escaped) {
const code = character.codePointAt(0) ?? 0;
// quoted-pair may not carry a bare control character.
if (code <= 0x1f || code === 0x7f) return null;
current += character;
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
current += character;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
current += character;
continue;
}
if (character === "," && !inQuotes) {
parts.push(current);
current = "";
continue;
}
current += character;
}
// An unterminated quoted-string or a dangling escape is malformed.
if (inQuotes || escaped) return null;
parts.push(current);
return parts;
}
function unquoteCacheControlValue(rawValue: string): string | null {
if (!rawValue.startsWith('"')) {
// A bare value may not contain a quote at all.
return rawValue.includes('"') ? null : rawValue;
}
if (rawValue.length < 2 || !rawValue.endsWith('"')) return null;
const inner = rawValue.slice(1, -1);
let unquoted = "";
let escaped = false;
for (const character of inner) {
if (escaped) {
unquoted += character;
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
// An unescaped quote inside the string means the quoting is unbalanced.
if (character === '"') return null;
unquoted += character;
}
return escaped ? null : unquoted;
}
function parseCacheControl(
value: string | null,
): ReadonlyMap<string, string | true> | null {
@@ -348,7 +419,10 @@ function parseCacheControl(
"public",
]);
const directives = new Map<string, string | true>();
for (const part of value?.split(",") ?? []) {
if (value === null) return directives;
const parts = splitCacheControlDirectives(value);
if (!parts) return null;
for (const part of parts) {
const trimmedPart = part.trim();
const separator = trimmedPart.indexOf("=");
const name = (
@@ -367,7 +441,9 @@ function parseCacheControl(
if (flagDirectives.has(name)) return null;
const rawValue = trimmedPart.slice(separator + 1).trim();
if (rawValue === "") return null;
directives.set(name, rawValue.replace(/^"|"$/gu, ""));
const unquoted = unquoteCacheControlValue(rawValue);
if (unquoted === null) return null;
directives.set(name, unquoted);
}
return directives;
}
@@ -154,41 +154,34 @@ export function createPresignedTransferExecutor(
const consumed = consume(capability);
if (!consumed.ok) return consumed;
const scope = createAbortScope(signal, timeoutMs, scheduler);
try {
const response = await fetcher(binding.href, {
method: "GET",
headers: headersFor(binding),
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
});
const validated = validateDownloadResponse(
response,
binding,
);
if (!validated.ok) {
cancelBody(response);
scope.release();
return validated;
}
const source = createDownloadSource({
response,
binding,
capability,
externalSignal: signal,
scope,
hardMaxChunkBytes,
createVerifier,
observer,
});
return browserDataSuccess(source);
} catch {
scope.release();
return transferFailure(signal, scope.timedOut());
}
// BT-PRE-01. The lease is lazy and single-start: `open()` performs no
// network I/O, so the transfer deadline begins at first consumption and an
// unused source can be discarded through `close()` without leaking a body,
// a timer or a listener.
const source = createDownloadSource({
start: async (scope) =>
await fetcher(binding.href, {
method: "GET",
headers: headersFor(binding),
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
}),
validateResponse: (response) =>
validateDownloadResponse(response, binding),
createScope: () => createAbortScope(signal, timeoutMs, scheduler),
recheckExpiry: () =>
validateExpiry(capability, minimumRemainingLifetimeMs, now()),
binding,
capability,
externalSignal: signal,
hardMaxChunkBytes,
createVerifier,
observer,
});
return browserDataSuccess(source);
}
async function putUploadPart(
@@ -406,29 +399,53 @@ export function createPresignedTransferExecutor(
}
function createDownloadSource(input: Readonly<{
response: Response;
start: (
scope: ReturnType<typeof createAbortScope>,
) => Promise<Response>;
validateResponse: (response: Response) => BrowserDataResult<unknown>;
createScope: () => ReturnType<typeof createAbortScope>;
recheckExpiry: () => BrowserDataResult<unknown>;
binding: PresignedCapabilityBinding;
capability: PresignedDownloadCapability;
externalSignal: AbortSignal;
scope: ReturnType<typeof createAbortScope>;
hardMaxChunkBytes: number;
createVerifier: (
expectedSha256: string,
) => StreamingSha256Verifier;
observer: BrowserDataObserver | undefined;
}>): PresignedDownloadByteSource {
let started = false;
/** BT-PRE-01. One state machine shared by `stream()` and `close()`. */
let state: "READY" | "STREAMING" | "CLOSED" = "READY";
let activeScope: ReturnType<typeof createAbortScope> | undefined;
let activeResponse: Response | undefined;
const releaseActive = () => {
if (activeResponse) {
cancelBody(activeResponse);
activeResponse = undefined;
}
if (activeScope) {
activeScope.release();
activeScope = undefined;
}
};
return Object.freeze({
byteLength: input.capability.byteLength,
capability: input.capability,
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
close() {
// READY -> CLOSED performs no I/O; STREAMING -> CLOSED cancels once.
if (state === "CLOSED") return;
state = "CLOSED";
releaseActive();
},
async *stream(
consumerSignal: AbortSignal,
): AsyncIterable<BrowserDataResult<Uint8Array>> {
if (!isAbortSignal(consumerSignal)) {
started = true;
cancelBody(input.response);
input.scope.release();
state = "CLOSED";
releaseActive();
const failure = browserDataFailure(
"INVALID_INPUT",
"PRESIGNED_TRANSFER",
@@ -437,7 +454,7 @@ function createDownloadSource(input: Readonly<{
yield failure;
return;
}
if (started) {
if (state !== "READY") {
const failure = browserDataFailure(
"CONFLICT",
"PRESIGNED_TRANSFER",
@@ -449,7 +466,45 @@ function createDownloadSource(input: Readonly<{
yield failure;
return;
}
started = true;
state = "STREAMING";
// The capability may have expired while the lease sat unused.
const stillActive = input.recheckExpiry();
if (!stillActive.ok) {
state = "CLOSED";
observeTransferResult(
input.observer,
"DOWNLOAD",
stillActive,
0,
);
yield stillActive as BrowserDataResult<never>;
return;
}
const scope = input.createScope();
activeScope = scope;
let response: Response;
try {
response = await input.start(scope);
} catch {
state = "CLOSED";
releaseActive();
const failure = transferFailure(
input.externalSignal,
scope.timedOut(),
);
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
yield failure;
return;
}
activeResponse = response;
const validated = input.validateResponse(response);
if (!validated.ok) {
state = "CLOSED";
releaseActive();
observeTransferResult(input.observer, "DOWNLOAD", validated, 0);
yield validated as BrowserDataResult<never>;
return;
}
let combined:
| ReturnType<typeof combineConsumerAbort>
| undefined;
@@ -471,13 +526,13 @@ function createDownloadSource(input: Readonly<{
};
try {
combined = combineConsumerAbort(
input.scope,
scope,
consumerSignal,
);
const verifier = input.createVerifier(
input.capability.expectedSha256,
);
if (!input.response.body) {
if (!response.body) {
let verified = false;
try {
verified =
@@ -492,7 +547,7 @@ function createDownloadSource(input: Readonly<{
yield fail("INTEGRITY_FAILED");
return;
}
reader = input.response.body.getReader();
reader = response.body.getReader();
while (true) {
if (
input.externalSignal.aborted ||
@@ -501,7 +556,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED");
return;
}
if (input.scope.timedOut()) {
if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -510,7 +565,7 @@ function createDownloadSource(input: Readonly<{
}
const result = await readWithSignal(
reader,
input.scope.signal,
scope.signal,
);
if (result.done) break;
const chunk = result.value;
@@ -544,7 +599,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED");
return;
}
if (input.scope.timedOut()) {
if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -590,7 +645,7 @@ function createDownloadSource(input: Readonly<{
consumerSignal.aborted
) {
yield fail("ABORTED");
} else if (input.scope.timedOut()) {
} else if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -605,14 +660,18 @@ function createDownloadSource(input: Readonly<{
combined?.release();
if (!completed) {
if (reader) cancelReader(reader);
else cancelBody(input.response);
else cancelBody(response);
}
try {
reader?.releaseLock();
} catch {
// Reader cleanup cannot change stream success or failure.
}
input.scope.release();
// The lease is terminal once its single stream ends; cleanup runs once.
state = "CLOSED";
activeResponse = undefined;
activeScope = undefined;
scope.release();
observeBrowserData(input.observer, {
operation: "DOWNLOAD",
outcome: completed
@@ -576,8 +576,18 @@ function createFetchAttempt(
terminal,
terminalKind: () => terminalKind,
release() {
clearTimeout(timer);
parent.removeEventListener("abort", abort);
// BT-UP-01. Cleanup is best effort and must never replace the already
// classified terminal result with a rejection.
try {
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.
}
},
});
}
@@ -667,12 +677,18 @@ function boundedPositiveInteger(
return value;
}
/**
* BT-UP-01. The structural guard must cover every method cleanup will call.
* Admitting a signal without `removeEventListener` turned a `finally` into a
* Promise rejection instead of the typed terminal result.
*/
function isAbortSignal(value: unknown): value is AbortSignal {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as AbortSignal).aborted === "boolean" &&
typeof (value as AbortSignal).addEventListener === "function",
typeof (value as AbortSignal).addEventListener === "function" &&
typeof (value as AbortSignal).removeEventListener === "function",
);
}
@@ -2,6 +2,7 @@ import type {
ResumableUploadCheckpointAdmin,
ResumableUploadCheckpoint,
ResumableUploadCheckpointStore,
PartitionDeleteOutcome,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import {
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
const GOVERNANCE_KEY = "scope-binding";
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
/**
* BT-UP-03. Per-realm registry keyed by `(IDBFactory identity, databaseName)`.
* It prevents this realm from recreating a store whose deletion is still in
* flight. It deliberately claims nothing about other realms, which are handled
* by native blocked ordering and explicit recovery.
*/
const PENDING_DELETIONS = new WeakMap<object, Set<string>>();
function pendingDeletionsFor(factory: unknown): Set<string> {
const key = (factory ?? PENDING_DELETIONS) as object;
let pending = PENDING_DELETIONS.get(key);
if (!pending) {
pending = new Set<string>();
PENDING_DELETIONS.set(key, pending);
}
return pending;
}
export type IndexedDbUploadCheckpointScope = Readonly<{
authorityToken: string;
namespaceToken: string;
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
? factory.deleteDatabase.bind(factory)
: undefined;
const databaseName = uploadCheckpointDatabaseName(scope);
const pendingDeletions = pendingDeletionsFor(factory);
if (pendingDeletions.has(databaseName)) {
// BT-UP-03. A deletion dispatched by this realm has not settled, so a new
// store over the same database would race an unknown native effect.
throw new TypeError(
"Upload checkpoint partition has an unresolved pending deletion.",
);
}
const expectedBinding: ScopeBinding = Object.freeze({
key: GOVERNANCE_KEY,
schemaVersion: 1,
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
async deletePartition(
signal?: AbortSignal,
): Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
BrowserDataResult<PartitionDeleteOutcome>
> {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
} catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
}
return await new Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
>((resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>,
) => {
if (settled) return;
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
// intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
// BT-UP-03. Once dispatched the deletion may still commit after this
// call returns, so the pending registration is installed before the
// promise settles and is only released by the real native completion.
pendingDeletions.add(databaseName);
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
(resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<PartitionDeleteOutcome>,
) => {
if (settled) return;
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
const releasePending = () => {
pendingDeletions.delete(databaseName);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal
// is intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
// Not NOT_APPLIED: the request is still live in the browser.
finish(
browserDataSuccess(
Object.freeze({
state: "PENDING" as const,
effect: "UNKNOWN" as const,
reason: "BLOCKED_DEADLINE" as const,
}),
),
);
}, blockedTimeoutMs);
};
request.onerror = () => {
releasePending();
finish(
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
}),
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
}, blockedTimeoutMs);
};
request.onerror = () =>
finish(
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
request.onsuccess = () =>
finish(
browserDataSuccess(
Object.freeze({ state: "DELETED" as const }),
),
);
});
};
request.onsuccess = () => {
releasePending();
finish(
browserDataSuccess(
Object.freeze({
state: "DELETED" as const,
effect: "APPLIED" as const,
}),
),
);
};
},
);
},
};
const admin = Object.freeze(adminValue);
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
recovery: "RESUME",
});
}
// BT-UP-04. A NaN or negative clock silently bypasses every expiry
// comparison, and an infinite one misreports a dependency failure as a
// capability policy failure. Both are dependency failures.
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0) {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if (
!capability ||
capability.method !== "PUT" ||
@@ -0,0 +1,190 @@
/**
* BT-X-01. Shared abort and deadline mechanics.
*
* Several adapters independently reimplemented "race a promise against a
* caller signal and a deadline, then clean up listeners and timers". Only the
* mechanics are shared here; every subsystem keeps its own result taxonomy and
* recovery vocabulary, so this module deliberately imports none of them and is
* not a generic middleware layer.
*/
export type AbortTerminalReason = "CALLER_ABORT" | "DEADLINE" | "CLOSED";
export type AbortRace<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "TERMINAL"; terminal: AbortTerminalReason }>;
export type AbortableOperation = Readonly<{
/** The composed signal: caller abort, deadline and close all feed it. */
readonly signal: AbortSignal;
/**
* The first terminal owner, or `null` while the operation is still live.
* This is a live accessor, not a snapshot.
*/
terminal(): AbortTerminalReason | null;
/**
* Resolves with the operation's value, or with the first terminal owner.
* A terminal race never returns a bare value.
*/
race<Value>(operation: Promise<Value>): Promise<AbortRace<Value>>;
/**
* Idempotent. Removes listeners, clears the deadline timer and marks the
* operation `CLOSED` if nothing terminal happened first.
*/
close(): void;
}>;
export type AbortableOperationInput = Readonly<{
signal?: AbortSignal;
timeoutMs?: number;
/** Scheduler seam; a throwing scheduler must not leak a listener. */
setTimer?: (callback: () => void, delayMs: number) => unknown;
clearTimer?: (handle: unknown) => void;
}>;
export function createAbortableOperation(
input: AbortableOperationInput = {},
): AbortableOperation {
const controller = new AbortController();
const setTimer =
input.setTimer ??
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
const clearTimer =
input.clearTimer ??
((handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
});
let terminalReason: AbortTerminalReason | null = null;
let disposed = false;
let timer: unknown;
/** First terminal owner wins; later owners never overwrite it. */
const settle = (reason: AbortTerminalReason) => {
terminalReason ??= reason;
if (!controller.signal.aborted) controller.abort();
};
const onCallerAbort = () => {
settle("CALLER_ABORT");
dispose();
};
function dispose(): void {
if (disposed) return;
disposed = true;
try {
input.signal?.removeEventListener("abort", onCallerAbort);
} catch {
// A hostile signal facade cannot block cleanup of the rest.
}
if (timer !== undefined) {
try {
clearTimer(timer);
} catch {
// A throwing scheduler cannot leave the operation un-disposed.
}
timer = undefined;
}
}
if (input.signal?.aborted) {
settle("CALLER_ABORT");
disposed = true;
} else if (input.signal) {
input.signal.addEventListener("abort", onCallerAbort, { once: true });
}
if (
terminalReason === null &&
input.timeoutMs !== undefined &&
Number.isFinite(input.timeoutMs) &&
input.timeoutMs >= 0
) {
try {
timer = setTimer(() => {
settle("DEADLINE");
dispose();
}, input.timeoutMs);
} catch {
// A scheduler that throws leaves no timer behind; the operation stays
// bounded only by the caller signal.
timer = undefined;
dispose();
}
}
return Object.freeze({
signal: controller.signal,
terminal: () => terminalReason,
async race<Value>(operation: Promise<Value>): Promise<AbortRace<Value>> {
// Observe a late rejection so an abandoned operation cannot surface as an
// unhandled rejection.
operation.catch(() => {});
if (terminalReason !== null) {
return Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason,
});
}
const raced = await Promise.race([
operation.then(
(value) => Object.freeze({ kind: "VALUE" as const, value }),
() =>
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
),
new Promise<AbortRace<Value>>((resolve) => {
if (controller.signal.aborted) {
resolve(
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
);
return;
}
controller.signal.addEventListener(
"abort",
() =>
resolve(
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
),
{ once: true },
);
}),
]);
// A value that arrives after a terminal owner is not admitted.
return terminalReason !== null && raced.kind === "VALUE"
? Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason,
})
: raced;
},
close() {
settle("CLOSED");
dispose();
},
});
}
/**
* Compensates a native handle that arrives after the operation ended. The
* compensation itself is best effort and can never change the already selected
* outcome.
*/
export function compensateLateHandle(
handle: Promise<Readonly<{ body?: { cancel(): Promise<void> } | null }> | null>,
): void {
void handle
.then(async (value) => {
await value?.body?.cancel();
})
.catch(() => undefined);
}
@@ -113,6 +113,13 @@ export type PresignedDownloadByteSource = FileByteSource &
byteLength: number;
capability: PresignedDownloadCapability;
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
/**
* BT-PRE-01. Discards the lease. Before the first `stream()` this performs
* no network I/O at all; during streaming it cancels the body and releases
* the timer and listeners exactly once. Every consumer must call it in a
* `finally`, including on a pre-stream failure.
*/
close(): void;
}>;
export interface PresignedDownloadSourcePort {
@@ -40,6 +40,14 @@ export type UploadPartReceipt = UploadPartDescriptor &
receiptToken: string;
}>;
export type PartitionDeleteOutcome =
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
| Readonly<{
state: "PENDING";
effect: "UNKNOWN";
reason: "BLOCKED_DEADLINE";
}>;
export interface UploadRangeReader {
readonly byteLength: number;
readRange(input: Readonly<{
@@ -273,7 +281,13 @@ export interface ResumableUploadCheckpointAdmin {
* Account/logout lifecycle operation for this already-bound opaque partition.
* The adapter closes its connection before deletion and bounds blocked waits.
*/
/**
* BT-UP-03. An IndexedDB `deleteDatabase()` request cannot be cancelled once
* dispatched, so a blocked deadline is not evidence that nothing happened.
* `PENDING` reports the effect honestly as `UNKNOWN`; only pre-dispatch
* problems are ordinary failures.
*/
deletePartition(
signal?: AbortSignal,
): Promise<BrowserDataResult<Readonly<{ state: "DELETED" }>>>;
): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
}
+139
View File
@@ -0,0 +1,139 @@
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");
});
it("observes a late rejection instead of leaking it", async () => {
const operation = createAbortableOperation();
const rejected = Promise.reject(new Error("late"));
await expect(operation.race(rejected)).resolves.toEqual({
kind: "TERMINAL",
terminal: "CLOSED",
});
});
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);
});
it("survives a throwing scheduler without leaking a listener", () => {
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()).toBeNull();
expect(remove).toHaveBeenCalledTimes(1);
});
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();
});
});
+4
View File
@@ -1479,6 +1479,10 @@ describe("browser image probe", () => {
close: vi.fn(),
}));
for (const cacheControl of [
// BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number.
'public, max-age="31536000, immutable',
'public, max-age=31536000", immutable',
'public, max-age="31536000\\", immutable',
"public, public, max-age=31536000, immutable",
"public, max-age=31536000, s-maxage=60, immutable",
"public, max-age=31536000, immutable, must-revalidate",
+132 -21
View File
@@ -602,13 +602,15 @@ describe("presigned transfer", () => {
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
).toMatchObject({
await expect(
firstStreamResult(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
@@ -621,13 +623,15 @@ describe("presigned transfer", () => {
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
).toMatchObject({
await expect(
firstStreamResult(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
@@ -640,18 +644,101 @@ describe("presigned transfer", () => {
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
).toMatchObject({
await expect(
firstStreamResult(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "INTEGRITY_FAILED" },
});
});
it("does not fetch a presigned download until stream consumption", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
// BT-PRE-01. open() performs no network I/O.
expect(downloadFetches).toBe(0);
for await (const chunk of opened.value.stream(signal)) {
expect(chunk.ok).toBe(true);
}
expect(downloadFetches).toBe(1);
opened.value.close();
});
it("closes an unused download source without network I/O", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
opened.value.close();
// close() is idempotent and never starts the transfer.
opened.value.close();
expect(downloadFetches).toBe(0);
// A stream after close is one terminal conflict, still without fetching.
const results = [];
for await (const chunk of opened.value.stream(signal)) {
results.push(chunk);
}
expect(results).toMatchObject([
{ ok: false, error: { code: "CONFLICT" } },
]);
expect(downloadFetches).toBe(0);
});
it.each([
{
name: "truncation",
@@ -1633,3 +1720,27 @@ describe("presigned transfer", () => {
expect(written).toEqual([...bytes]);
});
});
/**
* BT-PRE-01. The download lease is lazy, so a response-shape rejection is
* observed on first consumption rather than at `open()`.
*/
async function firstStreamResult(
opened: Awaited<
ReturnType<
ReturnType<typeof createHarness>["executor"]["downloadSources"]["open"]
>
>,
): Promise<unknown> {
if (!opened.ok) return opened;
try {
for await (const chunk of opened.value.stream(
new AbortController().signal,
)) {
if (!chunk.ok) return chunk;
}
return { ok: true };
} finally {
opened.value.close();
}
}
+37 -10
View File
@@ -183,7 +183,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
).toMatchObject({ ok: true });
expect(await runtime.admin.deletePartition()).toEqual({
ok: true,
value: { state: "DELETED" },
value: { state: "DELETED", effect: "APPLIED" },
});
expect(await runtime.store.read("upload_key_01")).toMatchObject({
ok: false,
@@ -191,21 +191,48 @@ describe("IndexedDB resumable upload checkpoint", () => {
});
});
it("bounds a partition deletion blocked by another browser context", async () => {
it("returns PENDING UNKNOWN when deleteDatabase is still blocked", async () => {
const memory = new MemoryIndexedDbFactory();
const factory = deletingFactory(memory, "BLOCKED");
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
scope,
factory: deletingFactory(memory, "BLOCKED"),
factory,
blockedTimeoutMs: 1,
});
// BT-UP-03. The native request is still live, so the deadline is not
// evidence that nothing happened.
expect(await runtime.admin.deletePartition()).toEqual({
ok: true,
value: {
state: "PENDING",
effect: "UNKNOWN",
reason: "BLOCKED_DEADLINE",
},
});
});
it("keeps the checkpoint store closed until a pending delete is resolved externally", async () => {
const memory = new MemoryIndexedDbFactory();
const factory = deletingFactory(memory, "BLOCKED");
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
scope,
factory,
blockedTimeoutMs: 1,
});
expect(await runtime.admin.deletePartition()).toMatchObject({
ok: false,
error: {
code: "BLOCKED",
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
},
ok: true,
value: { state: "PENDING" },
});
// A second runtime over the same realm and database would race an unknown
// native effect.
expect(() =>
createIndexedDbResumableUploadCheckpointRuntime({
scope,
factory,
blockedTimeoutMs: 1,
}),
).toThrow(TypeError);
});
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
@@ -219,7 +246,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
controller.abort();
expect(await deletion).toEqual({
ok: true,
value: { state: "DELETED" },
value: { state: "DELETED", effect: "APPLIED" },
});
const preAborted = new AbortController();