Skip to content

Commit be863cd

Browse files
committed
Fix waiter key collisions across plugins
1 parent 97b2fdd commit be863cd

File tree

4 files changed

+90
-4
lines changed

4 files changed

+90
-4
lines changed

opencode/glance.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ function cleanupWaiters() {
5454
}
5555
}
5656

57+
function getOpenCodeWaiterKeys(): string[] {
58+
return Object.keys(globalThis).filter((key) =>
59+
key.startsWith("__glance_waiter_opencode_"),
60+
)
61+
}
62+
5763
/**
5864
* URL-aware fetch mock. Routes by URL so both the background loop and
5965
* tool calls get correct responses regardless of call order.
@@ -216,6 +222,39 @@ describe("opencode glance plugin", () => {
216222
expect(result).toContain("https://glance.sh/chunked.png")
217223
})
218224

225+
it("registers distinct waiters even within the same millisecond", async () => {
226+
vi.stubGlobal(
227+
"fetch",
228+
routedFetch({
229+
session: { id: "sess-waiters", url: "/s/sess-waiters" },
230+
}),
231+
)
232+
233+
const GlancePlugin = await loadPlugin()
234+
const plugin = await GlancePlugin(mockClient())
235+
236+
await plugin.tool.glance.execute({})
237+
238+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123)
239+
const ac1 = new AbortController()
240+
const ac2 = new AbortController()
241+
242+
const wait1 = plugin.tool.glance_wait.execute({}, mockContext(ac1.signal))
243+
const wait2 = plugin.tool.glance_wait.execute({}, mockContext(ac2.signal))
244+
245+
expect(getOpenCodeWaiterKeys()).toHaveLength(2)
246+
247+
ac1.abort()
248+
ac2.abort()
249+
250+
await expect(Promise.all([wait1, wait2])).resolves.toEqual([
251+
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
252+
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
253+
])
254+
255+
dateNowSpy.mockRestore()
256+
})
257+
219258
it("returns timeout message when aborted", async () => {
220259
vi.stubGlobal(
221260
"fetch",

opencode/glance.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const RECONNECT_DELAY_MS = 3_000
2727
/** How often to create a fresh session (sessions have 10-min TTL). */
2828
const SESSION_REFRESH_MS = 8 * 60 * 1000
2929

30+
const WAITER_PREFIX = "__glance_waiter_opencode_"
31+
3032
interface SessionResponse {
3133
id: string
3234
url: string
@@ -43,6 +45,7 @@ let currentSession: SessionResponse | null = null
4345
let sessionCreatedAt = 0
4446
let abortController: AbortController | null = null
4547
let running = false
48+
let waiterCounter = 0
4649

4750
async function createSession(): Promise<SessionResponse> {
4851
const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" })
@@ -101,6 +104,11 @@ function sleep(ms: number): Promise<void> {
101104
return new Promise((r) => setTimeout(r, ms))
102105
}
103106

107+
function nextWaiterKey(): string {
108+
waiterCounter += 1
109+
return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`
110+
}
111+
104112
// ── SSE listener (multi-image) ─────────────────────────────────────
105113

106114
async function listenForImages(
@@ -183,7 +191,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
183191

184192
const timeout = setTimeout(() => resolve(null), SSE_TIMEOUT_MS)
185193

186-
const key = `__glance_waiter_${Date.now()}`
194+
const key = nextWaiterKey()
187195
;(globalThis as any)[key] = (image: ImageEvent) => {
188196
clearTimeout(timeout)
189197
delete (globalThis as any)[key]
@@ -200,7 +208,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
200208

201209
function dispatchToWaiters(image: ImageEvent) {
202210
for (const key of Object.keys(globalThis)) {
203-
if (key.startsWith("__glance_waiter_")) {
211+
if (key.startsWith(WAITER_PREFIX)) {
204212
const fn = (globalThis as any)[key]
205213
if (typeof fn === "function") fn(image)
206214
}

pi/glance.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ function createTheme(): Theme {
127127
};
128128
}
129129

130+
function getPiWaiterKeys(): string[] {
131+
return Object.keys(globalThis).filter((key) =>
132+
key.startsWith("__glance_waiter_pi_"),
133+
);
134+
}
135+
130136
function createPi(options?: { autoShutdownOnMessage?: boolean }) {
131137
const events = new Map<string, (...args: any[]) => unknown>();
132138
const commands = new Map<string, CommandDefinition>();
@@ -293,6 +299,30 @@ describe("pi/glance", () => {
293299
});
294300
});
295301

302+
it("registers distinct waiters even within the same millisecond", async () => {
303+
__testing.setSession({
304+
id: "session-waiters",
305+
url: "https://glance.sh/s/session-waiters",
306+
});
307+
308+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123);
309+
310+
const ac1 = new AbortController();
311+
const ac2 = new AbortController();
312+
313+
const wait1 = __testing.waitForNextImage(ac1.signal);
314+
const wait2 = __testing.waitForNextImage(ac2.signal);
315+
316+
expect(getPiWaiterKeys()).toHaveLength(2);
317+
318+
ac1.abort();
319+
ac2.abort();
320+
321+
await expect(Promise.all([wait1, wait2])).resolves.toEqual([null, null]);
322+
323+
dateNowSpy.mockRestore();
324+
});
325+
296326
it("shows the active session URL through the /glance command", async () => {
297327
const session = {
298328
id: "session-4",

pi/glance.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const RECONNECT_DELAY_MS = 3_000;
2929
/** How often to create a fresh session (sessions have 10-min TTL). */
3030
const SESSION_REFRESH_MS = 8 * 60 * 1000; // 8 minutes — well before expiry
3131

32+
const WAITER_PREFIX = "__glance_waiter_pi_";
33+
3234
interface SessionResponse {
3335
id: string;
3436
url: string;
@@ -56,6 +58,7 @@ let currentSession: SessionResponse | null = null;
5658
let sessionCreatedAt = 0;
5759
let abortController: AbortController | null = null;
5860
let running = false;
61+
let waiterCounter = 0;
5962

6063
async function createSession(): Promise<SessionResponse> {
6164
const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" });
@@ -128,6 +131,11 @@ function sleep(ms: number): Promise<void> {
128131
return new Promise((r) => setTimeout(r, ms));
129132
}
130133

134+
function nextWaiterKey(): string {
135+
waiterCounter += 1;
136+
return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`;
137+
}
138+
131139
// ── SSE listener (multi-image) ─────────────────────────────────────
132140

133141
/**
@@ -222,7 +230,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
222230

223231
// Poll: check for new images by watching the background loop.
224232
// We do this by subscribing to a one-time callback.
225-
const key = `__glance_waiter_${Date.now()}`;
233+
const key = nextWaiterKey();
226234
(globalThis as any)[key] = (image: ImageEvent) => {
227235
clearTimeout(timeout);
228236
delete (globalThis as any)[key];
@@ -239,7 +247,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
239247

240248
function getWaiterKeys(): string[] {
241249
return Object.keys(globalThis).filter((key) =>
242-
key.startsWith("__glance_waiter_"),
250+
key.startsWith(WAITER_PREFIX),
243251
);
244252
}
245253

@@ -274,6 +282,7 @@ export const __testing = {
274282
stopBackground();
275283
sessionCreatedAt = 0;
276284
running = false;
285+
waiterCounter = 0;
277286
clearWaiters();
278287
},
279288
setSession(session: SessionResponse | null, createdAt = Date.now()) {

0 commit comments

Comments
 (0)