From 668a9062b3116784d98ac18d333689cfd78f3fc5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 06:11:23 +0900 Subject: [PATCH] fix: fence runtime detail route state --- crates/workspace-server/src/server.rs | 16 +++++- .../lib/workspace/api/runtime-management.ts | 28 ++++++++++ .../runtimes/[runtimeId]/+page.svelte | 55 ++++++++++++++++--- .../tests/runtime-management-source.test.ts | 10 ++++ .../tests/runtime-management.test.ts | 22 ++++++++ 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ba0fa922..41db9ae9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14684,7 +14684,7 @@ async fn workspace_runtime_resources_response( let runtimes = api.runtime.list_runtimes(limit); let bindings = api .store - .list_workspace_runtime_bindings(workspace_id, false) + .list_workspace_runtime_bindings(workspace_id, true) .await?; let mut items = runtimes .items @@ -22674,6 +22674,20 @@ mod tests { .unwrap(); assert_eq!(binding.binding_revision, 3); assert!(binding.revoked_at.is_some()); + let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID) + .await + .unwrap(); + let listed_runtime = listed + .items + .iter() + .find(|resource| resource.runtime.runtime_id == "runtime-a") + .expect("revoked binding must remain listed"); + assert!(listed_runtime.management.config_managed); + let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a") + .await + .unwrap(); + assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked); + assert!(detail.runtime.management.config_managed); assert!( !api.runtime_binding_expectations .read() diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 2b6a8ee3..ac15d012 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -98,6 +98,34 @@ export class RuntimeTrustRequestError extends Error { } } +export type RuntimeTrustRouteOperation = Readonly<{ + runtimeId: string; + generation: number; +}>; + +export class RuntimeTrustRouteFence { + #runtimeId: string | null = null; + #generation = 0; + + enter(runtimeId: string): number { + if (this.#runtimeId !== runtimeId) { + this.#runtimeId = runtimeId; + this.#generation += 1; + } + return this.#generation; + } + + capture(runtimeId: string): RuntimeTrustRouteOperation { + return { runtimeId, generation: this.enter(runtimeId) }; + } + + isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean { + return operation.runtimeId === runtimeId && + operation.generation === this.#generation && + this.#runtimeId === runtimeId; + } +} + function fail(path: string, message: string): never { throw new RuntimeManagementValidationError(`${path} ${message}`); } diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index 2aa5d7be..bceedc8f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -11,7 +11,9 @@ revealRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, + RuntimeTrustRouteFence, RuntimeTrustRequestError, + type RuntimeTrustRouteOperation, } from '$lib/workspace/api/runtime-management'; import type { PageProps } from './$types'; @@ -30,6 +32,26 @@ let replacementFingerprint = $state(null); let replacementFingerprintError = $state(null); let fingerprintGeneration = 0; + const routeFence = new RuntimeTrustRouteFence(); + let routeGeneration = 0; + + $effect(() => { + const nextGeneration = routeFence.enter(data.runtimeId); + if (nextGeneration === routeGeneration) return; + routeGeneration = nextGeneration; + fingerprintGeneration += 1; + showPublicKey = false; + revealedPublicKey = null; + publicKey = ''; + fingerprintConfirmation = ''; + revokeFingerprintConfirmation = ''; + busyAction = null; + fieldError = null; + requestError = null; + successMessage = null; + replacementFingerprint = null; + replacementFingerprintError = null; + }); $effect(() => { const key = publicKey.trim(); @@ -79,6 +101,10 @@ await invalidateAll(); } + function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean { + return routeFence.isCurrent(operation, data.runtimeId); + } + async function saveTrustKey(event: SubmitEvent): Promise { event.preventDefault(); if (busyAction !== null || !data.runtimeDetail) return; @@ -123,9 +149,11 @@ expected_revision: trust.revision ?? null, }; + const operation = routeFence.capture(data.runtimeId); busyAction = 'save'; try { - await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request); + await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); + if (!isCurrentRoute(operation)) return; publicKey = ''; fingerprintConfirmation = ''; revokeFingerprintConfirmation = ''; @@ -138,6 +166,7 @@ : 'Workspace trust was reactivated.'; await reloadAuthority(); } catch (error) { + if (!isCurrentRoute(operation)) return; fingerprintConfirmation = ''; if (error instanceof RuntimeTrustConflictError) { requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; @@ -148,7 +177,7 @@ requestError = error instanceof Error ? error.message : 'Runtime trust update failed.'; } } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } @@ -170,6 +199,7 @@ fieldError = null; requestError = null; successMessage = null; + const operation = routeFence.capture(data.runtimeId); busyAction = 'revoke'; const request: RevokeRuntimeTrustKeyRequest = { expected_revision: trust.revision, @@ -178,11 +208,12 @@ try { await revokeRuntimeTrustKey( data.workspaceId, - data.runtimeId, + operation.runtimeId, request, trust.fingerprint, revokeFingerprintConfirmation, ); + if (!isCurrentRoute(operation)) return; publicKey = ''; fingerprintConfirmation = ''; revokeFingerprintConfirmation = ''; @@ -191,6 +222,7 @@ successMessage = 'Workspace trust was revoked.'; await reloadAuthority(); } catch (error) { + if (!isCurrentRoute(operation)) return; if (error instanceof RuntimeTrustConflictError) { requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; await reloadAuthority(); @@ -198,7 +230,7 @@ requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.'; } } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } @@ -209,35 +241,42 @@ return; } if (busyAction !== null) return; + const operation = routeFence.capture(data.runtimeId); busyAction = 'reveal'; requestError = null; successMessage = null; try { - const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId); + if (!isCurrentRoute(operation)) return; revealedPublicKey = response.public_key; showPublicKey = true; } catch (error) { + if (!isCurrentRoute(operation)) return; requestError = error instanceof Error ? error.message : 'Public key reveal failed.'; } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } async function copyPublicKey(): Promise { if (busyAction !== null) return; + const operation = routeFence.capture(data.runtimeId); busyAction = 'copy'; requestError = null; successMessage = null; try { - const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId); + if (!isCurrentRoute(operation)) return; await navigator.clipboard.writeText(response.public_key); + if (!isCurrentRoute(operation)) return; successMessage = 'Public key copied.'; } catch (error) { + if (!isCurrentRoute(operation)) return; requestError = error instanceof Error ? error.message : 'The browser could not copy the public key.'; } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index d1ef4af4..1aaee2c1 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -110,6 +110,16 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as "Revoke Workspace trust", "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "RuntimeTrustConflictError", + "RuntimeTrustRouteFence", + "routeFence.enter(data.runtimeId)", + "showPublicKey = false", + "revealedPublicKey = null", + "publicKey = ''", + "fingerprintConfirmation = ''", + "revokeFingerprintConfirmation = ''", + "requestError = null", + "successMessage = null", + "isCurrentRoute(operation)", "revealRuntimeTrustKey", "revokeFingerprintConfirmation.trim() !== trust.fingerprint", "await reloadAuthority()", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index 9e9daf5b..3d5c83b6 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -11,6 +11,7 @@ import { putRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, + RuntimeTrustRouteFence, } from "../src/lib/workspace/api/runtime-management.ts"; function assert(condition: unknown, message: string): asserts condition { @@ -207,6 +208,27 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => { assert(requests === 0, "mismatched fingerprint sent a revoke request"); }); +Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => { + const fence = new RuntimeTrustRouteFence(); + fence.enter("runtime-a"); + const operation = fence.capture("runtime-a"); + let renderedKey: string | null = null; + let resolveReveal!: (key: string) => void; + const delayedReveal = new Promise((resolve) => { + resolveReveal = resolve; + }).then((key) => { + if (fence.isCurrent(operation, "runtime-b")) renderedKey = key; + }); + + fence.enter("runtime-b"); + resolveReveal("runtime-a-public-key"); + await delayedReveal; + assert( + renderedKey === null, + "Runtime A key rendered after navigating to Runtime B", + ); +}); + Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => { const fingerprint = await previewRuntimePublicKeyFingerprint( "yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",