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:
co-authored by
Claude Opus 5
parent
8f67974f68
commit
cc4e875c2d
@@ -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 |
|
| 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-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-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-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-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-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-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` | — | `NOT_STARTED` | expiry check rejection | — |
|
| 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-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-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-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-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-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-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`)
|
### 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(
|
function parseCacheControl(
|
||||||
value: string | null,
|
value: string | null,
|
||||||
): ReadonlyMap<string, string | true> | null {
|
): ReadonlyMap<string, string | true> | null {
|
||||||
@@ -348,7 +419,10 @@ function parseCacheControl(
|
|||||||
"public",
|
"public",
|
||||||
]);
|
]);
|
||||||
const directives = new Map<string, string | true>();
|
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 trimmedPart = part.trim();
|
||||||
const separator = trimmedPart.indexOf("=");
|
const separator = trimmedPart.indexOf("=");
|
||||||
const name = (
|
const name = (
|
||||||
@@ -367,7 +441,9 @@ function parseCacheControl(
|
|||||||
if (flagDirectives.has(name)) return null;
|
if (flagDirectives.has(name)) return null;
|
||||||
const rawValue = trimmedPart.slice(separator + 1).trim();
|
const rawValue = trimmedPart.slice(separator + 1).trim();
|
||||||
if (rawValue === "") return null;
|
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;
|
return directives;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,41 +154,34 @@ export function createPresignedTransferExecutor(
|
|||||||
const consumed = consume(capability);
|
const consumed = consume(capability);
|
||||||
if (!consumed.ok) return consumed;
|
if (!consumed.ok) return consumed;
|
||||||
|
|
||||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
// BT-PRE-01. The lease is lazy and single-start: `open()` performs no
|
||||||
try {
|
// network I/O, so the transfer deadline begins at first consumption and an
|
||||||
const response = await fetcher(binding.href, {
|
// unused source can be discarded through `close()` without leaking a body,
|
||||||
method: "GET",
|
// a timer or a listener.
|
||||||
headers: headersFor(binding),
|
const source = createDownloadSource({
|
||||||
credentials: "omit",
|
start: async (scope) =>
|
||||||
redirect: "error",
|
await fetcher(binding.href, {
|
||||||
referrerPolicy: "no-referrer",
|
method: "GET",
|
||||||
cache: "no-store",
|
headers: headersFor(binding),
|
||||||
signal: scope.signal,
|
credentials: "omit",
|
||||||
});
|
redirect: "error",
|
||||||
const validated = validateDownloadResponse(
|
referrerPolicy: "no-referrer",
|
||||||
response,
|
cache: "no-store",
|
||||||
binding,
|
signal: scope.signal,
|
||||||
);
|
}),
|
||||||
if (!validated.ok) {
|
validateResponse: (response) =>
|
||||||
cancelBody(response);
|
validateDownloadResponse(response, binding),
|
||||||
scope.release();
|
createScope: () => createAbortScope(signal, timeoutMs, scheduler),
|
||||||
return validated;
|
recheckExpiry: () =>
|
||||||
}
|
validateExpiry(capability, minimumRemainingLifetimeMs, now()),
|
||||||
const source = createDownloadSource({
|
binding,
|
||||||
response,
|
capability,
|
||||||
binding,
|
externalSignal: signal,
|
||||||
capability,
|
hardMaxChunkBytes,
|
||||||
externalSignal: signal,
|
createVerifier,
|
||||||
scope,
|
observer,
|
||||||
hardMaxChunkBytes,
|
});
|
||||||
createVerifier,
|
return browserDataSuccess(source);
|
||||||
observer,
|
|
||||||
});
|
|
||||||
return browserDataSuccess(source);
|
|
||||||
} catch {
|
|
||||||
scope.release();
|
|
||||||
return transferFailure(signal, scope.timedOut());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putUploadPart(
|
async function putUploadPart(
|
||||||
@@ -406,29 +399,53 @@ export function createPresignedTransferExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDownloadSource(input: Readonly<{
|
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;
|
binding: PresignedCapabilityBinding;
|
||||||
capability: PresignedDownloadCapability;
|
capability: PresignedDownloadCapability;
|
||||||
externalSignal: AbortSignal;
|
externalSignal: AbortSignal;
|
||||||
scope: ReturnType<typeof createAbortScope>;
|
|
||||||
hardMaxChunkBytes: number;
|
hardMaxChunkBytes: number;
|
||||||
createVerifier: (
|
createVerifier: (
|
||||||
expectedSha256: string,
|
expectedSha256: string,
|
||||||
) => StreamingSha256Verifier;
|
) => StreamingSha256Verifier;
|
||||||
observer: BrowserDataObserver | undefined;
|
observer: BrowserDataObserver | undefined;
|
||||||
}>): PresignedDownloadByteSource {
|
}>): 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({
|
return Object.freeze({
|
||||||
byteLength: input.capability.byteLength,
|
byteLength: input.capability.byteLength,
|
||||||
capability: input.capability,
|
capability: input.capability,
|
||||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
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(
|
async *stream(
|
||||||
consumerSignal: AbortSignal,
|
consumerSignal: AbortSignal,
|
||||||
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
||||||
if (!isAbortSignal(consumerSignal)) {
|
if (!isAbortSignal(consumerSignal)) {
|
||||||
started = true;
|
state = "CLOSED";
|
||||||
cancelBody(input.response);
|
releaseActive();
|
||||||
input.scope.release();
|
|
||||||
const failure = browserDataFailure(
|
const failure = browserDataFailure(
|
||||||
"INVALID_INPUT",
|
"INVALID_INPUT",
|
||||||
"PRESIGNED_TRANSFER",
|
"PRESIGNED_TRANSFER",
|
||||||
@@ -437,7 +454,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
yield failure;
|
yield failure;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (started) {
|
if (state !== "READY") {
|
||||||
const failure = browserDataFailure(
|
const failure = browserDataFailure(
|
||||||
"CONFLICT",
|
"CONFLICT",
|
||||||
"PRESIGNED_TRANSFER",
|
"PRESIGNED_TRANSFER",
|
||||||
@@ -449,7 +466,45 @@ function createDownloadSource(input: Readonly<{
|
|||||||
yield failure;
|
yield failure;
|
||||||
return;
|
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:
|
let combined:
|
||||||
| ReturnType<typeof combineConsumerAbort>
|
| ReturnType<typeof combineConsumerAbort>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -471,13 +526,13 @@ function createDownloadSource(input: Readonly<{
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
combined = combineConsumerAbort(
|
combined = combineConsumerAbort(
|
||||||
input.scope,
|
scope,
|
||||||
consumerSignal,
|
consumerSignal,
|
||||||
);
|
);
|
||||||
const verifier = input.createVerifier(
|
const verifier = input.createVerifier(
|
||||||
input.capability.expectedSha256,
|
input.capability.expectedSha256,
|
||||||
);
|
);
|
||||||
if (!input.response.body) {
|
if (!response.body) {
|
||||||
let verified = false;
|
let verified = false;
|
||||||
try {
|
try {
|
||||||
verified =
|
verified =
|
||||||
@@ -492,7 +547,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
yield fail("INTEGRITY_FAILED");
|
yield fail("INTEGRITY_FAILED");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reader = input.response.body.getReader();
|
reader = response.body.getReader();
|
||||||
while (true) {
|
while (true) {
|
||||||
if (
|
if (
|
||||||
input.externalSignal.aborted ||
|
input.externalSignal.aborted ||
|
||||||
@@ -501,7 +556,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
yield fail("ABORTED");
|
yield fail("ABORTED");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (input.scope.timedOut()) {
|
if (scope.timedOut()) {
|
||||||
yield fail("UNAVAILABLE", {
|
yield fail("UNAVAILABLE", {
|
||||||
retryable: true,
|
retryable: true,
|
||||||
recovery: "REISSUE_CAPABILITY",
|
recovery: "REISSUE_CAPABILITY",
|
||||||
@@ -510,7 +565,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
}
|
}
|
||||||
const result = await readWithSignal(
|
const result = await readWithSignal(
|
||||||
reader,
|
reader,
|
||||||
input.scope.signal,
|
scope.signal,
|
||||||
);
|
);
|
||||||
if (result.done) break;
|
if (result.done) break;
|
||||||
const chunk = result.value;
|
const chunk = result.value;
|
||||||
@@ -544,7 +599,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
yield fail("ABORTED");
|
yield fail("ABORTED");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (input.scope.timedOut()) {
|
if (scope.timedOut()) {
|
||||||
yield fail("UNAVAILABLE", {
|
yield fail("UNAVAILABLE", {
|
||||||
retryable: true,
|
retryable: true,
|
||||||
recovery: "REISSUE_CAPABILITY",
|
recovery: "REISSUE_CAPABILITY",
|
||||||
@@ -590,7 +645,7 @@ function createDownloadSource(input: Readonly<{
|
|||||||
consumerSignal.aborted
|
consumerSignal.aborted
|
||||||
) {
|
) {
|
||||||
yield fail("ABORTED");
|
yield fail("ABORTED");
|
||||||
} else if (input.scope.timedOut()) {
|
} else if (scope.timedOut()) {
|
||||||
yield fail("UNAVAILABLE", {
|
yield fail("UNAVAILABLE", {
|
||||||
retryable: true,
|
retryable: true,
|
||||||
recovery: "REISSUE_CAPABILITY",
|
recovery: "REISSUE_CAPABILITY",
|
||||||
@@ -605,14 +660,18 @@ function createDownloadSource(input: Readonly<{
|
|||||||
combined?.release();
|
combined?.release();
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
if (reader) cancelReader(reader);
|
if (reader) cancelReader(reader);
|
||||||
else cancelBody(input.response);
|
else cancelBody(response);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reader?.releaseLock();
|
reader?.releaseLock();
|
||||||
} catch {
|
} catch {
|
||||||
// Reader cleanup cannot change stream success or failure.
|
// 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, {
|
observeBrowserData(input.observer, {
|
||||||
operation: "DOWNLOAD",
|
operation: "DOWNLOAD",
|
||||||
outcome: completed
|
outcome: completed
|
||||||
|
|||||||
@@ -576,8 +576,18 @@ function createFetchAttempt(
|
|||||||
terminal,
|
terminal,
|
||||||
terminalKind: () => terminalKind,
|
terminalKind: () => terminalKind,
|
||||||
release() {
|
release() {
|
||||||
clearTimeout(timer);
|
// BT-UP-01. Cleanup is best effort and must never replace the already
|
||||||
parent.removeEventListener("abort", abort);
|
// 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;
|
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 {
|
function isAbortSignal(value: unknown): value is AbortSignal {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
value &&
|
value &&
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
typeof (value as AbortSignal).aborted === "boolean" &&
|
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,
|
ResumableUploadCheckpointAdmin,
|
||||||
ResumableUploadCheckpoint,
|
ResumableUploadCheckpoint,
|
||||||
ResumableUploadCheckpointStore,
|
ResumableUploadCheckpointStore,
|
||||||
|
PartitionDeleteOutcome,
|
||||||
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||||
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
|
|||||||
const GOVERNANCE_KEY = "scope-binding";
|
const GOVERNANCE_KEY = "scope-binding";
|
||||||
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
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<{
|
export type IndexedDbUploadCheckpointScope = Readonly<{
|
||||||
authorityToken: string;
|
authorityToken: string;
|
||||||
namespaceToken: string;
|
namespaceToken: string;
|
||||||
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
? factory.deleteDatabase.bind(factory)
|
? factory.deleteDatabase.bind(factory)
|
||||||
: undefined;
|
: undefined;
|
||||||
const databaseName = uploadCheckpointDatabaseName(scope);
|
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({
|
const expectedBinding: ScopeBinding = Object.freeze({
|
||||||
key: GOVERNANCE_KEY,
|
key: GOVERNANCE_KEY,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
async deletePartition(
|
async deletePartition(
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<
|
): Promise<
|
||||||
BrowserDataResult<Readonly<{ state: "DELETED" }>>
|
BrowserDataResult<PartitionDeleteOutcome>
|
||||||
> {
|
> {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||||
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
||||||
}
|
}
|
||||||
return await new Promise<
|
// BT-UP-03. Once dispatched the deletion may still commit after this
|
||||||
BrowserDataResult<Readonly<{ state: "DELETED" }>>
|
// call returns, so the pending registration is installed before the
|
||||||
>((resolve) => {
|
// promise settles and is only released by the real native completion.
|
||||||
let settled = false;
|
pendingDeletions.add(databaseName);
|
||||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
|
||||||
const finish = (
|
(resolve) => {
|
||||||
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>,
|
let settled = false;
|
||||||
) => {
|
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
if (settled) return;
|
const finish = (
|
||||||
settled = true;
|
result: BrowserDataResult<PartitionDeleteOutcome>,
|
||||||
if (blockedTimer) clearTimeout(blockedTimer);
|
) => {
|
||||||
resolve(result);
|
if (settled) return;
|
||||||
};
|
settled = true;
|
||||||
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
|
if (blockedTimer) clearTimeout(blockedTimer);
|
||||||
// intentionally observed only before dispatch so the adapter never
|
resolve(result);
|
||||||
// reports ABORTED while deletion may still commit.
|
};
|
||||||
request.onblocked = () => {
|
const releasePending = () => {
|
||||||
blockedTimer = setTimeout(() => {
|
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(
|
finish(
|
||||||
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
|
mapBrowserDataException(
|
||||||
retryable: true,
|
request.error,
|
||||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
"UPLOAD_RECONCILE",
|
||||||
}),
|
),
|
||||||
);
|
);
|
||||||
}, blockedTimeoutMs);
|
};
|
||||||
};
|
request.onsuccess = () => {
|
||||||
request.onerror = () =>
|
releasePending();
|
||||||
finish(
|
finish(
|
||||||
mapBrowserDataException(
|
browserDataSuccess(
|
||||||
request.error,
|
Object.freeze({
|
||||||
"UPLOAD_RECONCILE",
|
state: "DELETED" as const,
|
||||||
),
|
effect: "APPLIED" as const,
|
||||||
);
|
}),
|
||||||
request.onsuccess = () =>
|
),
|
||||||
finish(
|
);
|
||||||
browserDataSuccess(
|
};
|
||||||
Object.freeze({ state: "DELETED" as const }),
|
},
|
||||||
),
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const admin = Object.freeze(adminValue);
|
const admin = Object.freeze(adminValue);
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
|
|||||||
recovery: "RESUME",
|
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 (
|
if (
|
||||||
!capability ||
|
!capability ||
|
||||||
capability.method !== "PUT" ||
|
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;
|
byteLength: number;
|
||||||
capability: PresignedDownloadCapability;
|
capability: PresignedDownloadCapability;
|
||||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
|
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 {
|
export interface PresignedDownloadSourcePort {
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export type UploadPartReceipt = UploadPartDescriptor &
|
|||||||
receiptToken: string;
|
receiptToken: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type PartitionDeleteOutcome =
|
||||||
|
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
|
||||||
|
| Readonly<{
|
||||||
|
state: "PENDING";
|
||||||
|
effect: "UNKNOWN";
|
||||||
|
reason: "BLOCKED_DEADLINE";
|
||||||
|
}>;
|
||||||
|
|
||||||
export interface UploadRangeReader {
|
export interface UploadRangeReader {
|
||||||
readonly byteLength: number;
|
readonly byteLength: number;
|
||||||
readRange(input: Readonly<{
|
readRange(input: Readonly<{
|
||||||
@@ -273,7 +281,13 @@ export interface ResumableUploadCheckpointAdmin {
|
|||||||
* Account/logout lifecycle operation for this already-bound opaque partition.
|
* Account/logout lifecycle operation for this already-bound opaque partition.
|
||||||
* The adapter closes its connection before deletion and bounds blocked waits.
|
* 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(
|
deletePartition(
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<BrowserDataResult<Readonly<{ state: "DELETED" }>>>;
|
): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1479,6 +1479,10 @@ describe("browser image probe", () => {
|
|||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
}));
|
}));
|
||||||
for (const cacheControl of [
|
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, public, max-age=31536000, immutable",
|
||||||
"public, max-age=31536000, s-maxage=60, immutable",
|
"public, max-age=31536000, s-maxage=60, immutable",
|
||||||
"public, max-age=31536000, immutable, must-revalidate",
|
"public, max-age=31536000, immutable, must-revalidate",
|
||||||
|
|||||||
@@ -602,13 +602,15 @@ describe("presigned transfer", () => {
|
|||||||
});
|
});
|
||||||
expect(issued.ok).toBe(true);
|
expect(issued.ok).toBe(true);
|
||||||
if (!issued.ok) return;
|
if (!issued.ok) return;
|
||||||
expect(
|
await expect(
|
||||||
await harness.executor.downloadSources.open({
|
firstStreamResult(
|
||||||
resourceId: "resource-1",
|
await harness.executor.downloadSources.open({
|
||||||
capability: issued.value,
|
resourceId: "resource-1",
|
||||||
signal: new AbortController().signal,
|
capability: issued.value,
|
||||||
}),
|
signal: new AbortController().signal,
|
||||||
).toMatchObject({
|
}),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "POLICY_REJECTED" },
|
error: { code: "POLICY_REJECTED" },
|
||||||
});
|
});
|
||||||
@@ -621,13 +623,15 @@ describe("presigned transfer", () => {
|
|||||||
});
|
});
|
||||||
expect(issued.ok).toBe(true);
|
expect(issued.ok).toBe(true);
|
||||||
if (!issued.ok) return;
|
if (!issued.ok) return;
|
||||||
expect(
|
await expect(
|
||||||
await harness.executor.downloadSources.open({
|
firstStreamResult(
|
||||||
resourceId: "resource-1",
|
await harness.executor.downloadSources.open({
|
||||||
capability: issued.value,
|
resourceId: "resource-1",
|
||||||
signal: new AbortController().signal,
|
capability: issued.value,
|
||||||
}),
|
signal: new AbortController().signal,
|
||||||
).toMatchObject({
|
}),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "POLICY_REJECTED" },
|
error: { code: "POLICY_REJECTED" },
|
||||||
});
|
});
|
||||||
@@ -640,18 +644,101 @@ describe("presigned transfer", () => {
|
|||||||
});
|
});
|
||||||
expect(issued.ok).toBe(true);
|
expect(issued.ok).toBe(true);
|
||||||
if (!issued.ok) return;
|
if (!issued.ok) return;
|
||||||
expect(
|
await expect(
|
||||||
await harness.executor.downloadSources.open({
|
firstStreamResult(
|
||||||
resourceId: "resource-1",
|
await harness.executor.downloadSources.open({
|
||||||
capability: issued.value,
|
resourceId: "resource-1",
|
||||||
signal: new AbortController().signal,
|
capability: issued.value,
|
||||||
}),
|
signal: new AbortController().signal,
|
||||||
).toMatchObject({
|
}),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "INTEGRITY_FAILED" },
|
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([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "truncation",
|
name: "truncation",
|
||||||
@@ -1633,3 +1720,27 @@ describe("presigned transfer", () => {
|
|||||||
expect(written).toEqual([...bytes]);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
|||||||
).toMatchObject({ ok: true });
|
).toMatchObject({ ok: true });
|
||||||
expect(await runtime.admin.deletePartition()).toEqual({
|
expect(await runtime.admin.deletePartition()).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { state: "DELETED" },
|
value: { state: "DELETED", effect: "APPLIED" },
|
||||||
});
|
});
|
||||||
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
||||||
ok: false,
|
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 memory = new MemoryIndexedDbFactory();
|
||||||
|
const factory = deletingFactory(memory, "BLOCKED");
|
||||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||||
scope,
|
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,
|
blockedTimeoutMs: 1,
|
||||||
});
|
});
|
||||||
expect(await runtime.admin.deletePartition()).toMatchObject({
|
expect(await runtime.admin.deletePartition()).toMatchObject({
|
||||||
ok: false,
|
ok: true,
|
||||||
error: {
|
value: { state: "PENDING" },
|
||||||
code: "BLOCKED",
|
|
||||||
retryable: true,
|
|
||||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 () => {
|
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
|
||||||
@@ -219,7 +246,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
expect(await deletion).toEqual({
|
expect(await deletion).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { state: "DELETED" },
|
value: { state: "DELETED", effect: "APPLIED" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const preAborted = new AbortController();
|
const preAborted = new AbortController();
|
||||||
|
|||||||
Reference in New Issue
Block a user