From be863cdc3bf5d5e13492513f54e94f3dc791b11e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 7 Mar 2026 12:16:05 -0500 Subject: [PATCH] Fix waiter key collisions across plugins --- opencode/glance.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ opencode/glance.ts | 12 ++++++++++-- pi/glance.test.ts | 30 ++++++++++++++++++++++++++++++ pi/glance.ts | 13 +++++++++++-- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/opencode/glance.test.ts b/opencode/glance.test.ts index e6e91c2..d43e184 100644 --- a/opencode/glance.test.ts +++ b/opencode/glance.test.ts @@ -54,6 +54,12 @@ function cleanupWaiters() { } } +function getOpenCodeWaiterKeys(): string[] { + return Object.keys(globalThis).filter((key) => + key.startsWith("__glance_waiter_opencode_"), + ) +} + /** * URL-aware fetch mock. Routes by URL so both the background loop and * tool calls get correct responses regardless of call order. @@ -216,6 +222,39 @@ describe("opencode glance plugin", () => { expect(result).toContain("https://glance.sh/chunked.png") }) + it("registers distinct waiters even within the same millisecond", async () => { + vi.stubGlobal( + "fetch", + routedFetch({ + session: { id: "sess-waiters", url: "/s/sess-waiters" }, + }), + ) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + + await plugin.tool.glance.execute({}) + + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123) + const ac1 = new AbortController() + const ac2 = new AbortController() + + const wait1 = plugin.tool.glance_wait.execute({}, mockContext(ac1.signal)) + const wait2 = plugin.tool.glance_wait.execute({}, mockContext(ac2.signal)) + + expect(getOpenCodeWaiterKeys()).toHaveLength(2) + + ac1.abort() + ac2.abort() + + await expect(Promise.all([wait1, wait2])).resolves.toEqual([ + "Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters", + "Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters", + ]) + + dateNowSpy.mockRestore() + }) + it("returns timeout message when aborted", async () => { vi.stubGlobal( "fetch", diff --git a/opencode/glance.ts b/opencode/glance.ts index 1b26e39..faff5ab 100644 --- a/opencode/glance.ts +++ b/opencode/glance.ts @@ -27,6 +27,8 @@ const RECONNECT_DELAY_MS = 3_000 /** How often to create a fresh session (sessions have 10-min TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000 +const WAITER_PREFIX = "__glance_waiter_opencode_" + interface SessionResponse { id: string url: string @@ -43,6 +45,7 @@ let currentSession: SessionResponse | null = null let sessionCreatedAt = 0 let abortController: AbortController | null = null let running = false +let waiterCounter = 0 async function createSession(): Promise { const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" }) @@ -101,6 +104,11 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)) } +function nextWaiterKey(): string { + waiterCounter += 1 + return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}` +} + // ── SSE listener (multi-image) ───────────────────────────────────── async function listenForImages( @@ -183,7 +191,7 @@ function waitForNextImage(signal?: AbortSignal): Promise { const timeout = setTimeout(() => resolve(null), SSE_TIMEOUT_MS) - const key = `__glance_waiter_${Date.now()}` + const key = nextWaiterKey() ;(globalThis as any)[key] = (image: ImageEvent) => { clearTimeout(timeout) delete (globalThis as any)[key] @@ -200,7 +208,7 @@ function waitForNextImage(signal?: AbortSignal): Promise { function dispatchToWaiters(image: ImageEvent) { for (const key of Object.keys(globalThis)) { - if (key.startsWith("__glance_waiter_")) { + if (key.startsWith(WAITER_PREFIX)) { const fn = (globalThis as any)[key] if (typeof fn === "function") fn(image) } diff --git a/pi/glance.test.ts b/pi/glance.test.ts index af024b3..30d3a46 100644 --- a/pi/glance.test.ts +++ b/pi/glance.test.ts @@ -127,6 +127,12 @@ function createTheme(): Theme { }; } +function getPiWaiterKeys(): string[] { + return Object.keys(globalThis).filter((key) => + key.startsWith("__glance_waiter_pi_"), + ); +} + function createPi(options?: { autoShutdownOnMessage?: boolean }) { const events = new Map unknown>(); const commands = new Map(); @@ -293,6 +299,30 @@ describe("pi/glance", () => { }); }); + it("registers distinct waiters even within the same millisecond", async () => { + __testing.setSession({ + id: "session-waiters", + url: "https://glance.sh/s/session-waiters", + }); + + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123); + + const ac1 = new AbortController(); + const ac2 = new AbortController(); + + const wait1 = __testing.waitForNextImage(ac1.signal); + const wait2 = __testing.waitForNextImage(ac2.signal); + + expect(getPiWaiterKeys()).toHaveLength(2); + + ac1.abort(); + ac2.abort(); + + await expect(Promise.all([wait1, wait2])).resolves.toEqual([null, null]); + + dateNowSpy.mockRestore(); + }); + it("shows the active session URL through the /glance command", async () => { const session = { id: "session-4", diff --git a/pi/glance.ts b/pi/glance.ts index 70b9045..3eec1d2 100644 --- a/pi/glance.ts +++ b/pi/glance.ts @@ -29,6 +29,8 @@ const RECONNECT_DELAY_MS = 3_000; /** How often to create a fresh session (sessions have 10-min TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000; // 8 minutes — well before expiry +const WAITER_PREFIX = "__glance_waiter_pi_"; + interface SessionResponse { id: string; url: string; @@ -56,6 +58,7 @@ let currentSession: SessionResponse | null = null; let sessionCreatedAt = 0; let abortController: AbortController | null = null; let running = false; +let waiterCounter = 0; async function createSession(): Promise { const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" }); @@ -128,6 +131,11 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +function nextWaiterKey(): string { + waiterCounter += 1; + return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`; +} + // ── SSE listener (multi-image) ───────────────────────────────────── /** @@ -222,7 +230,7 @@ function waitForNextImage(signal?: AbortSignal): Promise { // Poll: check for new images by watching the background loop. // We do this by subscribing to a one-time callback. - const key = `__glance_waiter_${Date.now()}`; + const key = nextWaiterKey(); (globalThis as any)[key] = (image: ImageEvent) => { clearTimeout(timeout); delete (globalThis as any)[key]; @@ -239,7 +247,7 @@ function waitForNextImage(signal?: AbortSignal): Promise { function getWaiterKeys(): string[] { return Object.keys(globalThis).filter((key) => - key.startsWith("__glance_waiter_"), + key.startsWith(WAITER_PREFIX), ); } @@ -274,6 +282,7 @@ export const __testing = { stopBackground(); sessionCreatedAt = 0; running = false; + waiterCounter = 0; clearWaiters(); }, setSession(session: SessionResponse | null, createdAt = Date.now()) {