🐛 Preserve established plugin connection when rejecting a duplicate #10961

In multi-user mode, rejecting a second plugin WebSocket connection for an
already-registered user token performed the full removeConnection cleanup
for the newcomer. Since the token-keyed cleanup is keyed by token rather
than by socket, this deleted the clientsByToken entry and the Redis
request-channel subscription of the established, healthy connection. That
connection then remained open and heartbeating but was unroutable, so every
subsequent MCP tool call for the user failed although a valid plugin
connection existed.

removeConnection now performs the token-keyed cleanup only if the removed
connection actually owns the token registration, so rejecting a duplicate
releases only the resources the newcomer itself registered.

AI-assisted-by: claude-fable-5
This commit is contained in:
Dominik Jain 2026-07-30 10:40:33 +00:00 committed by Andrey Antukh
parent 1ae9334064
commit b4659df5b2

View File

@ -133,9 +133,10 @@ export class PluginBridge {
/**
* Removes a client connection and releases all resources associated with it.
*
* Clears the per-connection keep-alive interval and removes the connection
* from both the socket-keyed and token-keyed indexes. Safe to call with a
* socket that is not (or no longer) registered.
* Clears the per-connection keep-alive interval and removes the connection from the
* socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the
* token's Redis task subscription) is removed only if it is owned by the given
* connection. Safe to call with a socket that is not (or no longer) registered.
*
* @param ws - The WebSocket whose connection state should be removed
*/
@ -147,12 +148,18 @@ export class PluginBridge {
clearInterval(connection.pingInterval);
this.connectedClients.delete(ws);
if (connection.userToken) {
this.clientsByToken.delete(connection.userToken);
// Perform the token-keyed cleanup only if this connection owns the token registration.
// A connection rejected as a duplicate carries the same token but must not remove token associations.
if (this.clientsByToken.get(connection.userToken) !== connection) {
this.logger.debug("Removed connection does not own its token registration; skipping token cleanup");
} else {
this.clientsByToken.delete(connection.userToken);
if (this.redisBridge) {
this.redisBridge
.unsubscribeFromTasks(connection.userToken)
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
if (this.redisBridge) {
this.redisBridge
.unsubscribeFromTasks(connection.userToken)
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
}
}
}
}