Skip to content

Commit 32e7811

Browse files
anatoly314claude
andcommitted
Enforce single active GUI connection with proper race condition handling
- Add async locking mechanism to prevent race conditions during connection setup - Wait for existing connection setup to complete before accepting new connection - Remove event listeners from old connection before closing to prevent interference - Add timeout failsafe (2 seconds) to prevent deadlocks - Frontend detects "Replaced by new connection" and shows appropriate message - No reconnection attempts when connection is explicitly replaced - Update CLAUDE.md with single-connection behavior documentation - Bump version to 1.1.5 Fixes issue where multiple tabs could remain connected simultaneously due to race conditions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent a9bde62 commit 32e7811

File tree

5 files changed

+106
-34
lines changed

5 files changed

+106
-34
lines changed

CLAUDE.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,17 @@ This is a **pnpm workspaces + Turborepo** monorepo (converted from standalone ap
140140
- WebSocket auto-detects URL from browser (supports both direct access and nginx proxy)
141141
- Auto-detection: `ws://localhost:5173/remote-control` (dev) or `ws://localhost:8080/remote-control` (Docker)
142142
- Protocol switches to `wss://` for HTTPS connections
143+
- **Single Active Connection**: Only one GUI can control the diagram at a time
144+
- When a new GUI connects, the previous connection is gracefully closed
145+
- Old GUI shows "AI Assistant disconnected - connection taken over by another tab/window"
146+
- Prevents confusion from multiple tabs trying to control the same backend
143147
- Commands are handled by contexts (`DiagramContext`, `AreasContext`, `NotesContext`, etc.)
144148
- Automatic reconnection with exponential backoff (max 10 attempts, up to 30s delay with jitter)
145-
- Connection status displayed via Toast notifications ("AI Assistant connected/disconnected")
149+
- Exception: Won't reconnect if connection was replaced by another session
150+
- Connection status displayed via Toast notifications and persistent status badge
146151
- All commands processed through `useRemoteControl.js:152` switch statement
147152
- Command timeout: 30 seconds (configured in `DrawDBClientService:15`)
153+
- Heartbeat: Frontend sends ping every 30 seconds to keep connection alive
148154

149155
### Backend (apps/backend)
150156

@@ -402,6 +408,8 @@ function MyComponent() {
402408

403409
- **WebSocket connection fails**: Check that `VITE_REMOTE_CONTROL_ENABLED=true` and backend is running
404410
- **MCP tools timeout**: Default timeout is 30s (configured in `DrawDBClientService:15`)
411+
- **Multiple tabs/windows**: Only one GUI can be connected at a time. Opening a new tab will disconnect the previous one with message "connection taken over by another tab/window"
412+
- **Connection replaced**: If you see "connection taken over", another tab/window is now active. Close it or refresh this tab to reconnect
405413
- **Docker nginx issues**: Nginx runs as non-root user `nodejs:nodejs`, requires proper permissions
406414
- **Build failures**: Run `pnpm clean` then `pnpm install` to reset
407415
- **Turborepo cache issues**: Delete `.turbo` directory to clear cache
@@ -431,7 +439,7 @@ function MyComponent() {
431439

432440
### Version Management
433441

434-
- Backend version: `apps/backend/package.json` (currently 1.1.4)
442+
- Backend version: `apps/backend/package.json` (currently 1.1.5)
435443
- GUI version: `apps/gui/package.json` (currently 1.0.0)
436444
- Root package: `package.json` (workspace root, 1.0.0)
437445

apps/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "backend",
3-
"version": "1.1.4",
3+
"version": "1.1.5",
44
"description": "Model Context Protocol server for DrawDB - enables AI assistants to create and modify database diagrams via WebSocket",
55
"author": "Anatoly Tarnavsky",
66
"private": true,

apps/backend/src/drawdb/drawdb-client.service.ts

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DrawDBCommand, DrawDBResponse } from './drawdb.types';
88
export class DrawDBClientService {
99
private readonly logger = new Logger(DrawDBClientService.name);
1010
private ws: any = null;
11+
private isSettingConnection = false; // Lock to prevent race conditions
1112
private pendingRequests = new Map<
1213
string,
1314
{ resolve: (value: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }
@@ -16,42 +17,93 @@ export class DrawDBClientService {
1617

1718
/**
1819
* Set the WebSocket connection
20+
* Closes any existing connection to ensure only one GUI is active at a time
21+
* Uses a lock to prevent race conditions from simultaneous connections
1922
*/
20-
setConnection(ws: any) {
21-
this.ws = ws;
22-
this.logger.log('DrawDB client connected');
23+
async setConnection(ws: any) {
24+
// Wait if another connection is being set up (prevents race condition)
25+
let waitCount = 0;
26+
while (this.isSettingConnection && waitCount < 20) {
27+
// Wait max 2 seconds (20 * 100ms)
28+
this.logger.warn(
29+
`Connection setup already in progress, waiting... (${waitCount + 1}/20)`,
30+
);
31+
await new Promise((resolve) => setTimeout(resolve, 100));
32+
waitCount++;
33+
}
2334

24-
// Setup message handler
25-
ws.on('message', (data: string) => {
26-
try {
27-
const message = JSON.parse(data);
35+
if (this.isSettingConnection) {
36+
this.logger.error('Connection setup timeout - forcing connection replacement');
37+
this.isSettingConnection = false; // Force unlock
38+
}
2839

29-
// Handle ping/pong heartbeat
30-
if (message.type === 'ping') {
31-
this.logger.debug('Received ping, sending pong');
32-
ws.send(JSON.stringify({ type: 'pong' }));
33-
return;
34-
}
40+
// Set lock
41+
this.isSettingConnection = true;
3542

36-
// Handle normal command responses
37-
const response: DrawDBResponse = message;
38-
this.handleResponse(response);
39-
} catch (error) {
40-
this.logger.error('Failed to parse message from DrawDB client:', error);
43+
try {
44+
// Close existing connection if present
45+
if (this.ws) {
46+
const oldWs = this.ws;
47+
const isOpen = oldWs.readyState === 1; // WebSocket.OPEN = 1
48+
49+
if (isOpen) {
50+
this.logger.log(
51+
'Closing previous DrawDB client connection - new client connecting',
52+
);
53+
54+
// Remove event handlers from old connection to prevent interference
55+
oldWs.removeAllListeners();
56+
57+
// Close the old connection
58+
oldWs.close(1000, 'Replaced by new connection');
59+
60+
// Wait a tiny bit for close to propagate
61+
await new Promise((resolve) => setTimeout(resolve, 50));
62+
} else {
63+
this.logger.debug(
64+
`Previous connection already closed (state: ${oldWs.readyState})`,
65+
);
66+
}
4167
}
42-
});
4368

44-
// Setup close handler
45-
ws.on('close', () => {
46-
this.logger.log('DrawDB client disconnected');
47-
this.ws = null;
48-
// Reject all pending requests
49-
this.pendingRequests.forEach(({ reject, timeout }) => {
50-
clearTimeout(timeout);
51-
reject(new Error('DrawDB client disconnected'));
69+
this.ws = ws;
70+
this.logger.log('DrawDB client connected');
71+
72+
// Setup message handler
73+
ws.on('message', (data: string) => {
74+
try {
75+
const message = JSON.parse(data);
76+
77+
// Handle ping/pong heartbeat
78+
if (message.type === 'ping') {
79+
this.logger.debug('Received ping, sending pong');
80+
ws.send(JSON.stringify({ type: 'pong' }));
81+
return;
82+
}
83+
84+
// Handle normal command responses
85+
const response: DrawDBResponse = message;
86+
this.handleResponse(response);
87+
} catch (error) {
88+
this.logger.error('Failed to parse message from DrawDB client:', error);
89+
}
5290
});
53-
this.pendingRequests.clear();
54-
});
91+
92+
// Setup close handler
93+
ws.on('close', () => {
94+
this.logger.log('DrawDB client disconnected');
95+
this.ws = null;
96+
// Reject all pending requests
97+
this.pendingRequests.forEach(({ reject, timeout }) => {
98+
clearTimeout(timeout);
99+
reject(new Error('DrawDB client disconnected'));
100+
});
101+
this.pendingRequests.clear();
102+
});
103+
} finally {
104+
// Release lock
105+
this.isSettingConnection = false;
106+
}
55107
}
56108

57109
/**

apps/backend/src/drawdb/drawdb.gateway.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ export class DrawDBGateway implements OnGatewayConnection, OnGatewayDisconnect {
2626

2727
constructor(private readonly drawdbClient: DrawDBClientService) {}
2828

29-
handleConnection(client: any) {
29+
async handleConnection(client: any) {
3030
this.logger.log('DrawDB client attempting to connect...');
3131

3232
// Set the client connection in the service
33-
this.drawdbClient.setConnection(client);
33+
// This is async to properly handle connection replacement and prevent race conditions
34+
await this.drawdbClient.setConnection(client);
3435
}
3536

3637
handleDisconnect(client: any) {

apps/gui/src/hooks/useRemoteControl.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ export function useRemoteControl(enabled = false) {
124124
pingIntervalRef.current = null;
125125
}
126126

127+
// Check if connection was replaced by another session
128+
const wasReplaced = event.code === 1000 && event.reason === "Replaced by new connection";
129+
130+
if (wasReplaced) {
131+
// Connection taken over by another tab/window - don't reconnect
132+
console.log("[RemoteControl] Connection taken over by another session");
133+
Toast.warning("AI Assistant disconnected - connection taken over by another tab/window");
134+
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent reconnection
135+
return;
136+
}
137+
127138
// Only attempt to reconnect if enabled and haven't exceeded max attempts
128139
if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) {
129140
reconnectAttemptsRef.current++;

0 commit comments

Comments
 (0)