From 2320f338bf7042ef9d6ea4715212ee793ee4d9ee Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Sun, 19 Jul 2026 14:16:25 +0800 Subject: [PATCH] fix(ui): recover stalled Nostr profile requests (#111093) * fix(ui): bound Nostr profile requests * fix(ui): clarify Nostr timeout outcome --------- Co-authored-by: Peter Steinberger --- ui/src/pages/channels/channels-page.test.ts | 67 +++++++++ ui/src/pages/channels/channels-page.ts | 13 +- .../pages/channels/nostr-profile-ops.test.ts | 127 ++++++++++++++++++ ui/src/pages/channels/nostr-profile-ops.ts | 65 ++++++--- 4 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 ui/src/pages/channels/nostr-profile-ops.test.ts diff --git a/ui/src/pages/channels/channels-page.test.ts b/ui/src/pages/channels/channels-page.test.ts index 78f343e45003..e921d25d17f1 100644 --- a/ui/src/pages/channels/channels-page.test.ts +++ b/ui/src/pages/channels/channels-page.test.ts @@ -6,6 +6,8 @@ import { createChannelCapability } from "../../lib/channels/index.ts"; import { createRuntimeConfigCapability } from "../../lib/config/index.ts"; import "./channels-page.ts"; +const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000; + type ChannelsPageTestElement = HTMLElement & { context: ApplicationContext; updateComplete: Promise; @@ -17,6 +19,7 @@ type NostrTestPage = ChannelsPageTestElement & { values: NostrProfile; saving: boolean; importing: boolean; + error: string | null; } | null; nostrProfileAccountId: string | null; editNostrProfile: (accountId: string, profile: NostrProfile | null) => void; @@ -39,6 +42,21 @@ function createDeferred() { return { promise, resolve }; } +function stubHangingFetch() { + const fetchMock = vi.fn( + async (_input, init) => + await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + throw new Error("Expected Nostr profile request to carry an AbortSignal"); + } + signal.addEventListener("abort", () => reject(signal.reason as Error), { once: true }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + function createGateway(): TestGateway { const client = { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient; const snapshot: ApplicationGatewaySnapshot = { @@ -96,6 +114,7 @@ afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + vi.useRealTimers(); }); describe("ChannelsPage lifecycle", () => { @@ -222,4 +241,52 @@ describe("ChannelsPage lifecycle", () => { source.runtimeConfig.dispose(); source.channels.dispose(); }); + + it("clears profile saving when the gateway response times out", async () => { + vi.useFakeTimers(); + const gateway = createGateway(); + const source = createContext(gateway); + const fetchMock = stubHangingFetch(); + const page = document.createElement("openclaw-channels-page") as NostrTestPage; + page.context = source.context; + document.body.append(page); + await page.updateComplete; + page.editNostrProfile("default", { name: "Alice" }); + + const save = page.saveNostrProfile(); + await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS); + await save; + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(page.nostrProfileFormState?.saving).toBe(false); + expect(page.nostrProfileFormState?.error).toBe( + "Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.", + ); + source.runtimeConfig.dispose(); + source.channels.dispose(); + }); + + it("clears profile importing when the gateway response times out", async () => { + vi.useFakeTimers(); + const gateway = createGateway(); + const source = createContext(gateway); + const fetchMock = stubHangingFetch(); + const page = document.createElement("openclaw-channels-page") as NostrTestPage; + page.context = source.context; + document.body.append(page); + await page.updateComplete; + page.editNostrProfile("default", { name: "Alice" }); + + const load = page.importNostrProfile(); + await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS); + await load; + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(page.nostrProfileFormState?.importing).toBe(false); + expect(page.nostrProfileFormState?.error).toBe( + "Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.", + ); + source.runtimeConfig.dispose(); + source.channels.dispose(); + }); }); diff --git a/ui/src/pages/channels/channels-page.ts b/ui/src/pages/channels/channels-page.ts index e2a18ab6a62e..b1685b4d753e 100644 --- a/ui/src/pages/channels/channels-page.ts +++ b/ui/src/pages/channels/channels-page.ts @@ -16,6 +16,9 @@ import { ChannelWizardHost } from "./wizard-host.ts"; type NostrProfileFormState = ReturnType | null; +const NOSTR_PROFILE_TIMEOUT_ERROR = + "Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying."; + type NostrOperation = { generation: number; gateway: ApplicationContext["gateway"]; @@ -26,6 +29,12 @@ type NostrOperation = { headers: Record; }; +function formatNostrProfileOperationError(error: unknown, prefix: string): string { + return error instanceof DOMException && error.name === "TimeoutError" + ? NOSTR_PROFILE_TIMEOUT_ERROR + : `${prefix}: ${String(error)}`; +} + class ChannelsPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -356,7 +365,7 @@ class ChannelsPage extends OpenClawLightDomElement { this.nostrProfileFormState = { ...currentForm, saving: false, - error: `Profile update failed: ${String(err)}`, + error: formatNostrProfileOperationError(err, "Profile update failed"), success: null, }; } @@ -421,7 +430,7 @@ class ChannelsPage extends OpenClawLightDomElement { this.nostrProfileFormState = { ...currentForm, importing: false, - error: `Profile import failed: ${String(err)}`, + error: formatNostrProfileOperationError(err, "Profile import failed"), success: null, }; } diff --git a/ui/src/pages/channels/nostr-profile-ops.test.ts b/ui/src/pages/channels/nostr-profile-ops.test.ts new file mode 100644 index 000000000000..5f315d7b6a75 --- /dev/null +++ b/ui/src/pages/channels/nostr-profile-ops.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { importNostrProfile, putNostrProfile } from "./nostr-profile-ops.ts"; + +const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000; + +function requireRequestSignal(init: RequestInit | undefined): AbortSignal { + const signal = init?.signal; + if (!(signal instanceof AbortSignal)) { + throw new Error("Expected Nostr profile request to carry an AbortSignal"); + } + return signal; +} + +function rejectWhenAborted(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + const error = new Error("Nostr profile request timed out after 30 seconds"); + error.name = "TimeoutError"; + reject(error); + }, + { once: true }, + ); + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("Nostr profile HTTP operations", () => { + it("aborts a profile PUT when response headers never arrive", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_input, init) => { + return await rejectWhenAborted(requireRequestSignal(init)); + }); + vi.stubGlobal("fetch", fetchMock); + + const request = putNostrProfile({ + accountId: "main/account", + headers: { Authorization: "Bearer test" }, + values: { name: "Alice" }, + }); + const result = expect(request).rejects.toMatchObject({ + name: "TimeoutError", + message: "Nostr profile request timed out after 30 seconds", + }); + await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS); + await result; + + expect(fetchMock).toHaveBeenCalledWith( + "/api/channels/nostr/main%2Faccount/profile", + expect.objectContaining({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test", + }, + body: JSON.stringify({ name: "Alice" }), + signal: expect.any(AbortSignal), + }), + ); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }); + + it("keeps the import deadline active while the response body is pending", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_input, init) => { + const signal = requireRequestSignal(init); + return { + ok: true, + status: 200, + json: () => rejectWhenAborted(signal), + } as unknown as Response; + }); + vi.stubGlobal("fetch", fetchMock); + + const request = importNostrProfile({ accountId: "default", headers: {} }); + const result = expect(request).rejects.toMatchObject({ name: "TimeoutError" }); + await vi.advanceTimersByTimeAsync(NOSTR_PROFILE_REQUEST_TIMEOUT_MS); + await result; + + expect(fetchMock).toHaveBeenCalledWith( + "/api/channels/nostr/default/profile/import", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ autoMerge: true }), + signal: expect.any(AbortSignal), + }), + ); + }); + + it("preserves successful JSON responses for PUT and import", async () => { + const putResponse = new Response(JSON.stringify({ ok: true, persisted: true }), { + status: 200, + }); + const importResponse = new Response( + JSON.stringify({ ok: true, saved: true, merged: { name: "Alice" } }), + { status: 200 }, + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(putResponse) + .mockResolvedValueOnce(importResponse); + vi.stubGlobal("fetch", fetchMock); + + await expect( + putNostrProfile({ accountId: "default", headers: {}, values: { name: "Alice" } }), + ).resolves.toEqual({ data: { ok: true, persisted: true }, response: putResponse }); + await expect(importNostrProfile({ accountId: "default", headers: {} })).resolves.toEqual({ + data: { ok: true, saved: true, merged: { name: "Alice" } }, + response: importResponse, + }); + }); + + it("preserves the response when an error body is not JSON", async () => { + const response = new Response("gateway unavailable", { status: 503 }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + + await expect(importNostrProfile({ accountId: "default", headers: {} })).resolves.toEqual({ + data: null, + response, + }); + }); +}); diff --git a/ui/src/pages/channels/nostr-profile-ops.ts b/ui/src/pages/channels/nostr-profile-ops.ts index 05ed1d8ff1ff..b965c8a641ab 100644 --- a/ui/src/pages/channels/nostr-profile-ops.ts +++ b/ui/src/pages/channels/nostr-profile-ops.ts @@ -2,6 +2,41 @@ // publishing and importing the relay profile, plus validation-error parsing. import type { NostrProfile } from "../../api/types.ts"; +const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000; + +type NostrProfileHttpResult = { + data: T | null; + response: Response; +}; + +async function requestNostrProfile( + url: string, + init: Omit, +): Promise> { + const controller = new AbortController(); + const timeout = setTimeout( + () => + controller.abort( + new DOMException("Nostr profile request timed out after 30 seconds", "TimeoutError"), + ), + NOSTR_PROFILE_REQUEST_TIMEOUT_MS, + ); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + let data: T | null = null; + try { + data = (await response.json()) as T; + } catch (error) { + if (controller.signal.aborted) { + throw controller.signal.reason ?? error; + } + } + return { data, response }; + } finally { + clearTimeout(timeout); + } +} + export function parseValidationErrors(details: unknown): Record { if (!Array.isArray(details)) { return {}; @@ -33,7 +68,12 @@ export async function putNostrProfile(params: { headers: Record; values: NostrProfile; }) { - const response = await fetch(buildNostrProfileUrl(params.accountId), { + return await requestNostrProfile<{ + ok?: boolean; + error?: string; + details?: unknown; + persisted?: boolean; + }>(buildNostrProfileUrl(params.accountId), { method: "PUT", headers: { "Content-Type": "application/json", @@ -41,20 +81,19 @@ export async function putNostrProfile(params: { }, body: JSON.stringify(params.values), }); - const data = (await response.json().catch(() => null)) as { - ok?: boolean; - error?: string; - details?: unknown; - persisted?: boolean; - } | null; - return { data, response }; } export async function importNostrProfile(params: { accountId: string; headers: Record; }) { - const response = await fetch(buildNostrProfileUrl(params.accountId, "/import"), { + return await requestNostrProfile<{ + ok?: boolean; + error?: string; + imported?: NostrProfile; + merged?: NostrProfile; + saved?: boolean; + }>(buildNostrProfileUrl(params.accountId, "/import"), { method: "POST", headers: { "Content-Type": "application/json", @@ -62,12 +101,4 @@ export async function importNostrProfile(params: { }, body: JSON.stringify({ autoMerge: true }), }); - const data = (await response.json().catch(() => null)) as { - ok?: boolean; - error?: string; - imported?: NostrProfile; - merged?: NostrProfile; - saved?: boolean; - } | null; - return { data, response }; }