Skip to content

Commit 83c0704

Browse files
committed
CU-868fdadum PR#166 fixes
1 parent 5fd3e37 commit 83c0704

File tree

4 files changed

+397
-27
lines changed

4 files changed

+397
-27
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# SignalR Service Reconnection Self-Blocking Fix
2+
3+
## Problem
4+
5+
The SignalR service had a self-blocking issue where reconnection attempts would prevent direct connection attempts. Specifically:
6+
7+
1. When a hub was in "reconnecting" state, direct connection attempts would be blocked
8+
2. This could lead to scenarios where:
9+
- A reconnection attempt was in progress
10+
- A user tried to manually connect
11+
- The manual connection would be rejected
12+
- If the reconnection failed, the hub would be stuck in reconnecting state
13+
- Future manual connection attempts would continue to be blocked
14+
15+
## Root Cause
16+
17+
The service used a single `reconnectingHubs` Set to track both:
18+
- Automatic reconnection attempts
19+
- Direct connection attempts
20+
21+
This caused the guard logic in `_connectToHubInternal` (lines 240-246) to block direct connections when hubs were in reconnecting state.
22+
23+
## Solution
24+
25+
Implemented a more granular state management system:
26+
27+
### 1. New State Enum
28+
29+
```typescript
30+
export enum HubConnectingState {
31+
IDLE = 'idle',
32+
RECONNECTING = 'reconnecting',
33+
DIRECT_CONNECTING = 'direct-connecting',
34+
}
35+
```
36+
37+
### 2. State Management
38+
39+
- Added `hubStates: Map<string, HubConnectingState>` to track individual hub states
40+
- Added `setHubState()` method to manage state transitions and maintain backward compatibility
41+
- Added helper methods: `isHubConnecting()`, `isHubReconnecting()`
42+
43+
### 3. Updated Connection Logic
44+
45+
**Direct Connections (`_connectToHubInternal` and `_connectToHubWithEventingUrlInternal`):**
46+
- Only block duplicate direct connections (same `DIRECT_CONNECTING` state)
47+
- Allow direct connections even when hub is in `RECONNECTING` state
48+
- Log reconnecting state but proceed with connection attempt
49+
- Set state to `DIRECT_CONNECTING` during connection attempt
50+
- Clean up state on both success and failure
51+
52+
**Automatic Reconnections:**
53+
- Set state to `RECONNECTING` during reconnection attempts
54+
- Clean up state on both success and failure
55+
- Maintain existing reconnection logic and limits
56+
57+
### 4. Backward Compatibility
58+
59+
- Maintained the `reconnectingHubs` Set for existing API compatibility
60+
- `setHubState()` automatically manages the legacy set alongside the new state map
61+
- All existing methods continue to work as expected
62+
63+
## Key Changes
64+
65+
1. **Lines 240-246**: Changed from blocking all connections during reconnect to only blocking duplicate direct connections
66+
2. **State Management**: Added proper state tracking with cleanup in success/failure paths
67+
3. **Connection Isolation**: Reconnection attempts and direct connections now operate independently
68+
4. **Cleanup**: Ensured state cleanup happens in all code paths to prevent stuck states
69+
70+
## Testing
71+
72+
- Updated existing tests to use new state management system
73+
- All existing tests continue to pass
74+
- Tests verify that direct connections are allowed during reconnection
75+
- Tests verify proper state cleanup in success and failure scenarios
76+
77+
## Benefits
78+
79+
- Eliminates self-blocking behavior during reconnections
80+
- Allows users to manually retry connections even during automatic reconnection
81+
- Prevents permanent stuck states
82+
- Maintains full backward compatibility
83+
- Provides better separation of concerns between automatic and manual connections
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
2+
3+
import { logger } from '@/lib/logging';
4+
import { HubConnectingState } from '../signalr.service';
5+
6+
// Mock the required modules
7+
jest.mock('@/lib/env', () => ({
8+
Env: {
9+
REALTIME_GEO_HUB_NAME: 'geolocationHub',
10+
},
11+
}));
12+
13+
jest.mock('@/lib/logging', () => ({
14+
logger: {
15+
info: jest.fn(),
16+
warn: jest.fn(),
17+
error: jest.fn(),
18+
debug: jest.fn(),
19+
},
20+
}));
21+
22+
jest.mock('@/stores/auth/store', () => ({
23+
__esModule: true,
24+
default: {
25+
getState: jest.fn(() => ({
26+
accessToken: 'mock-token',
27+
refreshAccessToken: jest.fn().mockResolvedValue(undefined),
28+
})),
29+
},
30+
}));
31+
32+
jest.mock('@microsoft/signalr', () => ({
33+
HubConnectionBuilder: jest.fn(),
34+
LogLevel: {
35+
Information: 'Information',
36+
},
37+
HubConnectionState: {
38+
Connected: 'Connected',
39+
Disconnected: 'Disconnected',
40+
},
41+
}));
42+
43+
// Import after mocking
44+
import { SignalRService } from '../signalr.service';
45+
46+
describe('SignalRService - Reconnect Self-Blocking Fix', () => {
47+
let signalRService: any;
48+
let mockConnection: jest.Mocked<HubConnection>;
49+
let mockBuilderInstance: jest.Mocked<HubConnectionBuilder>;
50+
const mockLogger = logger as jest.Mocked<typeof logger>;
51+
52+
const mockHubConnectionBuilder = HubConnectionBuilder as jest.MockedClass<typeof HubConnectionBuilder>;
53+
const mockStart = jest.fn().mockResolvedValue(undefined);
54+
55+
beforeEach(() => {
56+
jest.clearAllMocks();
57+
// Reset the singleton instance
58+
SignalRService.resetInstance();
59+
signalRService = SignalRService.getInstance();
60+
61+
// Mock HubConnection
62+
mockConnection = {
63+
start: mockStart,
64+
stop: jest.fn().mockResolvedValue(undefined),
65+
on: jest.fn(),
66+
onclose: jest.fn(),
67+
onreconnecting: jest.fn(),
68+
onreconnected: jest.fn(),
69+
invoke: jest.fn().mockResolvedValue(undefined),
70+
state: 'Connected',
71+
} as any;
72+
73+
// Mock HubConnectionBuilder
74+
mockBuilderInstance = {
75+
withUrl: jest.fn().mockReturnThis(),
76+
withAutomaticReconnect: jest.fn().mockReturnThis(),
77+
configureLogging: jest.fn().mockReturnThis(),
78+
build: jest.fn().mockReturnValue(mockConnection),
79+
} as any;
80+
81+
mockHubConnectionBuilder.mockImplementation(() => mockBuilderInstance);
82+
});
83+
84+
afterEach(() => {
85+
jest.clearAllMocks();
86+
});
87+
88+
const mockConfig = {
89+
name: 'testHub',
90+
eventingUrl: 'https://api.example.com/',
91+
hubName: 'eventingHub',
92+
methods: ['method1'],
93+
};
94+
95+
describe('Direct connection during reconnection', () => {
96+
it('should allow direct connection attempts when hub is in reconnecting state', async () => {
97+
// Set hub to reconnecting state
98+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
99+
100+
// Verify hub is in reconnecting state
101+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
102+
103+
// Attempt direct connection - should not be blocked
104+
await signalRService.connectToHubWithEventingUrl(mockConfig);
105+
106+
// Should have attempted the connection
107+
expect(mockHubConnectionBuilder).toHaveBeenCalled();
108+
expect(mockLogger.info).toHaveBeenCalledWith({
109+
message: `Hub ${mockConfig.name} is currently reconnecting, proceeding with direct connection attempt`,
110+
});
111+
});
112+
113+
it('should prevent duplicate direct connections', async () => {
114+
// Set hub to direct-connecting state
115+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING);
116+
117+
// Attempt another direct connection - should be blocked
118+
await signalRService.connectToHubWithEventingUrl(mockConfig);
119+
120+
// Should not have attempted the connection
121+
expect(mockHubConnectionBuilder).not.toHaveBeenCalled();
122+
expect(mockLogger.info).toHaveBeenCalledWith({
123+
message: `Hub ${mockConfig.name} is already in direct-connecting state, skipping duplicate connection attempt`,
124+
});
125+
});
126+
127+
it('should clean up direct-connecting state on successful connection', async () => {
128+
// Start with idle state
129+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
130+
131+
// Attempt connection
132+
await signalRService.connectToHubWithEventingUrl(mockConfig);
133+
134+
// Should be back to idle state after successful connection
135+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
136+
expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined();
137+
});
138+
139+
it('should clean up direct-connecting state on failed connection', async () => {
140+
// Mock connection failure
141+
mockStart.mockRejectedValueOnce(new Error('Connection failed'));
142+
143+
// Start with idle state
144+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
145+
146+
// Attempt connection (should fail)
147+
await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow('Connection failed');
148+
149+
// Should be back to idle state after failed connection
150+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
151+
expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined();
152+
153+
// Reset mock for future tests
154+
mockStart.mockResolvedValue(undefined);
155+
});
156+
157+
it('should maintain backward compatibility with legacy reconnectingHubs set', async () => {
158+
// Set hub to reconnecting state
159+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
160+
161+
// Legacy reconnectingHubs set should also be updated
162+
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(true);
163+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
164+
165+
// Clear state
166+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE);
167+
168+
// Legacy set should be cleared too
169+
expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false);
170+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
171+
});
172+
});
173+
174+
describe('State management', () => {
175+
it('should distinguish between reconnecting and direct-connecting states', () => {
176+
// Set to reconnecting
177+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
178+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
179+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true);
180+
181+
// Set to direct-connecting
182+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING);
183+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
184+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true);
185+
186+
// Set to idle
187+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE);
188+
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false);
189+
expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false);
190+
});
191+
192+
it('should properly manage isHubAvailable with new states', () => {
193+
const hubName = 'testHub';
194+
195+
// Not connected, not connecting
196+
expect(signalRService.isHubAvailable(hubName)).toBe(false);
197+
198+
// Reconnecting
199+
(signalRService as any).setHubState(hubName, HubConnectingState.RECONNECTING);
200+
expect(signalRService.isHubAvailable(hubName)).toBe(true);
201+
202+
// Direct connecting
203+
(signalRService as any).setHubState(hubName, HubConnectingState.DIRECT_CONNECTING);
204+
expect(signalRService.isHubAvailable(hubName)).toBe(true);
205+
206+
// Add actual connection
207+
(signalRService as any).connections.set(hubName, mockConnection);
208+
(signalRService as any).setHubState(hubName, HubConnectingState.IDLE);
209+
expect(signalRService.isHubAvailable(hubName)).toBe(true);
210+
211+
// Clean up
212+
(signalRService as any).connections.delete(hubName);
213+
expect(signalRService.isHubAvailable(hubName)).toBe(false);
214+
});
215+
});
216+
});

