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 <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-19 14:16:25 +08:00
committed by GitHub
parent 7aa5ee594e
commit 2320f338bf
4 changed files with 253 additions and 19 deletions

View File

@@ -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<boolean>;
@@ -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<T>() {
return { promise, resolve };
}
function stubHangingFetch() {
const fetchMock = vi.fn<typeof fetch>(
async (_input, init) =>
await new Promise<Response>((_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();
});
});

View File

@@ -16,6 +16,9 @@ import { ChannelWizardHost } from "./wizard-host.ts";
type NostrProfileFormState = ReturnType<typeof createNostrProfileFormState> | 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<string, string>;
};
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,
};
}

View File

@@ -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<never> {
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>()
.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<typeof fetch>().mockResolvedValue(response));
await expect(importNostrProfile({ accountId: "default", headers: {} })).resolves.toEqual({
data: null,
response,
});
});
});

View File

@@ -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<T> = {
data: T | null;
response: Response;
};
async function requestNostrProfile<T>(
url: string,
init: Omit<RequestInit, "signal">,
): Promise<NostrProfileHttpResult<T>> {
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<string, string> {
if (!Array.isArray(details)) {
return {};
@@ -33,7 +68,12 @@ export async function putNostrProfile(params: {
headers: Record<string, string>;
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<string, string>;
}) {
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 };
}