Skip to content

Commit feedf55

Browse files
committed
Fix waiter key collisions across plugins
1 parent cfe4956 commit feedf55

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.
@@ -191,6 +197,39 @@ describe("opencode glance plugin", () => {
191197
)
192198
})
193199

200+
it("registers distinct waiters even within the same millisecond", async () => {
201+
vi.stubGlobal(
202+
"fetch",
203+
routedFetch({
204+
session: { id: "sess-waiters", url: "/s/sess-waiters" },
205+
}),
206+
)
207+
208+
const GlancePlugin = await loadPlugin()
209+
const plugin = await GlancePlugin(mockClient())
210+
211+
await plugin.tool.glance.execute({})
212+
213+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123)
214+
const ac1 = new AbortController()
215+
const ac2 = new AbortController()
216+
217+
const wait1 = plugin.tool.glance_wait.execute({}, mockContext(ac1.signal))
218+
const wait2 = plugin.tool.glance_wait.execute({}, mockContext(ac2.signal))
219+
220+
expect(getOpenCodeWaiterKeys()).toHaveLength(2)
221+
222+
ac1.abort()
223+
ac2.abort()
224+
225+
await expect(Promise.all([wait1, wait2])).resolves.toEqual([
226+
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
227+
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
228+
])
229+
230+
dateNowSpy.mockRestore()
231+
})
232+
194233
it("returns timeout message when aborted", async () => {
195234
vi.stubGlobal(
196235
"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(
@@ -184,7 +192,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
184192

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

187-
const key = `__glance_waiter_${Date.now()}`
195+
const key = nextWaiterKey()
188196
;(globalThis as any)[key] = (image: ImageEvent) => {
189197
clearTimeout(timeout)
190198
delete (globalThis as any)[key]
@@ -201,7 +209,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
201209

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

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>();
@@ -287,6 +293,30 @@ describe("pi/glance", () => {
287293
});
288294
});
289295

296+
it("registers distinct waiters even within the same millisecond", async () => {
297+
__testing.setSession({
298+
id: "session-waiters",
299+
url: "https://glance.sh/s/session-waiters",
300+
});
301+
302+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123);
303+
304+
const ac1 = new AbortController();
305+
const ac2 = new AbortController();
306+
307+
const wait1 = __testing.waitForNextImage(ac1.signal);
308+
const wait2 = __testing.waitForNextImage(ac2.signal);
309+
310+
expect(getPiWaiterKeys()).toHaveLength(2);
311+
312+
ac1.abort();
313+
ac2.abort();
314+
315+
await expect(Promise.all([wait1, wait2])).resolves.toEqual([null, null]);
316+
317+
dateNowSpy.mockRestore();
318+
});
319+
290320
it("shows the active session URL through the /glance command", async () => {
291321
const session = {
292322
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;
@@ -52,6 +54,7 @@ let currentSession: SessionResponse | null = null;
5254
let sessionCreatedAt = 0;
5355
let abortController: AbortController | null = null;
5456
let running = false;
57+
let waiterCounter = 0;
5558

5659
async function createSession(): Promise<SessionResponse> {
5760
const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" });
@@ -123,6 +126,11 @@ function sleep(ms: number): Promise<void> {
123126
return new Promise((r) => setTimeout(r, ms));
124127
}
125128

129+
function nextWaiterKey(): string {
130+
waiterCounter += 1;
131+
return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`;
132+
}
133+
126134
// ── SSE listener (multi-image) ─────────────────────────────────────
127135

128136
/**
@@ -217,7 +225,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
217225

218226
// Poll: check for new images by watching the background loop.
219227
// We do this by subscribing to a one-time callback.
220-
const key = `__glance_waiter_${Date.now()}`;
228+
const key = nextWaiterKey();
221229
(globalThis as any)[key] = (image: ImageEvent) => {
222230
clearTimeout(timeout);
223231
delete (globalThis as any)[key];
@@ -234,7 +242,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {
234242

235243
function getWaiterKeys(): string[] {
236244
return Object.keys(globalThis).filter((key) =>
237-
key.startsWith("__glance_waiter_"),
245+
key.startsWith(WAITER_PREFIX),
238246
);
239247
}
240248

@@ -269,6 +277,7 @@ export const __testing = {
269277
stopBackground();
270278
sessionCreatedAt = 0;
271279
running = false;
280+
waiterCounter = 0;
272281
clearWaiters();
273282
},
274283
setSession(session: SessionResponse | null, createdAt = Date.now()) {

0 commit comments

Comments
 (0)