Commit 92fd55f
authored
🤖 fix: auto-reconnect WebSocket on server restart (#1071)
## Problem
When running `make dev-server` and the backend restarts via nodemon, the
frontend incorrectly shows "Authentication Required" modal instead of
automatically reconnecting.
## Root Cause
- The `error` handler set `auth_required` even when no token existed
- The `close` handler only handled auth codes (1008/4401), ignoring
normal disconnection codes (1001, 1006)
- No reconnection logic existed
## Solution
Implement auto-reconnect with exponential backoff:
- **New `reconnecting` status** - allows the app to stay visible while
reconnecting
- **Track connection history** - distinguish "first connect failed" vs
"was connected, lost connection"
- **Exponential backoff** - 100ms base delay, doubling up to 10s max, 10
attempts before giving up
- **Smart error handling**:
- Auth error codes (1008, 4401) → show auth modal
- Normal disconnects after being connected → reconnect silently
- First connection failure → show auth modal (server might require auth)
## Testing
Added 5 unit tests covering:
- Reconnects on close without showing auth_required when previously
connected
- Shows auth_required on close with auth error codes (4401, 1008)
- Shows auth_required on first connection error without token
- Reconnects on error when previously connected
---
<details>
<summary>📋 Implementation Plan</summary>
# Plan: Fix WebSocket Reconnection on Server Restart
## Problem Statement
When running `make dev-server` and the backend restarts via nodemon, the
frontend incorrectly shows "Authentication Required" modal instead of
automatically reconnecting.
## Root Cause Analysis
In `src/browser/contexts/API.tsx`:
1. **`error` handler (lines 152-160)** - Sets `auth_required` even when
no token exists:
```typescript
} else {
setState({ status: "auth_required" }); // Wrong: assumes auth needed
}
```
2. **`close` handler (lines 162-168)** - Only handles auth codes
1008/4401, ignores normal disconnection codes (1001, 1006)
3. **No reconnection logic** - Frontend never attempts to reconnect
after disconnection
## Solution: Auto-reconnect with exponential backoff
### Changes to `src/browser/contexts/API.tsx`
#### 1. Add new connection state for reconnecting
```typescript
export type APIState =
| { status: "connecting"; api: null; error: null }
| { status: "connected"; api: APIClient; error: null }
| { status: "reconnecting"; api: null; error: null; attempt: number } // NEW
| { status: "auth_required"; api: null; error: string | null }
| { status: "error"; api: null; error: string };
```
#### 2. Track "has ever connected" state
Add a ref to track whether we've successfully connected at least once:
```typescript
const hasConnectedRef = useRef(false);
```
Set to `true` when connection succeeds; used to distinguish "first
connect failed" vs "reconnect needed".
#### 3. Add reconnection logic with exponential backoff
```typescript
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_DELAY_MS = 100;
const MAX_DELAY_MS = 10000;
const scheduleReconnect = useCallback(() => {
const attempt = reconnectAttemptRef.current;
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setState({ status: "error", error: "Connection lost. Please refresh the page." });
return;
}
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS);
reconnectAttemptRef.current = attempt + 1;
setState({ status: "reconnecting", api: null, error: null, attempt: attempt + 1 });
reconnectTimeoutRef.current = setTimeout(() => {
connect(authToken);
}, delay);
}, [authToken, connect]);
```
#### 4. Update error handler
```typescript
ws.addEventListener("error", () => {
cleanup();
if (hasConnectedRef.current) {
// Was connected before - try to reconnect
scheduleReconnect();
} else if (token) {
// First connection failed with token - might be invalid
clearStoredAuthToken();
setState({ status: "auth_required", error: "Connection failed - invalid token?" });
} else {
// First connection failed without token - server might require auth
setState({ status: "auth_required" });
}
});
```
#### 5. Update close handler
```typescript
ws.addEventListener("close", (event) => {
// Auth-specific close codes
if (event.code === 1008 || event.code === 4401) {
cleanup();
clearStoredAuthToken();
hasConnectedRef.current = false; // Reset - need fresh auth
setState({ status: "auth_required", error: "Authentication required" });
return;
}
// Normal disconnection (server restart, network issue)
if (hasConnectedRef.current) {
cleanup();
scheduleReconnect();
}
});
```
#### 6. Reset reconnect state on successful connection
In the `open` handler's success path:
```typescript
.then(() => {
hasConnectedRef.current = true;
reconnectAttemptRef.current = 0; // Reset backoff
// ... existing code
})
```
#### 7. Cleanup on unmount
```typescript
useEffect(() => {
connect(authToken);
return () => {
cleanupRef.current?.();
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
```
### Changes to `src/browser/App.tsx`
Update the status check to handle `reconnecting` state - no modal, just
let the app show while reconnecting:
```typescript
// Show auth modal if authentication is required
if (status === "auth_required") {
return <AuthTokenModal isOpen={true} onSubmit={authenticate} error={error} />;
}
// Reconnecting is handled gracefully - app stays visible
// Optional: could add a subtle toast/indicator later
```
## Files to Modify
1. `src/browser/contexts/API.tsx` - Main changes (reconnection logic)
2. `src/browser/App.tsx` - Handle `reconnecting` state (minor, just
update type handling)
## Testing
### Unit Test: `src/browser/contexts/API.test.tsx`
Test the reconnection behavior without showing auth modal on disconnect:
```typescript
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { APIProvider, useAPI } from "./API";
// Mock WebSocket that we can control
class MockWebSocket {
static instances: MockWebSocket[] = [];
url: string;
readyState = 0; // CONNECTING
onopen: (() => void) | null = null;
onclose: ((event: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
eventListeners: Map<string, Function[]> = new Map();
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
addEventListener(event: string, handler: Function) {
const handlers = this.eventListeners.get(event) || [];
handlers.push(handler);
this.eventListeners.set(event, handlers);
}
close() {
this.readyState = 3; // CLOSED
}
// Test helpers
simulateOpen() {
this.readyState = 1; // OPEN
this.eventListeners.get("open")?.forEach((h) => h());
}
simulateClose(code: number) {
this.readyState = 3;
this.eventListeners.get("close")?.forEach((h) => h({ code }));
}
simulateError() {
this.eventListeners.get("error")?.forEach((h) => h());
}
static reset() {
MockWebSocket.instances = [];
}
static lastInstance() {
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
}
}
// Test component to observe API state
function APIStateObserver({ onState }: { onState: (state: ReturnType<typeof useAPI>) => void }) {
const apiState = useAPI();
onState(apiState);
return null;
}
describe("API reconnection", () => {
beforeEach(() => {
const window = new GlobalWindow();
globalThis.window = window as unknown as Window & typeof globalThis;
globalThis.document = window.document;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
MockWebSocket.reset();
});
afterEach(() => {
cleanup();
MockWebSocket.reset();
});
test("reconnects on close without showing auth_required when previously connected", async () => {
const states: string[] = [];
render(
<APIProvider>
<APIStateObserver onState={(s) => states.push(s.status)} />
</APIProvider>
);
// Initial connection
const ws1 = MockWebSocket.lastInstance();
expect(ws1).toBeDefined();
// Simulate successful connection
await act(async () => {
ws1.simulateOpen();
// Mock the ping succeeding (need to mock orpc client)
});
// Simulate server restart (close code 1006 = abnormal closure)
await act(async () => {
ws1.simulateClose(1006);
});
// Should be "reconnecting", NOT "auth_required"
await waitFor(() => {
expect(states).toContain("reconnecting");
expect(states.filter((s) => s === "auth_required")).toHaveLength(0);
});
// New WebSocket should be created for reconnect attempt
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
});
test("shows auth_required on close with auth error codes (1008, 4401)", async () => {
const states: string[] = [];
render(
<APIProvider>
<APIStateObserver onState={(s) => states.push(s.status)} />
</APIProvider>
);
const ws1 = MockWebSocket.lastInstance();
// Simulate successful connection then auth rejection
await act(async () => {
ws1.simulateOpen();
ws1.simulateClose(4401); // Auth required code
});
await waitFor(() => {
expect(states).toContain("auth_required");
});
});
test("shows error after max reconnect attempts exhausted", async () => {
// Fast-forward timers for this test
const states: string[] = [];
render(
<APIProvider>
<APIStateObserver onState={(s) => states.push(s.status)} />
</APIProvider>
);
// Simulate 10 failed reconnection attempts
for (let i = 0; i < 11; i++) {
const ws = MockWebSocket.lastInstance();
await act(async () => {
ws.simulateError();
});
// Advance timers for backoff delay
}
await waitFor(() => {
expect(states).toContain("error");
});
});
});
```
### Key Test Cases
| Scenario | Expected Status | Auth Modal? |
|----------|-----------------|-------------|
| Server restart (close code 1006) after connected | `reconnecting` | No
|
| Server restart (close code 1001) after connected | `reconnecting` | No
|
| Auth rejection (close code 4401) | `auth_required` | Yes |
| Auth rejection (close code 1008) | `auth_required` | Yes |
| First connection fails, no token | `auth_required` | Yes |
| First connection fails, with token | `auth_required` | Yes |
| Max reconnect attempts (10) exhausted | `error` | No |
### Manual Testing
1. Run `make dev-server`
2. Open frontend in browser
3. Make a code change to trigger nodemon restart
4. Verify frontend reconnects automatically without showing auth modal
5. Test with auth token to ensure auth errors still show modal
appropriately
</details>
---
_Generated with `mux`_
---------
Signed-off-by: Thomas Kosiewski <[email protected]>1 parent 89e52e0 commit 92fd55f
2 files changed
+345
-13
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
0 commit comments