fix(socket): preserve queued once listeners (#1978)

This commit is contained in:
Aqil Aziz
2026-05-16 20:44:36 -07:00
committed by GitHub
parent 9400f77364
commit 992d46dd15
2 changed files with 69 additions and 5 deletions
@@ -160,6 +160,60 @@ describe('socketService — resolveCoreSocketBaseUrl uses getCoreRpcUrl', () =>
expect(connectedUrl).toBe('http://custom-core-host:9000');
}
});
it('preserves queued once listeners when the socket is created later', async () => {
const { io } = await import('socket.io-client');
const ioMock = vi.mocked(io);
ioMock.mockClear();
hoisted.getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc');
const { socketService } = await import('../socketService');
socketService.disconnect();
const callback = vi.fn();
socketService.once('queued-once-event', callback);
socketService.connect('mock-jwt-queued-once');
await pollUntil(() => expect(ioMock).toHaveBeenCalled());
const latestSocket = ioMock.mock.results[ioMock.mock.results.length - 1].value as {
on: ReturnType<typeof vi.fn>;
once: ReturnType<typeof vi.fn>;
};
await pollUntil(() =>
expect(latestSocket.once).toHaveBeenCalledWith('queued-once-event', expect.any(Function))
);
expect(latestSocket.on).not.toHaveBeenCalledWith('queued-once-event', expect.any(Function));
});
it('preserves queued on listeners when the socket is created later', async () => {
const { io } = await import('socket.io-client');
const ioMock = vi.mocked(io);
ioMock.mockClear();
hoisted.getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc');
const { socketService } = await import('../socketService');
socketService.disconnect();
const callback = vi.fn();
socketService.on('queued-on-event', callback);
socketService.connect('mock-jwt-queued-on');
await pollUntil(() => expect(ioMock).toHaveBeenCalled());
const latestSocket = ioMock.mock.results[ioMock.mock.results.length - 1].value as {
on: ReturnType<typeof vi.fn>;
once: ReturnType<typeof vi.fn>;
};
await pollUntil(() =>
expect(latestSocket.on).toHaveBeenCalledWith('queued-on-event', expect.any(Function))
);
expect(latestSocket.once).not.toHaveBeenCalledWith('queued-on-event', expect.any(Function));
});
});
describe('socketService — connectivity dispatch on socket events (lines 164, 212, 230, 237, 240)', () => {
+15 -5
View File
@@ -53,6 +53,12 @@ interface ChannelConnectionUpdatedEvent {
capabilities?: string[];
}
interface PendingSocketListener {
event: string;
callback: (...args: unknown[]) => void;
once: boolean;
}
function normalizeChannelConnectionUpdatePayload(
value: unknown
): ChannelConnectionUpdatedEvent | null {
@@ -115,7 +121,7 @@ class SocketService {
private socket: Socket | null = null;
private token: string | null = null;
private mcpTransport: SocketIOMCPTransportImpl | null = null;
private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
private pendingListeners: PendingSocketListener[] = [];
// Maps original caller callbacks → wrapped callbacks so off() can locate the
// exact function references that were registered with socket.io, scoped by event.
private listenerMap = new Map<
@@ -185,8 +191,12 @@ class SocketService {
// Flush any listeners that were registered before the socket existed.
if (this.pendingListeners.length > 0) {
socketLog('Flushing pending listeners', { count: this.pendingListeners.length });
for (const { event, callback } of this.pendingListeners) {
this.socket.on(event, callback);
for (const { event, callback, once } of this.pendingListeners) {
if (once) {
this.socket.once(event, callback);
} else {
this.socket.on(event, callback);
}
}
this.pendingListeners = [];
}
@@ -372,7 +382,7 @@ class SocketService {
this.socket.on(event, wrappedCallback);
} else {
socketLog('Socket not ready, queuing listener', { event });
this.pendingListeners.push({ event, callback: wrappedCallback });
this.pendingListeners.push({ event, callback: wrappedCallback, once: false });
}
}
@@ -441,7 +451,7 @@ class SocketService {
this.socket.once(event, wrappedCallback);
} else {
socketLog('Socket not ready, queuing once listener', { event });
this.pendingListeners.push({ event, callback: wrappedCallback });
this.pendingListeners.push({ event, callback: wrappedCallback, once: true });
}
}
}