Browse Source

Improve `WebSocketPool` class

- Share a single connection per URL
- Set a limit to the number of connections to prevent resource exhaustion
- Prevent certain race conditions
master
buttercat1791 8 months ago
parent
commit
2839e2547a
  1. 248
      src/lib/data_structures/websocket_pool.ts

248
src/lib/data_structures/websocket_pool.ts

@ -1,27 +1,39 @@
interface WebSocketPoolWaitingQueueItem {
url: string;
resolve: (ws: WebSocket) => void;
reject: (reason?: any) => void;
}
/** /**
* A resource pool for WebSocket connections. * A resource pool for WebSocket connections. Its purpose is to allow multiple requestors to share
* The pool maintains a set of up to 4 connections for each URL. Connections are acquired from the * an open WebSocket connection for greater resource efficiency.
* pool on a per-URL basis. Idle connections are automatically closed after 60 seconds. *
* The pool maintains a single connection per URL. Requestors may acquire a reference to a
* connection, and are expected to release it when it is no longer needed. If the number of
* requestors using a connection drops to zero and no new requestors acquire it for a set period,
* the connection is closed.
*/ */
export class WebSocketPool { export class WebSocketPool {
static #instance: WebSocketPool; static #instance: WebSocketPool;
#pool: Map<string, WebSocket[]> = new Map(); #pool: Map<string, WebSocket> = new Map();
#busy: Set<WebSocket> = new Set(); #connecting: Map<string, Promise<WebSocket>> = new Map();
#waitQueue: Map<string, Array<(ws: WebSocket | PromiseLike<WebSocket>) => void>> = new Map(); #refCounts: Map<WebSocket, number> = new Map();
#idleTimers: Map<WebSocket, number> = new Map(); #idleTimers: Map<WebSocket, ReturnType<typeof setTimeout>> = new Map();
#maxConnectionsPerUrl; #idleTimeoutMs: number;
#idleTimeoutMs; #maxConnections: number;
#waitingQueue: WebSocketPoolWaitingQueueItem[] = [];
/** /**
* Private constructor invoked when the singleton instance is first created. * Private constructor invoked when the singleton instance is first created.
* @param maxConnectionsPerUrl - The maximum number of connections to maintain for each URL.
* @param idleTimeoutMs - The timeout in milliseconds after which idle connections will be * @param idleTimeoutMs - The timeout in milliseconds after which idle connections will be
* closed. Defaults to 60 seconds. * closed. Defaults to 60 seconds.
* @param maxConnections - The maximum number of simultaneous WebSocket connections. Defaults to
* 16.
*/ */
private constructor(maxConnectionsPerUrl: number = 4, idleTimeoutMs: number = 60000) { private constructor(idleTimeoutMs: number = 60000, maxConnections: number = 16) {
this.#maxConnectionsPerUrl = maxConnectionsPerUrl;
this.#idleTimeoutMs = idleTimeoutMs; this.#idleTimeoutMs = idleTimeoutMs;
this.#maxConnections = maxConnections;
} }
/** /**
@ -47,27 +59,39 @@ export class WebSocketPool {
* @param url - The URL to connect to. * @param url - The URL to connect to.
* @returns A promise that resolves with a WebSocket connection. * @returns A promise that resolves with a WebSocket connection.
*/ */
public acquire(url: string): Promise<WebSocket> { public async acquire(url: string): Promise<WebSocket> {
const availableSocket = this.#findAvailableSocket(url); const normalizedUrl = this.#normalizeUrl(url);
if (availableSocket) { const existingSocket = this.#pool.get(normalizedUrl);
// Clear any idle timer for this socket since it's being used again.
this.#clearIdleTimer(availableSocket);
// Add the reference to the socket resource to the busy set.
this.#busy.add(availableSocket);
return Promise.resolve(availableSocket);
}
const socketsForUrl = this.#pool.get(url) || []; if (existingSocket && existingSocket.readyState < WebSocket.CLOSING) {
if (socketsForUrl.length < this.#maxConnectionsPerUrl) { this.#checkOutSocket(existingSocket);
return this.#createSocket(url); return Promise.resolve(existingSocket);
} }
return new Promise(resolve => { const connectingPromise = this.#connecting.get(normalizedUrl);
if (!this.#waitQueue.has(url)) { if (connectingPromise) {
this.#waitQueue.set(url, []); const ws = await connectingPromise;
if (ws.readyState === WebSocket.OPEN) {
this.#checkOutSocket(ws);
return ws;
} }
this.#waitQueue.get(url)!.push(resolve); throw new Error(`[WebSocketPool] WebSocket connection failed for ${normalizedUrl}`);
}
if (this.#pool.size + this.#connecting.size >= this.#maxConnections) {
return new Promise((resolve, reject) => {
this.#waitingQueue.push({ url: normalizedUrl, resolve, reject });
});
}
const newConnectionPromise = this.#createSocket(normalizedUrl);
this.#connecting.set(normalizedUrl, newConnectionPromise);
newConnectionPromise.finally(() => {
this.#connecting.delete(normalizedUrl);
}); });
return newConnectionPromise;
} }
/** /**
@ -78,24 +102,19 @@ export class WebSocketPool {
* @param ws - The WebSocket connection to release. * @param ws - The WebSocket connection to release.
*/ */
public release(ws: WebSocket): void { public release(ws: WebSocket): void {
const url = ws.url; const currentCount = this.#refCounts.get(ws);
if (!currentCount) {
throw new Error('[WebSocketPool] Attempted to release an unmanaged WebSocket connection.');
}
const waitingResolvers = this.#waitQueue.get(url); if (currentCount > 0) {
if (waitingResolvers?.length > 0) { const newCount = currentCount - 1;
const resolver = waitingResolvers.shift()!; this.#refCounts.set(ws, newCount);
// Cleanup empty queues immediately if (newCount === 0) {
if (waitingResolvers.length === 0) { this.#startIdleTimer(ws);
this.#waitQueue.delete(url);
} }
resolver(ws);
return;
} }
// If no requestors are waiting, delete the reference from the busy set and start idle timer.
this.#busy.delete(ws);
this.#startIdleTimer(ws);
} }
/** /**
@ -116,6 +135,25 @@ export class WebSocketPool {
return this.#idleTimeoutMs; return this.#idleTimeoutMs;
} }
/**
* Sets the maximum number of simultaneous WebSocket connections.
*
* @param limit - The new connection limit.
*/
public set maxConnections(limit: number) {
this.#maxConnections = limit;
this.#processWaitingQueue();
}
/**
* Gets the current maximum number of simultaneous WebSocket connections.
*
* @returns The current connection limit.
*/
public get maxConnections(): number {
return this.#maxConnections;
}
/** /**
* Closes all WebSocket connections and "drains" the pool. * Closes all WebSocket connections and "drains" the pool.
*/ */
@ -126,47 +164,50 @@ export class WebSocketPool {
} }
this.#idleTimers.clear(); this.#idleTimers.clear();
for (const sockets of this.#pool.values()) { for (const { reject } of this.#waitingQueue) {
for (const ws of sockets) { reject(new Error('[WebSocketPool] Draining pool.'));
ws.onclose = null; }
ws.close(); this.#waitingQueue = [];
}
for (const promise of this.#connecting.values()) {
// While we can't cancel the connection attempt, we can prevent callers from using it.
promise.catch(() => {
/* ignore connection errors during drain */
});
}
this.#connecting.clear();
for (const ws of this.#pool.values()) {
ws.close();
} }
this.#pool.clear(); this.#pool.clear();
this.#busy.clear(); this.#refCounts.clear();
this.#waitQueue.clear();
} }
// #endregion // #endregion
// #region Private Helper Methods // #region Private Helper Methods
#findAvailableSocket(url: string): WebSocket | null {
const sockets = this.#pool.get(url);
if (!sockets) {
return null;
}
return sockets.find(ws => !this.#busy.has(ws) && ws.readyState === WebSocket.OPEN) ?? null;
}
#createSocket(url: string): Promise<WebSocket> { #createSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const ws = new WebSocket(url); const ws = new WebSocket(url);
ws.onopen = () => { ws.onopen = () => {
const sockets = this.#pool.get(url) || []; this.#pool.set(url, ws);
sockets.push(ws); this.#refCounts.set(ws, 1);
this.#pool.set(url, sockets);
this.#busy.add(ws); // Remove the socket from the pool when it is closed. The socket may be closed by
// either the client or the server.
ws.onclose = () => this.#removeSocket(ws);
resolve(ws); resolve(ws);
}; };
// Remove the socket from the pool when it is closed. The socket may be closed by either ws.onerror = (event) => {
// the client or the server. this.#processWaitingQueue();
ws.onclose = () => this.#removeSocket(ws); reject(new Error(`[WebSocketPool] WebSocket connection failed for ${url}: ${event.type}`));
};
ws.onerror = () => reject(new Error('WebSocket error'));
} catch (error) { } catch (error) {
this.#processWaitingQueue();
reject(error); reject(error);
} }
}); });
@ -174,33 +215,25 @@ export class WebSocketPool {
#removeSocket(ws: WebSocket): void { #removeSocket(ws: WebSocket): void {
const url = ws.url; const url = ws.url;
const sockets = this.#pool.get(url); this.#pool.delete(url);
if (sockets) { this.#refCounts.delete(ws);
const index = sockets.indexOf(ws);
if (index > -1) {
sockets.splice(index, 1);
}
if (sockets.length === 0) {
this.#pool.delete(url);
}
}
this.#busy.delete(ws);
this.#clearIdleTimer(ws); this.#clearIdleTimer(ws);
this.#processWaitingQueue();
} }
/** /**
* Starts an idle timer for the specified WebSocket. The connection will be automatically * Starts an idle timer for the specified WebSocket. The connection will be automatically
* closed after the idle timeout period if it remains unused. * closed after the idle timeout period if it remains unused.
* *
* @param ws - The WebSocket to start the idle timer for. * @param ws - The WebSocket for which to start the idle timer.
*/ */
#startIdleTimer(ws: WebSocket): void { #startIdleTimer(ws: WebSocket): void {
// Clear any existing timer first // Clear any existing timer first
this.#clearIdleTimer(ws); this.#clearIdleTimer(ws);
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Check if the socket is still idle (not in busy set) before closing const refCount = this.#refCounts.get(ws);
if (!this.#busy.has(ws) && ws.readyState === WebSocket.OPEN) { if ((!refCount || refCount === 0) && ws.readyState === WebSocket.OPEN) {
ws.close(); ws.close();
this.#removeSocket(ws); this.#removeSocket(ws);
} }
@ -212,7 +245,7 @@ export class WebSocketPool {
/** /**
* Clears the idle timer for the specified WebSocket. * Clears the idle timer for the specified WebSocket.
* *
* @param ws - The WebSocket to clear the idle timer for. * @param ws - The WebSocket for which to clear the idle timer.
*/ */
#clearIdleTimer(ws: WebSocket): void { #clearIdleTimer(ws: WebSocket): void {
const timer = this.#idleTimers.get(ws); const timer = this.#idleTimers.get(ws);
@ -222,5 +255,56 @@ export class WebSocketPool {
} }
} }
#processWaitingQueue(): void {
while (
this.#waitingQueue.length > 0 &&
this.#pool.size + this.#connecting.size < this.#maxConnections
) {
const nextInQueue = this.#waitingQueue.shift();
if (!nextInQueue) {
continue;
}
const { url, resolve, reject } = nextInQueue;
// Re-check if a connection for this URL was created while this request was in the queue
const existingSocket = this.#pool.get(url);
if (existingSocket && existingSocket.readyState < WebSocket.CLOSING) {
this.#checkOutSocket(existingSocket);
resolve(existingSocket);
} else {
const connectingPromise = this.#connecting.get(url);
if (connectingPromise) {
connectingPromise.then(resolve, reject);
}
}
}
}
#checkOutSocket(ws: WebSocket): void {
const count = (this.#refCounts.get(ws) || 0) + 1;
this.#refCounts.set(ws, count);
this.#clearIdleTimer(ws);
}
#normalizeUrl(url: string): string {
try {
const urlObj = new URL(url);
// The URL constructor correctly normalizes scheme and hostname casing.
let normalized = urlObj.toString();
// The logic to remove a trailing slash for connection coalescing can be kept,
// but should be done on the normalized string.
if (urlObj.pathname !== '/' && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
} catch {
// If URL is invalid, return it as-is and let WebSocket constructor handle the error.
return url;
}
}
// #endregion // #endregion
} }

Loading…
Cancel
Save