fix: fence runtime removal races and retries
This commit is contained in:
@@ -1026,6 +1026,23 @@ export async function updateRemoteRuntime(
|
||||
return finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
|
||||
export class RuntimeRemovalAttempt {
|
||||
#operationId: string | null = null;
|
||||
|
||||
operationId(create: () => string = () => crypto.randomUUID()): string {
|
||||
if (this.#operationId === null) this.#operationId = create();
|
||||
return this.#operationId;
|
||||
}
|
||||
|
||||
complete(operationId: string): void {
|
||||
if (this.#operationId === operationId) this.#operationId = null;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#operationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRemoteRuntime(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
updateRemoteRuntime,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRouteFence,
|
||||
RuntimeRemovalAttempt,
|
||||
RuntimeTrustRequestError,
|
||||
type RuntimeTrustRouteOperation,
|
||||
} from '$lib/workspace/api/runtime-management';
|
||||
@@ -37,6 +38,7 @@
|
||||
let replacementFingerprintError = $state<string | null>(null);
|
||||
let fingerprintGeneration = 0;
|
||||
const routeFence = new RuntimeTrustRouteFence();
|
||||
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
|
||||
let routeGeneration = 0;
|
||||
|
||||
$effect(() => {
|
||||
@@ -51,6 +53,7 @@
|
||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||
editingMetadata = false;
|
||||
deleteRuntimeConfirmation = '';
|
||||
runtimeRemovalAttempt.reset();
|
||||
busyAction = null;
|
||||
fieldError = null;
|
||||
deleteRuntimeError = null;
|
||||
@@ -290,6 +293,7 @@
|
||||
deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.';
|
||||
return;
|
||||
}
|
||||
const operationId = runtimeRemovalAttempt.operationId();
|
||||
busyAction = 'delete';
|
||||
deleteRuntimeError = null;
|
||||
try {
|
||||
@@ -297,11 +301,12 @@
|
||||
data.workspaceId,
|
||||
routeOperation.runtimeId,
|
||||
{
|
||||
operation_id: crypto.randomUUID(),
|
||||
operation_id: operationId,
|
||||
expected_binding_revision: trust.revision,
|
||||
},
|
||||
);
|
||||
if (!isCurrentRoute(routeOperation)) return;
|
||||
runtimeRemovalAttempt.complete(operationId);
|
||||
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
|
||||
replaceState: true,
|
||||
});
|
||||
|
||||
@@ -178,7 +178,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||
"await revokeRuntimeTrustKey(",
|
||||
"await removeRemoteRuntime(",
|
||||
"operation_id: crypto.randomUUID()",
|
||||
"new RuntimeRemovalAttempt()",
|
||||
"runtimeRemovalAttempt.operationId()",
|
||||
"operation_id: operationId",
|
||||
"expected_binding_revision: trust.revision",
|
||||
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
|
||||
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
previewRuntimePublicKeyFingerprint,
|
||||
removeRemoteRuntime,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeRemovalAttempt,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRouteFence,
|
||||
updateRemoteRuntime,
|
||||
@@ -314,6 +315,58 @@ Deno.test("Runtime metadata update never sends public key authority", async () =
|
||||
assert(!("public_key" in body), "metadata update sent public_key");
|
||||
});
|
||||
|
||||
Deno.test("Runtime removal attempt retains its id across response-loss retry", async () => {
|
||||
const attempt = new RuntimeRemovalAttempt();
|
||||
const operationIds: string[] = [];
|
||||
let calls = 0;
|
||||
const submit = async () => {
|
||||
const operationId = attempt.operationId(() => "stable-removal-operation");
|
||||
operationIds.push(operationId);
|
||||
const operation = await removeRemoteRuntime(
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
{ operation_id: operationId, expected_binding_revision: 4 },
|
||||
() => {
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error("response lost"));
|
||||
return Promise.resolve(Response.json({
|
||||
operation_id: operationId,
|
||||
workspace_id: "workspace-a",
|
||||
runtime_id: "runtime-a",
|
||||
state: "succeeded",
|
||||
binding_removed: true,
|
||||
runtime_registration_removed: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:01Z",
|
||||
completed_at: "2026-01-01T00:00:01Z",
|
||||
}));
|
||||
},
|
||||
);
|
||||
attempt.complete(operation.operation_id);
|
||||
};
|
||||
|
||||
try {
|
||||
await submit();
|
||||
throw new Error("expected response-loss retry to fail");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof Error && error.message.includes("response lost"),
|
||||
`unexpected response-loss error: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
await submit();
|
||||
assert(
|
||||
operationIds.length === 2 &&
|
||||
operationIds.every((id) => id === "stable-removal-operation"),
|
||||
`response-loss retry changed operation id: ${operationIds.join(",")}`,
|
||||
);
|
||||
assert(
|
||||
attempt.operationId(() => "next-removal-operation") ===
|
||||
"next-removal-operation",
|
||||
"authoritative success did not clear the completed operation id",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime removal uses the Workspace-scoped operation route", async () => {
|
||||
let requestedUrl = "";
|
||||
let requestedMethod = "";
|
||||
|
||||
Reference in New Issue
Block a user