diff --git a/packages/workshop-backend/__integration__/open-gadget-rpc.test.ts b/packages/workshop-backend/__integration__/open-gadget-rpc.test.ts index 5df6aa53..f020aa90 100644 --- a/packages/workshop-backend/__integration__/open-gadget-rpc.test.ts +++ b/packages/workshop-backend/__integration__/open-gadget-rpc.test.ts @@ -1,3 +1,4 @@ +import { abortAllDurableObjects } from "cloudflare:test"; import { exports } from "cloudflare:workers"; import { newWebSocketRpcSession, type RpcStub } from "capnweb"; import { @@ -133,3 +134,49 @@ describe.skip("openGadget errors across native RPC and Cap'n Web", () => { expectRpcCode(browserError, OPEN_GADGET_ERROR_CODES.workspaceAccessDenied); }); }); + +// In production, workerd tags rejections from a reset DO with the structured flags +// do-telemetry.ts reads. Locally, vitest-pool-workers aborts reject FLAGLESS — this test pins that, so if a +// future pool upgrade starts attaching the production flags, it fails and the flag paths can +// graduate from synthetic unit tests to real-reset integration tests. abortAllDurableObjects() +// is the non-graceful teardown (deliberately not evictDurableObject(), which never breaks a +// stub). +describe("user-DO reset flags", () => { + it("local aborts reject flagless — flag-based recovery is untestable locally", async () => { + using publicApi = await connect(); + const account = await createAccount(publicApi, "probe"); + using authenticated = await publicApi.authenticate(account.token); + + expect(await authenticated.listModels()).toBeInstanceOf(Array); + + // Bind a native stub to the current DO incarnation BEFORE the reset — a stub minted after + // the abort would simply restart the object and succeed. This poisoned-stub rejection is + // the exact shape AuthenticatedApiImpl sees when one of its calls loses the reset race. + const userStub = exports.UserDurableObject.get( + exports.UserDurableObject.idFromName(account.username)); + expect(await userStub.listModels()).toBeInstanceOf(Array); + + await abortAllDurableObjects(); + + // The session recovers: AuthenticatedApiImpl resolves a fresh stub per call, so the + // restarted object serves this read — the browser never sees the reset. + expect(await authenticated.listModels()).toBeInstanceOf(Array); + + const nativeErr = await rejection(userStub.listModels()); + expect({ + message: nativeErr.message, + durableObjectReset: (nativeErr as Record).durableObjectReset, + retryable: (nativeErr as Record).retryable, + overloaded: (nativeErr as Record).overloaded, + }).toEqual({ + message: "Application called abortAllDurableObjects().", + durableObjectReset: undefined, + retryable: undefined, + overloaded: undefined, + }); + + // Permanently broken, not fail-once: the fresh-stub-per-call design rests on this. + const nativeErr2 = await rejection(userStub.listModels()); + expect(nativeErr2.message).toBe("Application called abortAllDurableObjects()."); + }); +}); diff --git a/packages/workshop-backend/__tests__/do-telemetry.test.ts b/packages/workshop-backend/__tests__/do-telemetry.test.ts new file mode 100644 index 00000000..9f1df4bf --- /dev/null +++ b/packages/workshop-backend/__tests__/do-telemetry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { isDoResetError } from "../src/do-telemetry"; + +// Synthetic errors shaped like workerd's tagged rejections (jsg/util.c++). Local aborts reject +// flagless (pinned by the "user-DO reset flags" integration test), so the predicate is +// exercised here with the production shapes. +function resetError(flags: Record): Error { + return Object.assign(new Error("Durable Object reset."), flags); +} + +describe("isDoResetError", () => { + it("matches the durableObjectReset flag", () => { + expect(isDoResetError(resetError({ durableObjectReset: true }))).toBe(true); + }); + + it("matches the retryable flag (connection lost)", () => { + expect(isDoResetError(resetError({ retryable: true }))).toBe(true); + }); + + it("matches the production storage-timeout shape (overloaded reset)", () => { + expect(isDoResetError( + resetError({ remote: true, overloaded: true, durableObjectReset: true }))).toBe(true); + }); + + it("rejects overload without a reset (live object shedding load)", () => { + expect(isDoResetError(resetError({ remote: true, overloaded: true }))).toBe(false); + }); + + it("rejects unflagged and malformed values", () => { + expect(isDoResetError(new Error("some app error"))).toBe(false); + expect(isDoResetError(resetError({ durableObjectReset: "yes" }))).toBe(false); + expect(isDoResetError(resetError({ retryable: 1 }))).toBe(false); + expect(isDoResetError(null)).toBe(false); + expect(isDoResetError(undefined)).toBe(false); + expect(isDoResetError("boom")).toBe(false); + }); +}); diff --git a/packages/workshop-backend/src/do-telemetry.ts b/packages/workshop-backend/src/do-telemetry.ts new file mode 100644 index 00000000..fec967eb --- /dev/null +++ b/packages/workshop-backend/src/do-telemetry.ts @@ -0,0 +1,58 @@ +// Telemetry for Durable Object reset rejections. +// +// workerd tags rejections from a reset or disconnected DO with structured flags (jsg/util.c++): +// `retryable` ⇔ connection lost, `overloaded` ⇔ load shedding, and `durableObjectReset` +// whenever the object's incarnation died — the production storage-timeout reset arrives as +// `{remote, overloaded, durableObjectReset}`. The flags are attached natively in the calling +// Worker, so no message matching is needed. Local vitest-pool-workers aborts reject FLAGLESS +// (pinned by the "user-DO reset flags" integration test), so this predicate is unit-tested +// with synthetic production shapes. + +import { createWorkshopLogger } from "./observability"; + +const logger = createWorkshopLogger("workshop.server"); + +// True for rejections caused by a DO reset or lost connection. These are requests that could make +// sense to retry (although as of this writing, the code does not do so). `overloaded` is excluded +// because when the DO is overloaded, retrying would make the problem worse. +export function isDoResetError(e: unknown): boolean { + if (typeof e !== "object" || e === null) return false; + const flags = e as { durableObjectReset?: unknown; retryable?: unknown }; + return flags.durableObjectReset === true || flags.retryable === true; +} + +/** Wraps a DO stub so every method call observes DO-reset rejections for telemetry + * (`user_do.reset.surfaced`, with the method name as the operation) and rethrows them + * unchanged. Otherwise transparent. */ +export function wrapDoStubForTelemetry(stub: T): T { + return new Proxy(stub, { + get(target, prop) { + const value = Reflect.get(target, prop) as unknown; + if (typeof value !== "function") return value; + // Invoke through the stub (`target[prop](...)`) rather than `.apply` on the extracted + // handle: native RPC method handles are themselves proxies, and touching `.apply` on one + // is interpreted as a nested RPC property access (the DO then rejects a call to "apply"). + const methods = target as unknown as Record unknown>; + if (typeof prop !== "string") return (...args: unknown[]) => methods[prop](...args); + return (...args: unknown[]) => { + const result = methods[prop](...args); + if (typeof (result as PromiseLike | undefined)?.then !== "function") return result; + return (async () => { + try { + return await (result as PromiseLike); + } catch (e) { + if (isDoResetError(e)) { + logger.warn("user DO reset observed", { + event: "user_do.reset.surfaced", + operation: prop, + durableObjectId: target.id.toString(), + error: e, + }); + } + throw e; + } + })(); + }; + }, + }); +} diff --git a/packages/workshop-backend/src/observability.ts b/packages/workshop-backend/src/observability.ts index 231c8abb..6fc4981f 100644 --- a/packages/workshop-backend/src/observability.ts +++ b/packages/workshop-backend/src/observability.ts @@ -9,6 +9,7 @@ export type WorkshopObservabilityFields = { blueprintId: string; callbackInitiated: boolean; chatId: number; + durableObjectId: string; durationMs: number; eventName: string; executionId: string; diff --git a/packages/workshop-backend/src/server.ts b/packages/workshop-backend/src/server.ts index 26dc9079..2ff0ef2c 100644 --- a/packages/workshop-backend/src/server.ts +++ b/packages/workshop-backend/src/server.ts @@ -28,6 +28,7 @@ import { verifyCfAccessJwt } from "./access.js"; import { resolveUiFeatureFlags } from "./feature-flags"; import { serveSiteLogo, SITE_LOGO_PATH } from "./site-logo.js"; import { createWorkshopLogger } from "./observability"; +import { wrapDoStubForTelemetry } from "./do-telemetry"; const logger = createWorkshopLogger("workshop.server"); @@ -74,10 +75,11 @@ type Env = Cloudflare.Env & { @validateRpc() class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { constructor(private ctx: ExecutionContext, private env: Env, - private user: DurableObjectStub, + userId: DurableObjectId, private abortSession: (reason: Error) => void) { super(); + this.#userId = userId; this.overseers = this.ctx.exports.OverseerDurableObject; this.adminSettings = this.ctx.exports.AdminSettings; this.users = this.ctx.exports.UserDurableObject; @@ -87,8 +89,16 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { private adminSettings: DurableObjectNamespace; private users: DurableObjectNamespace; + #userId: DurableObjectId; + + // Get a stub pointing at the user DO. We create a new stub for every request so that we don't + // have to worry about detecting when a stub has become broken. + get #user(): DurableObjectStub { + return wrapDoStubForTelemetry(this.users.get(this.#userId)); + } + #isAdmin(): boolean { - let name = this.user.id.name; + let name = this.#userId.name; let admins = this.env.ADMINS; if (!name || !admins) return false; @@ -107,56 +117,56 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } whoami(): Promise { - return this.user.whoami(); + return this.#user.whoami(); } setOwnDisplayName(name: string): Promise { - return this.user.setOwnDisplayName(name); + return this.#user.setOwnDisplayName(name); } changePassword(oldHash: Uint8Array, newHash: Uint8Array): Promise { - return this.user.changePassword(oldHash, newHash); + return this.#user.changePassword(oldHash, newHash); } hasPasswordLogin(): Promise { - return this.user.hasPasswordLogin(); + return this.#user.hasPasswordLogin(); } listModels(): Promise { - return this.user.listModels(); + return this.#user.listModels(); } addModel(profile: AiChatAuthorInfo, config: AiModelConfig): Promise { - return this.user.addModel(profile, config); + return this.#user.addModel(profile, config); } deleteModel(id: string): Promise { - return this.user.deleteModel(id); + return this.#user.deleteModel(id); } setQuickModel(id: string | null): Promise { - return this.user.setQuickModel(id); + return this.#user.setQuickModel(id); } getQuickModel(): Promise { - return this.user.getQuickModel(); + return this.#user.getQuickModel(); } getPreferredModel(): Promise { - return this.user.getPreferredModel(); + return this.#user.getPreferredModel(); } setPreferredModel(id: string | null): Promise { - return this.user.setPreferredModel(id); + return this.#user.setPreferredModel(id); } isOnboardingCompleted(): Promise { - return this.user.isOnboardingCompleted(); + return this.#user.isOnboardingCompleted(); } completeOnboarding(): Promise { - return this.user.completeOnboarding(); + return this.#user.completeOnboarding(); } getCloudflareUsage(): Promise { - return getUsageInfo(this.env, this.user); + return getUsageInfo(this.env, this.#user); } listCloudflareAccounts(): Promise { - return listConnectedAccounts(this.env, this.user); + return listConnectedAccounts(this.env, this.#user); } selectCloudflareAccount(accountId: string): Promise { - return selectAccount(this.env, this.user, accountId); + return selectAccount(this.env, this.#user, accountId); } async setAvatar(data: Uint8Array | null): Promise { @@ -173,7 +183,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } // Avatar data lives in KV (global), not the user's DO storage, so we // read/write it directly here to avoid routing through the DO location. - let userId = this.user.id.name!; + let userId = this.#userId.name!; if (data) { await this.env.AVATARS.put(userId, data); } else { @@ -199,14 +209,14 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } getUiFeatureFlags(): Promise { - return resolveUiFeatureFlags(this.env, this.user.id.name!); + return resolveUiFeatureFlags(this.env, this.#userId.name!); } async #openGadgetInternal(id: string, shareKey?: string, configureObservers?: RpcStub) : Promise> { - let userId = this.user.id.toString(); - let profileId = this.user.id.name!; + let userId = this.#userId.toString(); + let profileId = this.#userId.name!; let overseerId; try { overseerId = this.overseers.idFromString(id); @@ -247,7 +257,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { // (refreshAffectedCollaboratorListings), but that push is best-effort. Only catches entries // they click; others stay frozen at revocation, as a disconnected collaborator gets no pushes. if (getOpenGadgetErrorCode(err) === OPEN_GADGET_ERROR_CODES.workspaceAccessDenied) { - await this.user.forgetSharedGadget(id); + await this.#user.forgetSharedGadget(id); } throw err; } @@ -271,10 +281,10 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { async newGadget(): Promise> { let id = this.overseers.newUniqueId().toString(); - await this.user.newGadget(id, "Untitled Workspace"); + await this.#user.newGadget(id, "Untitled Workspace"); recordAnalytics(this.ctx, this.env, { event_name: "gadget_created", - user_id: this.user.id.toString(), + user_id: this.#userId.toString(), gadget_id: id, source: "blank", }); @@ -286,11 +296,11 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } async listGadgets(): Promise { - return this.user.listGadgets(); + return this.#user.listGadgets(); } listOutputs(): Promise { - return this.user.listOutputs(); + return this.#user.listOutputs(); } async listOutputFormats(): Promise { @@ -300,67 +310,67 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } listGatekeeperVendors(filter?: GatekeeperVendorFilter): Promise { - return this.user.listGatekeeperVendors(filter); + return this.#user.listGatekeeperVendors(filter); } connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise<{url: string}> { - return this.user.connectAccount(vendorId, resourceUrlPatterns); + return this.#user.connectAccount(vendorId, resourceUrlPatterns); } ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}> { - return this.user.ensureAccountResources(accountId, resourceUrlPatterns); + return this.#user.ensureAccountResources(accountId, resourceUrlPatterns); } listAddableGatekeepers(): Promise { - return this.user.listAddableGatekeepers(); + return this.#user.listAddableGatekeepers(); } provisionAmbientAccount(vendorId: string): Promise { - return this.user.provisionAmbientAccount(vendorId); + return this.#user.provisionAmbientAccount(vendorId); } subscribeConnectedAccounts( subscriber: RpcStub, filter?: ConnectedAccountsFilter) : Promise> { - return this.user.subscribeConnectedAccounts(subscriber, filter); + return this.#user.subscribeConnectedAccounts(subscriber, filter); } disconnectAccount(accountId: number): Promise { - return this.user.disconnectAccount(accountId); + return this.#user.disconnectAccount(accountId); } reconnectAccount(accountId: number): Promise<{url: string}> { - return this.user.reconnectAccount(accountId); + return this.#user.reconnectAccount(accountId); } startResourceConfigurator( accountId: number, resourceUrlPattern: string) { - return this.user.startResourceConfigurator(accountId, resourceUrlPattern); + return this.#user.startResourceConfigurator(accountId, resourceUrlPattern); } async dismissSharedGadget(gadgetId: string): Promise { - return this.user.forgetSharedGadget(gadgetId); + return this.#user.forgetSharedGadget(gadgetId); } async listOwnBlueprints(): Promise { - return this.user.listBlueprints(); + return this.#user.listBlueprints(); } async getOwnBlueprint(blueprintId: string): Promise { - return this.user.getBlueprint(blueprintId); + return this.#user.getBlueprint(blueprintId); } async listLibraryBlueprints(): Promise { - return this.user.listLibraryBlueprints(); + return this.#user.listLibraryBlueprints(); } async setBlueprintPinned(blueprintId: string, pinned: boolean): Promise { - return this.user.setBlueprintPinned(blueprintId, pinned); + return this.#user.setBlueprintPinned(blueprintId, pinned); } async isBlueprintPinned(blueprintId: string): Promise { - return this.user.isBlueprintPinned(blueprintId); + return this.#user.isBlueprintPinned(blueprintId); } async listFeaturedBlueprints(): Promise { @@ -369,15 +379,15 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } async addBlueprintToLibrary(blueprintId: string): Promise { - return this.user.addBlueprintToLibrary(blueprintId); + return this.#user.addBlueprintToLibrary(blueprintId); } async removeBlueprintFromLibrary(blueprintId: string): Promise { - return this.user.removeBlueprintFromLibrary(blueprintId); + return this.#user.removeBlueprintFromLibrary(blueprintId); } isBlueprintInLibrary(blueprintId: string): Promise<{ uploaded: boolean } | null> { - return this.user.isBlueprintInLibrary(blueprintId); + return this.#user.isBlueprintInLibrary(blueprintId); } async importBlueprint(archive: ReadableStream): Promise { @@ -396,16 +406,16 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { let kvRecord: BlueprintKvRecord = { metadata, - ownerId: this.user.id.toString(), + ownerId: this.#userId.toString(), }; await this.env.BLUEPRINTS.put(blueprintId, JSON.stringify(kvRecord)); - await this.user.importBlueprint(blueprintId, metadata); + await this.#user.importBlueprint(blueprintId, metadata); recordAnalytics(this.ctx, this.env, { event_name: "blueprint_imported", - user_id: this.user.id.toString(), + user_id: this.#userId.toString(), blueprint_id: blueprintId, }); @@ -433,7 +443,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { // 3. Create new Overseer DO (same as newGadget()). let id = this.overseers.newUniqueId().toString(); - await this.user.newGadget(id, kvRecord.metadata.title); + await this.#user.newGadget(id, kvRecord.metadata.title); let overseerResult = await this.#openGadgetInternal(id); // 4. Initialize from blueprint code. @@ -527,7 +537,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { recordAnalytics(this.ctx, this.env, { event_name: "gadget_created", - user_id: this.user.id.toString(), + user_id: this.#userId.toString(), gadget_id: id, blueprint_id: blueprintId, source: "blueprint", @@ -539,7 +549,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { } async deleteOrphanedBlueprint(blueprintId: string): Promise { - return this.user.deleteOwnedBlueprint(blueprintId); + return this.#user.deleteOwnedBlueprint(blueprintId); } // --- Gatekeeper management apps --- @@ -551,7 +561,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { async listGatekeeperApps(): Promise { // listProvidedAccounts provisions auto-provisioned accounts first (idempotent), so their apps // appear in the nav even before the user opens a gadget — in a single round trip. - let accounts = await this.user.listProvidedAccounts(); + let accounts = await this.#user.listProvidedAccounts(); return accounts .filter(account => account.description.providesUi) .map(account => ({ @@ -564,11 +574,12 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { async getGatekeeperApp(id: string): Promise { // Self-sufficient: listProvidedAccounts provisions auto-provisioned accounts first (idempotent), // so a direct URL load of /gatekeepers/$id works without racing the Header's listGatekeeperApps. - let accounts = await this.user.listProvidedAccounts(); + let user = this.#user; // one stub for both calls + let accounts = await user.listProvidedAccounts(); let app = accounts.find(account => account.vendorId === id && account.description.providesUi); if (!app) return null; // isAdmin is supplied fresh per open so admin-gated features reflect the user's current status. - return this.user.startAccountAppUi(app.accountId, { isAdmin: this.#isAdmin() }); + return user.startAccountAppUi(app.accountId, { isAdmin: this.#isAdmin() }); } // --- Deployment admin --- @@ -581,7 +592,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { if (!this.#isAdmin()) return null; // #isAdmin() guarantees a non-empty user id name. Forwarded to gatekeepers when listing the // resource catalog so RBAC-gated ones still surface for this admin. - let adminUserId = this.user.id.name!; + let adminUserId = this.#userId.name!; // @ts-expect-error Cap'n Web RPC stubs and native RPC targets are compatible but the type // system doesn't know this. return new AdminApiImpl(this.adminSettings.getByName(""), adminUserId); @@ -668,14 +679,13 @@ class PublicApiImpl extends RpcTarget implements PublicApi { } let userId = this.users.idFromName(split[0]); - let stub = this.users.get(userId); - await stub.authenticate(split[1]); + await this.users.get(userId).authenticate(split[1]); recordAnalytics(this.ctx, this.env, { event_name: "user_authenticated", user_id: userId.toString(), source: "session_token", }); - return new AuthenticatedApiImpl(this.ctx, this.env, stub, this.abortSession); + return new AuthenticatedApiImpl(this.ctx, this.env, userId, this.abortSession); } async authenticateFromCfAccess(): Promise { @@ -685,9 +695,9 @@ class PublicApiImpl extends RpcTarget implements PublicApi { let email = this.accessPayload.email as string; let userId = this.users.idFromName(email); - let stub = this.users.get(userId); let signupsEnabled = (await readAdminConfig(this.env)).signupsEnabled; - let accountCreated = await stub.authenticateFromCfAccess(email, signupsEnabled); + let accountCreated = + await this.users.get(userId).authenticateFromCfAccess(email, signupsEnabled); if (accountCreated) { recordAnalytics(this.ctx, this.env, { event_name: "account_created", @@ -700,7 +710,7 @@ class PublicApiImpl extends RpcTarget implements PublicApi { user_id: userId.toString(), source: "cf_access", }); - return new AuthenticatedApiImpl(this.ctx, this.env, stub, this.abortSession); + return new AuthenticatedApiImpl(this.ctx, this.env, userId, this.abortSession); } async login(username: string, passwordHash: Uint8Array): Promise { @@ -714,9 +724,7 @@ class PublicApiImpl extends RpcTarget implements PublicApi { username = normalizeUsername(username); let id = this.users.idFromName(username); - let user = this.users.get(id); - - let token = await user.login(passwordHash); + let token = await this.users.get(id).login(passwordHash); if (!token) return null; recordAnalytics(this.ctx, this.env, { diff --git a/packages/workshop-backend/src/user.ts b/packages/workshop-backend/src/user.ts index 2fd8ea4e..e0fa3891 100644 --- a/packages/workshop-backend/src/user.ts +++ b/packages/workshop-backend/src/user.ts @@ -296,7 +296,14 @@ export class UserDurableObject extends DurableObject { } async authenticate(token: string): Promise { - let tokenBytes = Uint8Array.fromBase64(token); + let tokenBytes: Uint8Array; + try { + tokenBytes = Uint8Array.fromBase64(token); + } catch { + // A corrupt (non-Base64) token must classify as an auth failure like any other bad token, + // not surface as the decoder's SyntaxError. + throw createAuthError(AUTH_ERROR_CODES.invalidSessionToken); + } let hash = await crypto.subtle.digest('SHA-256', tokenBytes); let tokenId = new Uint8Array(hash).toHex(); let session = this.storage.sessions.get(tokenId); diff --git a/packages/workshop-backend/vitest.integration.config.ts b/packages/workshop-backend/vitest.integration.config.ts index 0a75cdc2..c6a599d8 100644 --- a/packages/workshop-backend/vitest.integration.config.ts +++ b/packages/workshop-backend/vitest.integration.config.ts @@ -33,6 +33,10 @@ export default defineConfig({ onUnhandledError(error) { const code = "code" in error ? error.code : undefined; if (typeof code === "string" && EXPECTED_OPEN_ERROR_CODES.has(code)) return false; + // The reset-recovery tests abort every Durable Object mid-session; capabilities that were + // held across the abort (e.g. the fire-and-forget AdminSettings install kicked off by the + // fetch handler) reject on their own schedule, independent of any awaited call. + if (error.message?.includes("abortAllDurableObjects")) return false; }, }, }); diff --git a/packages/workshop-frontend/src/components/BlueprintList.tsx b/packages/workshop-frontend/src/components/BlueprintList.tsx index 171e4c51..7de3380f 100644 --- a/packages/workshop-frontend/src/components/BlueprintList.tsx +++ b/packages/workshop-frontend/src/components/BlueprintList.tsx @@ -203,7 +203,12 @@ export default function BlueprintList() { } }, [authenticatedApi, load, toasts]) + // Overlapping setBlueprintPinned calls have no ordering guarantee, so ignore clicks while + // one is in flight. + const pinsInFlight = useRef(new Set()) const handleTogglePin = async (item: BlueprintItem) => { + if (pinsInFlight.current.has(item.id)) return + pinsInFlight.current.add(item.id) const nextPinned = !item.pinned setItems((prev) => sortItems(prev.map((b) => (b.id === item.id ? { ...b, pinned: nextPinned } : b)))) try { @@ -212,6 +217,8 @@ export default function BlueprintList() { console.error('Failed to update blueprint pin:', err) setItems((prev) => sortItems(prev.map((b) => (b.id === item.id ? { ...b, pinned: item.pinned } : b)))) toasts.add({ title: 'Failed to update favorite', variant: 'error' }) + } finally { + pinsInFlight.current.delete(item.id) } } diff --git a/packages/workshop-frontend/src/routes/providers.tsx b/packages/workshop-frontend/src/routes/providers.tsx index 16e16e04..92be1d36 100644 --- a/packages/workshop-frontend/src/routes/providers.tsx +++ b/packages/workshop-frontend/src/routes/providers.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { DropdownMenu, useKumoToastManager } from '@cloudflare/kumo' import { useAuthenticatedApi } from '../AuthContext' import { @@ -187,7 +187,12 @@ function ProvidersPage() { } } + // Overlapping setQuickModel calls have no ordering guarantee, so ignore clicks while one is + // in flight. + const quickInFlight = useRef(false) const handleSetQuick = async (modelId: string) => { + if (quickInFlight.current) return + quickInFlight.current = true const next = quickModel === modelId ? null : modelId setQuickModel(next) try { @@ -196,6 +201,8 @@ function ProvidersPage() { console.error('Failed to set quick model:', err) setQuickModel(quickModel) // revert toasts.add({ title: 'Failed to update default model', variant: 'error' }) + } finally { + quickInFlight.current = false } }