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 };
}