src/services/__tests__/signalr.service.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
22

33
import { logger } from '@/lib/logging';
4+
import { HubConnectingState } from '../signalr.service';
45

56
// Mock the env module
67
jest.mock('@/lib/env', () => ({
@@ -509,8 +510,8 @@ describe('SignalRService', () => {
509510
});
510511

511512
it('should return true for isHubAvailable when hub is reconnecting', () => {
512-
// Manually set reconnecting state to test
513-
(signalRService as any).reconnectingHubs.add(mockConfig.name);
513+
// Manually set reconnecting state to test using new state management
514+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
514515
expect(signalRService.isHubAvailable(mockConfig.name)).toBe(true);
515516
});
516517

@@ -519,22 +520,23 @@ describe('SignalRService', () => {
519520
});
520521

521522
it('should return true for isHubReconnecting when hub is reconnecting', () => {
522-
// Manually set reconnecting state to test
523-
(signalRService as any).reconnectingHubs.add(mockConfig.name);
523+
// Manually set reconnecting state to test using new state management
524+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
524525
expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true);
525526
});
526527

527528
it('should skip connection if hub is already reconnecting', async () => {
528-
// Set reconnecting state
529-
(signalRService as any).reconnectingHubs.add(mockConfig.name);
529+
// Set reconnecting state using new state management
530+
(signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING);
530531

531532
// Try to connect
532533
await signalRService.connectToHubWithEventingUrl(mockConfig);
533534

534-
// Should not have started a new connection
535-
expect(mockHubConnectionBuilder).not.toHaveBeenCalled();
535+
// Should not have started a new connection because the new logic allows connections but logs it
536+
// The new logic doesn't block direct connections anymore, so this test behavior changes
537+
expect(mockHubConnectionBuilder).toHaveBeenCalled();
536538
expect(mockLogger.info).toHaveBeenCalledWith({
537-
message: `Hub ${mockConfig.name} is currently reconnecting, skipping duplicate connection attempt`,
539+
message: `Hub ${mockConfig.name} is currently reconnecting, proceeding with direct connection attempt`,
538540
});
539541
});
540542
});

0 commit comments

Comments
 (0)