fix(nostr): clear per-relay publish timeout timer to prevent dangling handles (#98720)

(cherry picked from commit a7a444e7ef)
This commit is contained in:
wangmiao0668000666
2026-07-02 09:10:38 +08:00
committed by Dallin Romney
parent dadc1c3faa
commit f9fffe241b
2 changed files with 79 additions and 2 deletions
+73 -1
View File
@@ -1,5 +1,5 @@
// Nostr tests cover nostr profile plugin behavior.
import { verifyEvent, getPublicKey } from "nostr-tools";
import { verifyEvent, getPublicKey, type SimplePool } from "nostr-tools";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NostrProfile } from "./config-schema.js";
import {
@@ -8,6 +8,7 @@ import {
contentToProfile,
validateProfile,
sanitizeProfileForDisplay,
publishProfile,
type ProfileContent,
} from "./nostr-profile.js";
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
@@ -414,3 +415,74 @@ describe("edge cases", () => {
expect(verifyEvent(event)).toBe(true);
});
});
// ============================================================================
// Profile Publishing Tests
// ============================================================================
describe("publishProfile", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function createFakePool(publishResult: unknown): SimplePool {
return {
publish: vi.fn(() => [publishResult]),
} as unknown as SimplePool;
}
it("clears the per-relay timeout timer after a successful publish", async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const profile: NostrProfile = { name: "test" };
const pool = createFakePool(Promise.resolve());
const result = await publishProfile(
pool,
TEST_HEX_PRIVATE_KEY_BYTES,
["wss://relay.example"],
profile,
);
expect(result.successes).toEqual(["wss://relay.example"]);
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
it("clears the per-relay timeout timer after a publish timeout", async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const profile: NostrProfile = { name: "test" };
const pool = createFakePool(new Promise(() => {}));
const promise = publishProfile(
pool,
TEST_HEX_PRIVATE_KEY_BYTES,
["wss://relay.example"],
profile,
);
vi.advanceTimersByTime(6_000);
const result = await promise;
expect(result.failures).toHaveLength(1);
expect(result.failures[0]?.error).toContain("timeout");
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
it("does not add dangling timers when publishing to multiple relays", async () => {
vi.spyOn(globalThis, "setTimeout").mockClear();
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const profile: NostrProfile = { name: "test" };
const pool = createFakePool(Promise.resolve());
await publishProfile(
pool,
TEST_HEX_PRIVATE_KEY_BYTES,
["wss://relay.a", "wss://relay.b"],
profile,
);
expect(clearTimeoutSpy).toHaveBeenCalledTimes(2);
});
});
+6 -1
View File
@@ -98,9 +98,10 @@ async function publishProfileEvent(
// Publish to each relay in parallel with timeout
const publishPromises = relays.map(async (relay) => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
timer = setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
});
await Promise.race([...pool.publish([relay], event), timeoutPromise]);
@@ -109,6 +110,10 @@ async function publishProfileEvent(
} catch (err) {
const errorMessage = formatErrorMessage(err);
failures.push({ relay, error: errorMessage });
} finally {
if (timer) {
clearTimeout(timer);
}
}
});