Skip to content

Commit a7a444e

Browse files
fix(nostr): clear per-relay publish timeout timer to prevent dangling handles (#98720)
1 parent 3844285 commit a7a444e

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

extensions/nostr/src/nostr-profile.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Nostr tests cover nostr profile plugin behavior.
2-
import { verifyEvent, getPublicKey } from "nostr-tools";
2+
import { verifyEvent, getPublicKey, type SimplePool } from "nostr-tools";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import type { NostrProfile } from "./config-schema.js";
55
import {
@@ -8,6 +8,7 @@ import {
88
contentToProfile,
99
validateProfile,
1010
sanitizeProfileForDisplay,
11+
publishProfile,
1112
type ProfileContent,
1213
} from "./nostr-profile.js";
1314
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
@@ -414,3 +415,74 @@ describe("edge cases", () => {
414415
expect(verifyEvent(event)).toBe(true);
415416
});
416417
});
418+
419+
// ============================================================================
420+
// Profile Publishing Tests
421+
// ============================================================================
422+
423+
describe("publishProfile", () => {
424+
beforeEach(() => {
425+
vi.useFakeTimers();
426+
});
427+
428+
afterEach(() => {
429+
vi.useRealTimers();
430+
});
431+
432+
function createFakePool(publishResult: unknown): SimplePool {
433+
return {
434+
publish: vi.fn(() => [publishResult]),
435+
} as unknown as SimplePool;
436+
}
437+
438+
it("clears the per-relay timeout timer after a successful publish", async () => {
439+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
440+
const profile: NostrProfile = { name: "test" };
441+
const pool = createFakePool(Promise.resolve());
442+
443+
const result = await publishProfile(
444+
pool,
445+
TEST_HEX_PRIVATE_KEY_BYTES,
446+
["wss://relay.example"],
447+
profile,
448+
);
449+
450+
expect(result.successes).toEqual(["wss://relay.example"]);
451+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
452+
});
453+
454+
it("clears the per-relay timeout timer after a publish timeout", async () => {
455+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
456+
const profile: NostrProfile = { name: "test" };
457+
const pool = createFakePool(new Promise(() => {}));
458+
459+
const promise = publishProfile(
460+
pool,
461+
TEST_HEX_PRIVATE_KEY_BYTES,
462+
["wss://relay.example"],
463+
profile,
464+
);
465+
vi.advanceTimersByTime(6_000);
466+
const result = await promise;
467+
468+
expect(result.failures).toHaveLength(1);
469+
expect(result.failures[0]?.error).toContain("timeout");
470+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
471+
});
472+
473+
it("does not add dangling timers when publishing to multiple relays", async () => {
474+
vi.spyOn(globalThis, "setTimeout").mockClear();
475+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
476+
const profile: NostrProfile = { name: "test" };
477+
const pool = createFakePool(Promise.resolve());
478+
479+
await publishProfile(
480+
pool,
481+
TEST_HEX_PRIVATE_KEY_BYTES,
482+
["wss://relay.a", "wss://relay.b"],
483+
profile,
484+
);
485+
486+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(2);
487+
});
488+
});

extensions/nostr/src/nostr-profile.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ async function publishProfileEvent(
9898

9999
// Publish to each relay in parallel with timeout
100100
const publishPromises = relays.map(async (relay) => {
101+
let timer: ReturnType<typeof setTimeout> | undefined;
101102
try {
102103
const timeoutPromise = new Promise<never>((_, reject) => {
103-
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
104+
timer = setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
104105
});
105106

106107
await Promise.race([...pool.publish([relay], event), timeoutPromise]);
@@ -109,6 +110,10 @@ async function publishProfileEvent(
109110
} catch (err) {
110111
const errorMessage = formatErrorMessage(err);
111112
failures.push({ relay, error: errorMessage });
113+
} finally {
114+
if (timer) {
115+
clearTimeout(timer);
116+
}
112117
}
113118
});
114119

0 commit comments

Comments
 (0)