mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +00:00
The request_full_resync field exists in eagle.proto but is not read by the server. The actual resync mechanism uses filteredResultCount = 0 instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
2075 lines
68 KiB
Markdown
2075 lines
68 KiB
Markdown
# Eagle0 Client/Server Connection Architecture
|
|
|
|
## Overview
|
|
|
|
Eagle0 uses a bidirectional streaming gRPC connection between the Unity C# client and the Scala Eagle server. The Shardok tactical combat server (C++) is not directly exposed to clients; instead, Eagle server acts as a proxy, communicating with Shardok via internal gRPC and forwarding updates to clients.
|
|
|
|
## Architecture Diagram
|
|
|
|
```
|
|
Unity Client (C#) <--[gRPC/HTTP2/TLS]--> nginx <--[HTTP]--> Eagle Server (Scala) <--[gRPC]--> Shardok Server (C++)
|
|
```
|
|
|
|
## Protocol Definition
|
|
|
|
**Service:** `src/main/protobuf/net/eagle0/eagle/api/eagle.proto`
|
|
|
|
```protobuf
|
|
service Eagle {
|
|
rpc StreamUpdates(stream UpdateStreamRequest) returns (stream UpdateStreamResponse) {}
|
|
}
|
|
```
|
|
|
|
This is a bidirectional streaming RPC where:
|
|
- **Client can send:** StreamGameRequest, HeartbeatRequest, EnterLobbyRequest, PostCommandRequest, ErrorRequest, etc.
|
|
- **Server can send:** GameUpdate, HeartbeatResponse, LobbyResponse, etc.
|
|
|
|
### Key Message Types
|
|
|
|
**UpdateStreamRequest:**
|
|
- `StreamGameRequest`: Subscribe to game updates, includes:
|
|
- `game_id`: Which game to watch
|
|
- `unfiltered_result_count`: Last known Eagle action result count (for delta updates)
|
|
- `shardok_view_statuses[]`: Array of Shardok game states with their known result counts
|
|
- `streaming_text_statuses[]`: LLM text streaming progress
|
|
- `HeartbeatRequest`: Keepalive ping with client timestamp
|
|
- `PostCommandRequest`: Send Eagle or Shardok commands
|
|
|
|
**UpdateStreamResponse:**
|
|
- `GameUpdate`: Contains either Eagle action results or Shardok action results
|
|
- `HeartbeatResponse`: Keepalive pong with server timestamp
|
|
|
|
## Client Implementation (C#/Unity)
|
|
|
|
### Connection Setup
|
|
|
|
**File:** `Assets/EagleConnection.cs`
|
|
|
|
```csharp
|
|
private const double KeepAliveSeconds = 45.0;
|
|
|
|
_channel = GrpcChannel.ForAddress("https://" + url,
|
|
new GrpcChannelOptions {
|
|
HttpHandler = new YetAnotherHttpHandler {
|
|
Http2Only = true,
|
|
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds) // 45 seconds
|
|
},
|
|
// ...
|
|
});
|
|
```
|
|
|
|
**Key Settings:**
|
|
- **HTTP/2 KeepAlive Interval:** 45 seconds
|
|
- **Connection URL:** `https://<url>` (TLS required via nginx)
|
|
- **HTTP Handler:** YetAnotherHttpHandler (Unity-compatible HTTP/2 implementation)
|
|
|
|
### Persistent Connection Management
|
|
|
|
**File:** `Assets/Eagle/PersistentClientConnection.cs`
|
|
|
|
**Constants:**
|
|
```csharp
|
|
private const double TimeoutSeconds = 300.0; // 5 minutes
|
|
private const double HeartbeatTimerSeconds = 10.0; // 10 seconds
|
|
```
|
|
|
|
**Connection Lifecycle:**
|
|
|
|
1. **Connect()** (line 91)
|
|
- Creates bidirectional streaming call with 300-second deadline
|
|
- Starts background thread `HandleStreamingCall()` to process responses
|
|
- Sends `StreamGameRequest` for each subscribed game
|
|
- Prevents concurrent connection attempts with `_isConnecting` flag
|
|
|
|
2. **HandleStreamingCall()** (line 466)
|
|
- Runs in dedicated thread
|
|
- Continuously reads from `ResponseStream`
|
|
- Updates `_lastResponseReceived` timestamp on every message
|
|
- Sets up heartbeat timer via `SetUpTimer()` (line 475, 482)
|
|
- On exception: attempts reconnection based on status code
|
|
|
|
3. **HeartbeatTimer Logic** (line 648)
|
|
- Timer fires every 10 seconds
|
|
- **If no response > 20 seconds:** Forces reconnection by disposing stream and calling `Connect()`
|
|
- **If no response 10-20 seconds:** Sends heartbeat and creates new timer
|
|
- **If response < 10 seconds ago:** Heartbeat only sent when stream has new data
|
|
|
|
4. **Reconnection Handling** (line 579)
|
|
- **StatusCode.Cancelled:** Check if intentional cancellation, otherwise reconnect
|
|
- **StatusCode.Internal:** Immediate reconnection
|
|
- **StatusCode.DeadlineExceeded:** Immediate reconnection (300-second deadline hit)
|
|
- **StatusCode.Unavailable:** Wait 5 seconds, then reconnect
|
|
- **ObjectDisposedException:** Immediate reconnection
|
|
|
|
### Subscription Model
|
|
|
|
The client maintains a `Dictionary<EagleGameId, IClientConnectionSubscriber>` of active game subscriptions. When a game is subscribed:
|
|
|
|
1. Client sends `StreamGameRequest` with game ID and last known state
|
|
2. Server sends incremental updates (only new action results since `unfiltered_result_count`)
|
|
3. For Shardok games: Client sends `shardok_view_statuses[]` array with each Shardok game's last known result count
|
|
|
|
### Shardok Update Flow
|
|
|
|
**For Shardok tactical battles:**
|
|
|
|
1. Client tracks multiple concurrent Shardok games via `ShardokViewStatuses` array
|
|
2. Each `StreamGameRequest` includes:
|
|
```csharp
|
|
ShardokViewStatus {
|
|
string shardok_game_id
|
|
int32 filtered_result_count // Last known action count for this Shardok game
|
|
}
|
|
```
|
|
3. Server responds with `ShardokActionResultResponse` containing:
|
|
```protobuf
|
|
message SingleShardokGameResultResponse {
|
|
string shardok_game_id
|
|
repeated ActionResultView action_result_views // Only new actions since filtered_result_count
|
|
int32 new_result_view_count
|
|
AvailableCommands available_commands
|
|
}
|
|
```
|
|
|
|
This delta update mechanism reduces bandwidth during Shardok gameplay.
|
|
|
|
## Server Implementation (Scala)
|
|
|
|
**File:** `src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
|
|
|
### StreamUpdates Method
|
|
|
|
```scala
|
|
def streamUpdates(responseObserver: StreamObserver[UpdateStreamResponse]):
|
|
StreamObserver[UpdateStreamRequest] =
|
|
new SyncStreamObserver(new SyncResponseObserver(AuthorizationUtils.userName, responseObserver))
|
|
```
|
|
|
|
Creates a bidirectional stream handler that:
|
|
1. Wraps the response observer with `SyncResponseObserver` (username-tagged)
|
|
2. Returns `SyncStreamObserver` to handle client requests
|
|
|
|
### Request Handling
|
|
|
|
**SyncStreamObserver.onNext()** dispatches based on request type:
|
|
- `StreamGameRequest` → `streamOneUpdate()` → delegates to GamesManager or CustomBattleManager
|
|
- `HeartbeatRequest` → `handleHeartbeat()` → sends HeartbeatResponse immediately
|
|
- `PostCommandRequest` → `postCommand()` → processes Eagle/Shardok commands
|
|
- `EnterLobbyRequest` → adds user to lobby subscribers, sends lobby state
|
|
|
|
### Heartbeat Handling
|
|
|
|
```scala
|
|
private def handleHeartbeat(clientTimestamp: Long, responseObserver: SyncResponseObserver): Unit = {
|
|
println(s"got heartbeat from $userName. Client timestamp $clientTimestamp,
|
|
server timestamp ${System.currentTimeMillis()}")
|
|
responseObserver.onNext(
|
|
UpdateStreamResponse(
|
|
responseDetails = HeartbeatResponse(serverTimestamp = System.currentTimeMillis())
|
|
)
|
|
)
|
|
}
|
|
```
|
|
|
|
Server immediately responds to heartbeats with server timestamp. No server-side timeout enforcement visible in this layer.
|
|
|
|
### Eagle ↔ Shardok Communication
|
|
|
|
When client sends `ShardokCommand` or `ShardokPlacementCommands`:
|
|
|
|
1. Eagle server checks if game is in `gamesManager` or `customBattleManager`
|
|
2. Routes to appropriate manager's `postShardokCommand()` or `postPlacementCommands()`
|
|
3. Manager communicates with Shardok server via internal gRPC
|
|
4. Shardok action results flow back through Eagle server to client via `ShardokActionResultResponse`
|
|
|
|
## Network Layer: nginx Proxy
|
|
|
|
**File:** `/Users/dancrosby/CodingProjects/github/eagle0/eagle0.net` (sibling repo)
|
|
|
|
**Configuration:**
|
|
|
|
```nginx
|
|
location /net.eagle0.eagle.api.Eagle {
|
|
auth_basic "User Area";
|
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
|
grpc_pass grpc://192.168.123.100:40032;
|
|
proxy_connect_timeout 1200s; # 20 minutes
|
|
proxy_send_timeout 1200s; # 20 minutes
|
|
proxy_read_timeout 1200s; # 20 minutes
|
|
grpc_send_timeout 1200s; # 20 minutes
|
|
grpc_read_timeout 1200s; # 20 minutes
|
|
grpc_socket_keepalive on;
|
|
}
|
|
|
|
location /net.eagle0.common.ShardokInternalInterface {
|
|
grpc_pass grpc://192.168.123.100:40042;
|
|
}
|
|
```
|
|
|
|
**Key Settings:**
|
|
- **Protocol:** HTTP/2 over TLS (line 6: `listen 443 ssl http2`)
|
|
- **Eagle Server:** `grpc://192.168.123.100:40032`
|
|
- **Shardok Server:** `grpc://192.168.123.100:40042` (⚠️ EXPOSED TO CLIENTS!)
|
|
- **Timeouts:** 1200 seconds (20 minutes) - very generous
|
|
- **Socket Keepalive:** Enabled for Eagle endpoint
|
|
- **TLS:** Let's Encrypt certificate for eagle0.net
|
|
|
|
**Analysis:**
|
|
- nginx timeouts are NOT the problem (20 minutes is very generous)
|
|
- HTTP/2 is properly configured
|
|
- Socket keepalive is enabled
|
|
- ⚠️ **CRITICAL:** Shardok internal interface is exposed at `/net.eagle0.common.ShardokInternalInterface` without auth or timeouts!
|
|
|
|
## Server Implementation Details
|
|
|
|
**File:** `src/main/scala/net/eagle0/eagle/Main.scala`
|
|
|
|
```scala
|
|
val serverBuilder = ServerBuilder.forPort(grpcPort) // Default port: 40032
|
|
```
|
|
|
|
**Server Configuration:**
|
|
- Uses default `ServerBuilder.forPort()` with NO custom timeout settings
|
|
- gRPC Java defaults:
|
|
- `maxConnectionIdle`: infinity (no automatic closure of idle connections)
|
|
- `maxConnectionAge`: infinity (connections never expire)
|
|
- `keepAliveTime`: 2 hours (but NOT enforced by default)
|
|
- `keepAliveTimeout`: 20 seconds
|
|
|
|
**Key Finding:** Server does NOT enforce any timeouts that would cause 2-minute drops.
|
|
|
|
## Complete Timeout Settings Summary
|
|
|
|
| Layer | Component | Setting | Value | Notes |
|
|
|-------|-----------|---------|-------|-------|
|
|
| **Client** | HTTP/2 Handler | KeepAlive Interval | 45s | HTTP/2 PING frames (YetAnotherHttpHandler) |
|
|
| **Client** | gRPC Call | Deadline | 300s (5 min) | Per-call timeout for StreamUpdates() |
|
|
| **Client** | Connection | Heartbeat Timer | 10s | Application-level ping when idle |
|
|
| **Client** | Connection | Heartbeat Timeout | 20s | Force reconnect if no response |
|
|
| **Client** | Reconnection | Unavailable Retry Delay | 5s | Backoff when server unavailable |
|
|
| **nginx** | Proxy | connect_timeout | 1200s (20 min) | Connection establishment |
|
|
| **nginx** | Proxy | send_timeout | 1200s (20 min) | Time to send request |
|
|
| **nginx** | Proxy | read_timeout | 1200s (20 min) | Time to read response |
|
|
| **nginx** | gRPC | send_timeout | 1200s (20 min) | gRPC-specific send |
|
|
| **nginx** | gRPC | read_timeout | 1200s (20 min) | gRPC-specific read |
|
|
| **Server** | gRPC | maxConnectionIdle | ∞ | No forced idle timeout |
|
|
| **Server** | gRPC | maxConnectionAge | ∞ | No forced age limit |
|
|
| **Server** | gRPC | keepAliveTime | 2 hours (unused) | Not enforced |
|
|
|
|
**Historical Note:** Client deadline was increased from 55s to 300s in commit 7f5b246d59 (Aug 2024) to "extend the timeout".
|
|
|
|
## Identified Issues
|
|
|
|
### 1. **MYSTERY: Where is the "2 minute timeout"?**
|
|
|
|
**User reports:** "Connection drops every 2min or so" and "I (think I) put it on a timeout slightly under 2min"
|
|
|
|
**Current settings in code:**
|
|
- Client gRPC deadline: **300 seconds (5 minutes)**
|
|
- nginx timeouts: **1200 seconds (20 minutes)**
|
|
- Server timeouts: **infinity (none)**
|
|
|
|
**FINDINGS:**
|
|
- ✅ nginx has very generous 20-minute timeouts - NOT the problem
|
|
- ✅ Server has no forced timeouts - NOT the problem
|
|
- ✅ Client deadline is 5 minutes - NOT 2 minutes
|
|
|
|
**HYPOTHESIS:**
|
|
1. **User may be misremembering** - there is NO 2-minute timeout in the code
|
|
2. **OR: External network infrastructure** - ISP/router/firewall between remote player and nginx may have 2-minute idle timeout
|
|
3. **OR: The "2 minutes" observation is approximate** - actual drops may be at 5 minutes (deadline) or caused by other issues
|
|
|
|
**RECOMMENDATION:** Add detailed logging with precise timestamps to measure actual time between drops.
|
|
|
|
### 2. **🔴 CRITICAL BUG: Shardok Internal Interface Exposed Without Auth!**
|
|
|
|
**nginx configuration (lines 53-57):**
|
|
```nginx
|
|
location /net.eagle0.common.ShardokInternalInterface {
|
|
grpc_pass grpc://192.168.123.100:40042;
|
|
}
|
|
```
|
|
|
|
**PROBLEMS:**
|
|
- ❌ No `auth_basic` directive (Eagle endpoint has auth on line 37-38)
|
|
- ❌ No timeout settings (Eagle endpoint has timeouts on lines 44-49)
|
|
- ❌ No socket keepalive (Eagle endpoint has it on line 49)
|
|
- ❌ Exposes internal Shardok interface directly to internet
|
|
|
|
**IMPACT:**
|
|
- Anyone can bypass Eagle server and call Shardok directly
|
|
- No authentication or authorization checks
|
|
- Contradicts architecture claim that "Shardok server is not exposed directly to the client"
|
|
- Potential security vulnerability
|
|
|
|
**RECOMMENDATION:** Either:
|
|
1. Remove this location block entirely (Shardok should only be called by Eagle server internally)
|
|
2. OR add same auth and timeout settings as Eagle endpoint
|
|
|
|
### 3. **HTTP/2 KeepAlive vs Application Heartbeat Mismatch**
|
|
|
|
**Current settings:**
|
|
- **HTTP/2 KeepAlive:** 45 seconds (YetAnotherHttpHandler)
|
|
- **Application Heartbeat:** 10 seconds (PersistentClientConnection)
|
|
- **Heartbeat Timeout:** 20 seconds (force reconnect)
|
|
|
|
**ISSUES:**
|
|
- HTTP/2 layer sends PING every 45 seconds
|
|
- Application layer sends heartbeat every 10 seconds (if no updates)
|
|
- **Redundant and wasteful** - two separate keepalive mechanisms
|
|
- Application heartbeat fires 4-5 times per HTTP/2 PING
|
|
|
|
**RECOMMENDATION:**
|
|
- Align HTTP/2 keepalive with application heartbeat (both 10s)
|
|
- OR disable application heartbeat and rely on HTTP/2 PING
|
|
- Current setup works but is inefficient
|
|
|
|
### 4. **Shardok-Specific Connection Issues**
|
|
|
|
**User reports:** "Issues only for one player who is not on the local network, and apparently only while Shardok game is going"
|
|
|
|
**Why Shardok is Different:**
|
|
1. **Higher message frequency** - Tactical combat has more frequent action/reaction cycles than strategic Eagle gameplay
|
|
2. **Larger message payloads** - Shardok sends full unit positions, stats, available actions per turn
|
|
3. **Multiple simultaneous games** - Client tracks array of `ShardokViewStatuses[]`, each with own result count
|
|
4. **Bidirectional traffic** - Player commands and AI responses flowing simultaneously
|
|
|
|
**Possible Root Causes:**
|
|
|
|
**A) Network Infrastructure NAT/Firewall Timeout**
|
|
- Remote player likely behind home router/NAT
|
|
- Many consumer routers have 60-120 second idle timeouts for UDP/TCP connections
|
|
- During active Shardok gameplay, connection may appear "idle" at transport layer if:
|
|
- Application data is buffered
|
|
- HTTP/2 multiplexing makes connection look inactive
|
|
- Router doesn't recognize gRPC traffic as "active"
|
|
|
|
**B) HTTP/2 Flow Control Window Exhaustion**
|
|
- Shardok sends bursts of updates during AI turns
|
|
- Client may not consume messages fast enough (UI thread blocking?)
|
|
- HTTP/2 flow control window fills up
|
|
- Server blocks sending, connection appears stalled
|
|
- Eventually timeout or client-side buffer overflow
|
|
|
|
**C) nginx Buffer Issues**
|
|
- nginx may buffer gRPC responses before sending to client
|
|
- Default nginx buffers might be too small for Shardok update bursts
|
|
- Missing `grpc_buffer_size` or `proxy_buffer_size` configuration
|
|
- Buffering could add latency that appears as "connection issues"
|
|
|
|
**D) Remote Player Packet Loss/Latency**
|
|
- High latency + packet loss = TCP retransmission delays
|
|
- gRPC is sensitive to head-of-line blocking in HTTP/2
|
|
- One lost packet can stall entire stream
|
|
- Appears as timeout or "dropped connection"
|
|
|
|
**E) Client-Side Processing Bottleneck**
|
|
- Shardok updates processed on Unity main thread
|
|
- If UI freezes during update processing, background thread can't read from stream
|
|
- Server-side buffers fill up
|
|
- Connection appears stalled
|
|
|
|
### 5. **Delta Update State Synchronization Risk**
|
|
|
|
**For Shardok**, client must track multiple game states:
|
|
```csharp
|
|
ShardokViewStatus {
|
|
string shardok_game_id
|
|
int32 filtered_result_count // Last action index seen
|
|
}
|
|
```
|
|
|
|
**Problem:** If connection drops mid-update:
|
|
1. Client may have partially processed updates
|
|
2. `filtered_result_count` may not match server's expectation
|
|
3. Reconnection sends stale count
|
|
4. Server either:
|
|
- Sends duplicate updates (client sees repeated actions)
|
|
- Skips updates (client misses actions)
|
|
|
|
**State recovery mechanism (partial):**
|
|
- Client can request resync by setting `filteredResultCount = 0` in `ShardokViewStatus`
|
|
- Server will send all results from the beginning when `filteredResultCount = 0`
|
|
- ⚠️ **NOTE:** The `request_full_resync` field exists in the proto (`eagle.proto:88`) but is **NOT read by the server**. It's effectively dead code. The resync works via `filteredResultCount = 0` instead.
|
|
- No checksum/hash to verify client and server are in sync
|
|
|
|
**TODO:** Either implement server-side handling of `request_full_resync`, or remove the field from the proto.
|
|
|
|
**Impact:** After connection drop during Shardok, client can recover via resync, but relies on `filteredResultCount = 0` workaround.
|
|
|
|
## Recommendations
|
|
|
|
### Critical Security Issue
|
|
|
|
**🔴 PRIORITY 1: Fix Shardok Internal Interface Exposure**
|
|
|
|
Update nginx config `/Users/dancrosby/CodingProjects/github/eagle0/eagle0.net`:
|
|
|
|
```nginx
|
|
location /net.eagle0.common.ShardokInternalInterface {
|
|
# This endpoint should NOT be publicly accessible
|
|
# Option 1: Remove this block entirely (recommended)
|
|
# Option 2: Add authentication and timeouts like Eagle endpoint:
|
|
auth_basic "User Area";
|
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
|
grpc_pass grpc://192.168.123.100:40042;
|
|
grpc_send_timeout 1200s;
|
|
grpc_read_timeout 1200s;
|
|
grpc_socket_keepalive on;
|
|
}
|
|
```
|
|
|
|
**Recommended:** Remove the location block entirely. Shardok should only be accessible to Eagle server on internal network.
|
|
|
|
### Immediate Diagnostic Actions
|
|
|
|
**1. Add Precise Connection Drop Logging**
|
|
|
|
Modify `PersistentClientConnection.cs` to log:
|
|
- Exact timestamp of every connection attempt
|
|
- Exact timestamp of every disconnection
|
|
- StatusCode of every RpcException
|
|
- Time delta between last successful message and disconnect
|
|
- Whether disconnect happened during Shardok gameplay
|
|
|
|
**Example log format:**
|
|
```
|
|
[2024-11-30 03:27:31.026] DISCONNECT after 127.3s idle, StatusCode=Cancelled, InShardok=true
|
|
```
|
|
|
|
This will answer:
|
|
- Is it ACTUALLY ~2 minutes, or something else?
|
|
- What is the exact error code?
|
|
- Does it only happen during Shardok?
|
|
|
|
**2. Add Remote Player Network Diagnostics**
|
|
|
|
For the affected remote player, collect:
|
|
- Ping time to eagle0.net (measure TCP latency, not ICMP)
|
|
- Traceroute to eagle0.net (identify network hops)
|
|
- MTU discovery (check for fragmentation issues)
|
|
- Connection stability test (long-running TCP connection)
|
|
|
|
**Tools:**
|
|
```bash
|
|
# On remote player's machine
|
|
mtr eagle0.net # Continuous ping + traceroute
|
|
curl -w "@curl-format.txt" -o /dev/null -s https://eagle0.net/
|
|
```
|
|
|
|
**3. Enable nginx Logging for gRPC Endpoint**
|
|
|
|
Add to nginx config:
|
|
```nginx
|
|
location /net.eagle0.eagle.api.Eagle {
|
|
# ... existing config ...
|
|
access_log /var/log/nginx/eagle-grpc-access.log;
|
|
error_log /var/log/nginx/eagle-grpc-error.log debug;
|
|
}
|
|
```
|
|
|
|
Look for patterns in nginx logs when remote player disconnects.
|
|
|
|
### Targeted Fixes for Shardok Issues
|
|
|
|
**If Root Cause is NAT/Firewall Timeout:**
|
|
|
|
**Solution A: Reduce HTTP/2 KeepAlive to be more aggressive**
|
|
|
|
In `EagleConnection.cs`, change:
|
|
```csharp
|
|
private const double KeepAliveSeconds = 15.0; // Was 45.0
|
|
```
|
|
|
|
Sending HTTP/2 PING every 15 seconds should keep NAT sessions alive.
|
|
|
|
**Solution B: Enable TCP keepalive at socket level**
|
|
|
|
Add to nginx config:
|
|
```nginx
|
|
location /net.eagle0.eagle.api.Eagle {
|
|
# ... existing config ...
|
|
proxy_socket_keepalive on; # Add this
|
|
}
|
|
```
|
|
|
|
**If Root Cause is HTTP/2 Flow Control:**
|
|
|
|
**Solution: Increase nginx HTTP/2 buffer sizes**
|
|
|
|
Add to nginx `http` block:
|
|
```nginx
|
|
http {
|
|
http2_max_field_size 16k;
|
|
http2_max_header_size 32k;
|
|
client_body_buffer_size 128k;
|
|
client_max_body_size 10m;
|
|
}
|
|
```
|
|
|
|
**If Root Cause is nginx Buffering:**
|
|
|
|
**Solution: Disable nginx buffering for gRPC**
|
|
|
|
Add to nginx gRPC location:
|
|
```nginx
|
|
location /net.eagle0.eagle.api.Eagle {
|
|
# ... existing config ...
|
|
grpc_buffering off; # Add this - disable response buffering
|
|
}
|
|
```
|
|
|
|
**If Root Cause is Client-Side Processing Delay:**
|
|
|
|
**Solution: Move Shardok update processing off Unity main thread**
|
|
|
|
Profile Unity to check if:
|
|
- `HandleStreamingCall()` thread is blocked waiting to write to queue
|
|
- Main thread is slow processing Shardok updates
|
|
- GC pauses are causing delays
|
|
|
|
### General Connection Robustness Improvements
|
|
|
|
**1. Add State Resync Mechanism**
|
|
|
|
When reconnecting after drop during Shardok:
|
|
- Send `StreamGameRequest` with `filtered_result_count = 0`
|
|
- Server sends full current state instead of delta
|
|
- Client discards partial state and resyncs
|
|
|
|
**2. Align Heartbeat Timings**
|
|
|
|
Current setup is inefficient with two layers:
|
|
- HTTP/2 keepalive: 45s
|
|
- Application heartbeat: 10s
|
|
|
|
**Option A: Single-layer approach**
|
|
```csharp
|
|
// In EagleConnection.cs
|
|
private const double KeepAliveSeconds = 10.0; // Align with application heartbeat
|
|
|
|
// In PersistentClientConnection.cs
|
|
// Remove application-level heartbeat entirely, rely on HTTP/2
|
|
```
|
|
|
|
**Option B: Keep both but optimize**
|
|
```csharp
|
|
// In EagleConnection.cs
|
|
private const double KeepAliveSeconds = 30.0; // Longer HTTP/2 interval
|
|
|
|
// In PersistentClientConnection.cs
|
|
private const double HeartbeatTimerSeconds = 30.0; // Match HTTP/2
|
|
```
|
|
|
|
**3. Implement Exponential Backoff for Reconnections**
|
|
|
|
Currently, client reconnects immediately on error. For transient network issues:
|
|
```csharp
|
|
private int _reconnectAttempts = 0;
|
|
private const int MaxReconnectDelay = 30; // seconds
|
|
|
|
public async Task Connect() {
|
|
if (_reconnectAttempts > 0) {
|
|
int delay = Math.Min(Math.Pow(2, _reconnectAttempts), MaxReconnectDelay);
|
|
await Task.Delay(delay * 1000);
|
|
}
|
|
// ... existing connect logic ...
|
|
_reconnectAttempts++; // Increment on failure
|
|
}
|
|
|
|
// Reset on successful connection:
|
|
// _reconnectAttempts = 0;
|
|
```
|
|
|
|
## Summary of Key Findings
|
|
|
|
### ✅ What's Working
|
|
|
|
1. **nginx Configuration:** Very generous 20-minute timeouts, properly configured for gRPC
|
|
2. **Client Reconnection Logic:** Now fixed with recent PRs (#4596, #4597) to handle heartbeat timeout and race conditions
|
|
3. **Server Configuration:** No forced timeouts that would cause premature disconnections
|
|
4. **TLS/HTTPS:** Properly configured with Let's Encrypt, HTTP/2 enabled
|
|
|
|
### 🔴 Critical Issues Found
|
|
|
|
1. **Shardok Internal Interface Exposed Without Auth** (Security vulnerability)
|
|
2. **Mystery "2-minute timeout"** - No 120-second timeout found in code
|
|
3. **No state resync mechanism** after connection drops during Shardok
|
|
4. **Inefficient dual-layer heartbeat** (HTTP/2 + application level)
|
|
|
|
### 🟡 Hypotheses for Remote Player Disconnects
|
|
|
|
**Most Likely:**
|
|
1. **NAT/Firewall timeout** at remote player's router/ISP (60-120s idle timeout)
|
|
2. **Network packet loss/latency** causing HTTP/2 head-of-line blocking
|
|
3. **nginx buffering** adding latency during Shardok update bursts
|
|
|
|
**Less Likely:**
|
|
4. HTTP/2 flow control window exhaustion
|
|
5. Client-side processing bottleneck
|
|
|
|
**Needs Measurement:**
|
|
- Exact time between connect and disconnect
|
|
- Correlation with Shardok gameplay phases (player turn vs AI turn)
|
|
- Remote player's network characteristics
|
|
|
|
## Next Steps (Prioritized)
|
|
|
|
1. **🔴 SECURITY:** Fix Shardok internal interface exposure in nginx config
|
|
2. **📊 DIAGNOSTICS:** Add precise connection drop logging with timestamps
|
|
3. **🔍 INVESTIGATE:** Get network diagnostics from affected remote player
|
|
4. **🐛 FIX:** Implement most likely solution (reduce HTTP/2 keepalive to 15s)
|
|
5. **🔄 IMPROVE:** Add state resync mechanism for Shardok
|
|
6. **⚡ OPTIMIZE:** Align heartbeat timings to reduce redundancy
|
|
|
|
## Files Examined
|
|
|
|
- ✅ `src/main/protobuf/net/eagle0/eagle/api/eagle.proto` - gRPC service definition
|
|
- ✅ `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/EagleConnection.cs` - Channel setup
|
|
- ✅ `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/PersistentClientConnection.cs` - Connection management
|
|
- ✅ `src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala` - Server-side handler
|
|
- ✅ `src/main/scala/net/eagle0/eagle/Main.scala` - Server startup configuration
|
|
- ✅ `/Users/dancrosby/CodingProjects/github/eagle0/eagle0.net` - nginx configuration
|
|
|
|
## Files Not Yet Examined
|
|
|
|
- YetAnotherHttpHandler source code (client HTTP/2 implementation)
|
|
- Shardok ↔ Eagle internal gRPC interface implementation
|
|
- GamesManager/CustomBattleManager Shardok integration
|
|
- Client-side Shardok update processing (Unity main thread impact)
|
|
- Server-side Shardok result caching/delta calculation
|
|
|
|
---
|
|
|
|
**Document Created:** 2024-11-30
|
|
**Last Updated:** 2024-11-30
|
|
**Author:** Claude Code (automated audit)
|
|
**Status:** Includes comprehensive resilience implementation plan
|
|
**Critical Finding:** Shardok internal interface security vulnerability
|
|
**Recommended First Action:** Add precise connection drop logging to measure actual timeout duration
|
|
|
|
---
|
|
|
|
# Connection Resilience Implementation Plan
|
|
|
|
This section outlines a comprehensive, prioritized plan to make the Eagle0 connection system more resilient, stable, and observable.
|
|
|
|
## Design Principles
|
|
|
|
1. **Fail Fast, Recover Fast** - Detect failures quickly and reconnect automatically
|
|
2. **Observability First** - Cannot fix what cannot measure
|
|
3. **Graceful Degradation** - System should remain partially functional during issues
|
|
4. **State Consistency** - Game state must be correct or obviously broken (no silent corruption)
|
|
5. **Security by Default** - All endpoints authenticated and rate-limited
|
|
6. **Progressive Enhancement** - Implement improvements incrementally without breaking existing functionality
|
|
|
|
## Priority 1: Critical Fixes & Diagnostics (Week 1)
|
|
|
|
### 1.1 Fix Shardok Security Vulnerability
|
|
|
|
**Problem:** Shardok internal interface exposed to internet without authentication
|
|
|
|
**Impact:** CRITICAL - Anyone can bypass Eagle server and manipulate Shardok games
|
|
|
|
**Implementation:**
|
|
|
|
**Option A: Remove Public Access (RECOMMENDED)**
|
|
```nginx
|
|
# In eagle0.net, REMOVE this entire location block:
|
|
# location /net.eagle0.common.ShardokInternalInterface {
|
|
# grpc_pass grpc://192.168.123.100:40042;
|
|
# }
|
|
```
|
|
|
|
Shardok should only be accessible on internal network (192.168.123.x) from Eagle server.
|
|
|
|
**Option B: Add Authentication & Timeouts (if public access needed)**
|
|
```nginx
|
|
location /net.eagle0.common.ShardokInternalInterface {
|
|
auth_basic "Shardok Internal - Authorized Use Only";
|
|
auth_basic_user_file /etc/nginx/.htpasswd_shardok; # Separate from Eagle
|
|
|
|
# Add same timeout protections as Eagle
|
|
grpc_pass grpc://192.168.123.100:40042;
|
|
grpc_send_timeout 1200s;
|
|
grpc_read_timeout 1200s;
|
|
grpc_socket_keepalive on;
|
|
|
|
# Rate limiting
|
|
limit_req zone=shardok_internal burst=10 nodelay;
|
|
|
|
# IP whitelist (optional)
|
|
allow 192.168.123.0/24; # Internal network
|
|
deny all;
|
|
}
|
|
|
|
# In http block, add rate limit zone:
|
|
limit_req_zone $binary_remote_addr zone=shardok_internal:10m rate=100r/s;
|
|
```
|
|
|
|
**Testing:**
|
|
- Verify Shardok calls fail without auth
|
|
- Verify Eagle server can still call Shardok internally
|
|
- Load test to ensure rate limits work
|
|
|
|
**Rollback Plan:** Keep old config file backup, can revert nginx config instantly
|
|
|
|
**Metrics:** Monitor nginx 401/403 errors on Shardok endpoint
|
|
|
|
---
|
|
|
|
### 1.2 Add Comprehensive Connection Logging
|
|
|
|
**Problem:** Cannot diagnose connection issues without precise timing data
|
|
|
|
**Implementation:**
|
|
|
|
**File:** `Assets/Eagle/PersistentClientConnection.cs`
|
|
|
|
Add connection metrics tracking:
|
|
|
|
```csharp
|
|
// New class for connection metrics
|
|
public class ConnectionMetrics {
|
|
public DateTime? LastConnectAttempt { get; set; }
|
|
public DateTime? LastSuccessfulConnect { get; set; }
|
|
public DateTime? LastDisconnect { get; set; }
|
|
public DateTime? LastMessageReceived { get; set; }
|
|
public DateTime? LastMessageSent { get; set; }
|
|
|
|
public int TotalConnectAttempts { get; set; }
|
|
public int TotalDisconnects { get; set; }
|
|
public int TotalReconnects { get; set; }
|
|
|
|
public StatusCode? LastDisconnectReason { get; set; }
|
|
public bool IsInShardokGame { get; set; }
|
|
public int ActiveShardokGamesCount { get; set; }
|
|
|
|
public double? LastConnectionDurationSeconds =>
|
|
(LastDisconnect.HasValue && LastSuccessfulConnect.HasValue)
|
|
? (LastDisconnect.Value - LastSuccessfulConnect.Value).TotalSeconds
|
|
: null;
|
|
|
|
public double? SecondsSinceLastMessage =>
|
|
LastMessageReceived.HasValue
|
|
? (DateTime.UtcNow - LastMessageReceived.Value).TotalSeconds
|
|
: null;
|
|
}
|
|
|
|
private ConnectionMetrics _metrics = new ConnectionMetrics();
|
|
```
|
|
|
|
Enhanced logging in key methods:
|
|
|
|
```csharp
|
|
public async Task Connect() {
|
|
_metrics.LastConnectAttempt = DateTime.UtcNow;
|
|
_metrics.TotalConnectAttempts++;
|
|
|
|
if (_isConnecting) {
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[CONNECT] Skipped - already connecting. Attempts={_metrics.TotalConnectAttempts}");
|
|
return;
|
|
}
|
|
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[CONNECT] Starting connection attempt #{_metrics.TotalConnectAttempts}. " +
|
|
$"Last disconnect: {FormatTimeDelta(_metrics.LastDisconnect)} ago, " +
|
|
$"Reason: {_metrics.LastDisconnectReason}");
|
|
|
|
// ... existing connect logic ...
|
|
|
|
_metrics.LastSuccessfulConnect = DateTime.UtcNow;
|
|
var connectDuration = (DateTime.UtcNow - _metrics.LastConnectAttempt.Value).TotalSeconds;
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[CONNECT] SUCCESS in {connectDuration:F2}s. Stream started.");
|
|
}
|
|
|
|
// In HandleStreamingCall response handling:
|
|
private async Task HandleStreamingCall() {
|
|
// ... existing setup ...
|
|
|
|
try {
|
|
while (await sc.ResponseStream.MoveNext(_currentThreadToken)) {
|
|
_metrics.LastMessageReceived = DateTime.UtcNow;
|
|
|
|
var response = sc.ResponseStream.Current;
|
|
|
|
_timingsLogger.LogLine(
|
|
$"[MESSAGE] Received {response.ResponseDetailsCase}, " +
|
|
$"ActiveShardok={_metrics.ActiveShardokGamesCount}, " +
|
|
$"Idle={_metrics.SecondsSinceLastMessage:F1}s");
|
|
|
|
// ... existing message handling ...
|
|
}
|
|
} catch (RpcException e) {
|
|
_metrics.LastDisconnect = DateTime.UtcNow;
|
|
_metrics.TotalDisconnects++;
|
|
_metrics.LastDisconnectReason = e.StatusCode;
|
|
|
|
var connectionDuration = _metrics.LastConnectionDurationSeconds ?? 0;
|
|
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[DISCONNECT] StatusCode={e.StatusCode}, " +
|
|
$"Duration={connectionDuration:F1}s, " +
|
|
$"InShardok={_metrics.IsInShardokGame}, " +
|
|
$"ShardokGames={_metrics.ActiveShardokGamesCount}, " +
|
|
$"IdleTime={_metrics.SecondsSinceLastMessage:F1}s, " +
|
|
$"TotalDisconnects={_metrics.TotalDisconnects}");
|
|
|
|
// ... existing error handling ...
|
|
}
|
|
}
|
|
|
|
// Helper method
|
|
private string FormatTimeDelta(DateTime? time) {
|
|
if (!time.HasValue) return "never";
|
|
var delta = (DateTime.UtcNow - time.Value).TotalSeconds;
|
|
if (delta < 60) return $"{delta:F1}s";
|
|
if (delta < 3600) return $"{delta/60:F1}m";
|
|
return $"{delta/3600:F1}h";
|
|
}
|
|
|
|
// Update when entering/leaving Shardok
|
|
private void UpdateShardokStatus(IClientConnectionSubscriber subscriber) {
|
|
_metrics.ActiveShardokGamesCount = subscriber.ShardokViewStatuses.Count;
|
|
_metrics.IsInShardokGame = _metrics.ActiveShardokGamesCount > 0;
|
|
}
|
|
```
|
|
|
|
**Log Analysis Script:**
|
|
|
|
Create `scripts/analyze_connection_logs.py`:
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
import re
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
|
|
# Parse connection logs and generate statistics
|
|
def analyze_logs(log_file):
|
|
disconnects = []
|
|
|
|
with open(log_file) as f:
|
|
for line in f:
|
|
if '[DISCONNECT]' in line:
|
|
# Parse: Duration=X.Xs, InShardok=true/false, IdleTime=X.Xs
|
|
match = re.search(r'Duration=([\d.]+)s.*InShardok=(\w+).*IdleTime=([\d.]+)s', line)
|
|
if match:
|
|
disconnects.append({
|
|
'duration': float(match.group(1)),
|
|
'in_shardok': match.group(2) == 'true',
|
|
'idle_time': float(match.group(3))
|
|
})
|
|
|
|
# Statistics
|
|
print(f"Total disconnects: {len(disconnects)}")
|
|
|
|
durations = [d['duration'] for d in disconnects]
|
|
if durations:
|
|
print(f"Connection duration - Avg: {sum(durations)/len(durations):.1f}s, " +
|
|
f"Min: {min(durations):.1f}s, Max: {max(durations):.1f}s")
|
|
|
|
shardok_disconnects = [d for d in disconnects if d['in_shardok']]
|
|
print(f"Shardok disconnects: {len(shardok_disconnects)} " +
|
|
f"({100*len(shardok_disconnects)/len(disconnects):.1f}%)")
|
|
|
|
idle_at_disconnect = [d['idle_time'] for d in disconnects]
|
|
if idle_at_disconnect:
|
|
print(f"Idle time at disconnect - Avg: {sum(idle_at_disconnect)/len(idle_at_disconnect):.1f}s")
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
analyze_logs(sys.argv[1] if len(sys.argv) > 1 else 'connection.log')
|
|
```
|
|
|
|
**Testing:**
|
|
- Run game for 1 hour, force several disconnects
|
|
- Verify logs contain all expected fields
|
|
- Run analysis script to validate metrics
|
|
|
|
**Success Metrics:**
|
|
- 100% of disconnects logged with duration and reason
|
|
- Can identify "2-minute pattern" if it exists
|
|
- Can correlate disconnects with Shardok gameplay
|
|
|
|
---
|
|
|
|
### 1.3 Aggressive HTTP/2 Keepalive for NAT Traversal
|
|
|
|
**Problem:** HTTP/2 keepalive (45s) may not prevent NAT/firewall timeouts (60-120s)
|
|
|
|
**Solution:** Reduce keepalive to 15s to ensure consistent NAT session refresh
|
|
|
|
**File:** `Assets/EagleConnection.cs`
|
|
|
|
```csharp
|
|
private const double KeepAliveSeconds = 15.0; // Was 45.0
|
|
|
|
// Reason: Many consumer routers have 60-120s idle timeout for NAT sessions.
|
|
// 15-second HTTP/2 PING ensures NAT sees connection as "active"
|
|
```
|
|
|
|
**Tradeoff Analysis:**
|
|
- ✅ **Benefit:** Keeps NAT sessions alive, prevents premature disconnects
|
|
- ✅ **Benefit:** Faster detection of broken connections
|
|
- ⚠️ **Cost:** Slightly more network traffic (~8 bytes every 15s = 0.5 KB/min)
|
|
- ⚠️ **Cost:** Slightly more CPU for PING/PONG processing (negligible)
|
|
|
|
**nginx Configuration Enhancement:**
|
|
|
|
Add to Eagle endpoint in `eagle0.net`:
|
|
|
|
```nginx
|
|
location /net.eagle0.eagle.api.Eagle {
|
|
# ... existing config ...
|
|
|
|
# Ensure nginx doesn't buffer HTTP/2 frames
|
|
proxy_buffering off;
|
|
proxy_request_buffering off;
|
|
|
|
# HTTP/2-specific settings
|
|
http2_push_preload on; # Allow server push if needed
|
|
}
|
|
```
|
|
|
|
**Testing:**
|
|
- Remote player plays for 30+ minutes with no input during Shardok
|
|
- Monitor connection logs for disconnects during idle periods
|
|
- Verify HTTP/2 PING frames occur every 15s (Wireshark capture)
|
|
|
|
**Success Metrics:**
|
|
- Connection stays alive for 10+ minutes during Shardok idle periods
|
|
- No disconnects with <60s connection duration
|
|
- Remote player reports no connection issues
|
|
|
|
---
|
|
|
|
## Priority 2: State Consistency & Recovery (Week 2)
|
|
|
|
### 2.1 Implement State Resync Mechanism for Shardok
|
|
|
|
**Problem:** After connection drop during Shardok, client may have inconsistent state due to delta updates
|
|
|
|
**Current Behavior:**
|
|
- Client sends `filtered_result_count` for each Shardok game
|
|
- Server sends only actions since that count
|
|
- If connection drops mid-update, counts can desync
|
|
|
|
**Solution:** Add resync request mechanism
|
|
|
|
**Protocol Extension:**
|
|
|
|
Add to `eagle.proto`:
|
|
|
|
```protobuf
|
|
message StreamGameRequest {
|
|
int64 game_id = 1;
|
|
int32 unfiltered_result_count = 2;
|
|
|
|
message ShardokViewStatus {
|
|
string shardok_game_id = 1;
|
|
int32 filtered_result_count = 2;
|
|
bool request_full_resync = 3; // NEW: Request full state instead of delta
|
|
}
|
|
repeated ShardokViewStatus shardok_view_statuses = 3;
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Client Implementation:**
|
|
|
|
File: `Assets/Eagle/EagleGameModel.cs`
|
|
|
|
```csharp
|
|
// Track if we need resync after reconnection
|
|
private Dictionary<string, bool> _shardokNeedsResync = new Dictionary<string, bool>();
|
|
|
|
// After connection drop:
|
|
public void MarkShardokForResync(string shardokGameId) {
|
|
_shardokNeedsResync[shardokGameId] = true;
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[RESYNC] Marked Shardok game {shardokGameId} for full resync");
|
|
}
|
|
|
|
// When building StreamGameRequest:
|
|
private StreamGameRequest BuildStreamRequest() {
|
|
var request = new StreamGameRequest {
|
|
GameId = this.GameId,
|
|
UnfilteredResultCount = _lastUnfilteredResultCount
|
|
};
|
|
|
|
foreach (var (shardokId, model) in _currentModel.ShardokGameModels) {
|
|
var needsResync = _shardokNeedsResync.GetValueOrDefault(shardokId, false);
|
|
|
|
request.ShardokViewStatuses.Add(new StreamGameRequest.Types.ShardokViewStatus {
|
|
ShardokGameId = shardokId,
|
|
FilteredResultCount = needsResync ? 0 : model.LastResultCount,
|
|
RequestFullResync = needsResync
|
|
});
|
|
|
|
if (needsResync) {
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[RESYNC] Requesting full state for Shardok {shardokId}");
|
|
}
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
// After successful resync:
|
|
public void OnShardokResyncComplete(string shardokGameId) {
|
|
_shardokNeedsResync[shardokGameId] = false;
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[RESYNC] Completed for Shardok game {shardokGameId}");
|
|
}
|
|
```
|
|
|
|
**File:** `Assets/Eagle/PersistentClientConnection.cs`
|
|
|
|
Mark all active Shardok games for resync on disconnect:
|
|
|
|
```csharp
|
|
catch (RpcException e) {
|
|
_metrics.LastDisconnect = DateTime.UtcNow;
|
|
_metrics.TotalDisconnects++;
|
|
|
|
// Mark all active Shardok games for resync
|
|
lock (this) {
|
|
foreach (var (gameId, subscriber) in _subscribers) {
|
|
foreach (var shardokStatus in subscriber.ShardokViewStatuses) {
|
|
if (subscriber is EagleGameModel model) {
|
|
model.MarkShardokForResync(shardokStatus.shardokGameId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ... existing error handling ...
|
|
}
|
|
```
|
|
|
|
**Server Implementation:**
|
|
|
|
File: `src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
|
|
|
```scala
|
|
private def streamOneUpdate(
|
|
request: StreamGameRequest,
|
|
responseObserver: SyncResponseObserver
|
|
): Unit = {
|
|
lockAndDoWithUserName { userName =>
|
|
request.shardokViewStatuses.foreach { status =>
|
|
if (status.requestFullResync) {
|
|
logger.info(s"Resync requested for Shardok game ${status.shardokGameId} by $userName")
|
|
// Send full state instead of delta by passing filteredResultCount = 0
|
|
// Server logic already handles this case
|
|
}
|
|
}
|
|
|
|
// ... existing stream logic ...
|
|
}
|
|
}
|
|
```
|
|
|
|
**Testing:**
|
|
- Force disconnect during Shardok AI turn (mid-update)
|
|
- Verify reconnection requests resync
|
|
- Verify client state matches server state after resync
|
|
- Test with multiple simultaneous Shardok games
|
|
|
|
**Success Metrics:**
|
|
- Zero state desyncs after reconnection
|
|
- Resync completes in <2 seconds
|
|
- No duplicate actions displayed after resync
|
|
|
|
---
|
|
|
|
### 2.2 Exponential Backoff for Reconnections
|
|
|
|
**Problem:** Immediate reconnection on failure can overwhelm server or waste resources on persistent failures
|
|
|
|
**Solution:** Implement exponential backoff with jitter
|
|
|
|
**File:** `Assets/Eagle/PersistentClientConnection.cs`
|
|
|
|
```csharp
|
|
private int _consecutiveFailures = 0;
|
|
private const int MaxBackoffSeconds = 30;
|
|
private const double BackoffMultiplier = 2.0;
|
|
private const double JitterFactor = 0.2; // ±20% randomization
|
|
private Random _backoffRandom = new Random();
|
|
|
|
public async Task Connect() {
|
|
// Apply backoff delay if we have consecutive failures
|
|
if (_consecutiveFailures > 0) {
|
|
var baseDelay = Math.Min(
|
|
Math.Pow(BackoffMultiplier, _consecutiveFailures - 1),
|
|
MaxBackoffSeconds
|
|
);
|
|
|
|
// Add jitter: delay ± 20%
|
|
var jitter = baseDelay * JitterFactor * (2 * _backoffRandom.NextDouble() - 1);
|
|
var delaySeconds = baseDelay + jitter;
|
|
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[BACKOFF] Waiting {delaySeconds:F1}s before reconnect " +
|
|
$"(failure #{_consecutiveFailures})");
|
|
|
|
await Task.Delay((int)(delaySeconds * 1000));
|
|
}
|
|
|
|
if (_isConnecting) {
|
|
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
|
|
return;
|
|
}
|
|
|
|
_isConnecting = true;
|
|
try {
|
|
_remoteEagleClientLogger.LogLine($"Connect() called");
|
|
|
|
// ... existing connection logic ...
|
|
|
|
// Reset consecutive failures on successful connection
|
|
var previousFailures = _consecutiveFailures;
|
|
_consecutiveFailures = 0;
|
|
|
|
if (previousFailures > 0) {
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[BACKOFF] Reset after {previousFailures} consecutive failures");
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
_consecutiveFailures++;
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[BACKOFF] Connection failed, consecutive failures: {_consecutiveFailures}");
|
|
throw;
|
|
} finally {
|
|
_isConnecting = false;
|
|
}
|
|
}
|
|
|
|
// Also increment on RpcException:
|
|
catch (RpcException e) {
|
|
_consecutiveFailures++;
|
|
// ... existing handling ...
|
|
}
|
|
```
|
|
|
|
**Backoff Schedule:**
|
|
|
|
| Failure # | Base Delay | With Jitter Range |
|
|
|-----------|------------|-------------------|
|
|
| 1 | 1s | 0.8s - 1.2s |
|
|
| 2 | 2s | 1.6s - 2.4s |
|
|
| 3 | 4s | 3.2s - 4.8s |
|
|
| 4 | 8s | 6.4s - 9.6s |
|
|
| 5 | 16s | 12.8s - 19.2s |
|
|
| 6+ | 30s (max) | 24s - 36s |
|
|
|
|
**Testing:**
|
|
- Kill server during active gameplay
|
|
- Verify backoff delays match expected schedule
|
|
- Verify reconnection succeeds after server restart
|
|
- Test with rapid on/off server toggling
|
|
|
|
**Success Metrics:**
|
|
- Server load doesn't spike during outages
|
|
- Connection succeeds within 1 minute of server recovery
|
|
- Backoff resets properly after successful connection
|
|
|
|
---
|
|
|
|
### 2.3 Connection Health Monitoring & UI Indicators
|
|
|
|
**Problem:** Users don't know when connection is degraded until failure
|
|
|
|
**Solution:** Monitor connection health and display indicators
|
|
|
|
**File:** `Assets/Eagle/ConnectionHealthMonitor.cs` (new)
|
|
|
|
```csharp
|
|
public class ConnectionHealthMonitor {
|
|
public enum HealthStatus {
|
|
Excellent, // <100ms latency, no packet loss
|
|
Good, // 100-300ms latency, <1% loss
|
|
Fair, // 300-500ms latency, <5% loss
|
|
Poor, // >500ms latency or >5% loss
|
|
Disconnected
|
|
}
|
|
|
|
private Queue<long> _heartbeatLatencies = new Queue<long>();
|
|
private const int LatencyWindowSize = 10;
|
|
|
|
private int _heartbeatsSent = 0;
|
|
private int _heartbeatsReceived = 0;
|
|
|
|
public HealthStatus CurrentStatus { get; private set; } = HealthStatus.Disconnected;
|
|
public double AverageLatencyMs { get; private set; }
|
|
public double PacketLossPercent { get; private set; }
|
|
|
|
public void RecordHeartbeatSent(long clientTimestamp) {
|
|
_heartbeatsSent++;
|
|
}
|
|
|
|
public void RecordHeartbeatReceived(long clientTimestamp, long serverTimestamp) {
|
|
_heartbeatsReceived++;
|
|
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var latency = now - clientTimestamp; // Round-trip time
|
|
|
|
_heartbeatLatencies.Enqueue(latency);
|
|
if (_heartbeatLatencies.Count > LatencyWindowSize) {
|
|
_heartbeatLatencies.Dequeue();
|
|
}
|
|
|
|
UpdateHealthStatus();
|
|
}
|
|
|
|
public void RecordDisconnect() {
|
|
CurrentStatus = HealthStatus.Disconnected;
|
|
_heartbeatsSent = 0;
|
|
_heartbeatsReceived = 0;
|
|
}
|
|
|
|
private void UpdateHealthStatus() {
|
|
// Calculate metrics
|
|
AverageLatencyMs = _heartbeatLatencies.Count > 0
|
|
? _heartbeatLatencies.Average()
|
|
: 0;
|
|
|
|
PacketLossPercent = _heartbeatsSent > 0
|
|
? 100.0 * (1.0 - (double)_heartbeatsReceived / _heartbeatsSent)
|
|
: 0;
|
|
|
|
// Determine status
|
|
if (PacketLossPercent > 5) {
|
|
CurrentStatus = HealthStatus.Poor;
|
|
} else if (AverageLatencyMs > 500) {
|
|
CurrentStatus = HealthStatus.Poor;
|
|
} else if (AverageLatencyMs > 300) {
|
|
CurrentStatus = HealthStatus.Fair;
|
|
} else if (AverageLatencyMs > 100) {
|
|
CurrentStatus = HealthStatus.Good;
|
|
} else {
|
|
CurrentStatus = HealthStatus.Excellent;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**UI Integration:**
|
|
|
|
Add connection indicator to `EagleGameController`:
|
|
|
|
```csharp
|
|
public TextMeshProUGUI connectionStatusText;
|
|
public Image connectionStatusIcon;
|
|
|
|
void Update() {
|
|
// ... existing update logic ...
|
|
|
|
// Update connection status display
|
|
var health = ConnectionHealthMonitor.Instance.CurrentStatus;
|
|
connectionStatusText.text = $"{health} ({ConnectionHealthMonitor.Instance.AverageLatencyMs:F0}ms)";
|
|
|
|
connectionStatusIcon.color = health switch {
|
|
ConnectionHealthMonitor.HealthStatus.Excellent => Color.green,
|
|
ConnectionHealthMonitor.HealthStatus.Good => Color.yellow,
|
|
ConnectionHealthMonitor.HealthStatus.Fair => new Color(1f, 0.5f, 0f), // Orange
|
|
ConnectionHealthMonitor.HealthStatus.Poor => Color.red,
|
|
ConnectionHealthMonitor.HealthStatus.Disconnected => Color.gray,
|
|
_ => Color.white
|
|
};
|
|
}
|
|
```
|
|
|
|
**Testing:**
|
|
- Simulate network latency using tc (Linux) or Charles Proxy
|
|
- Verify status changes from Excellent → Good → Fair → Poor as latency increases
|
|
- Verify packet loss detection
|
|
|
|
**Success Metrics:**
|
|
- Status accurately reflects connection quality
|
|
- Users report issues before complete disconnection
|
|
- Helps identify remote player's network problems
|
|
|
|
---
|
|
|
|
## Priority 3: Architectural Improvements (Week 3-4)
|
|
|
|
### 3.1 Consolidate Heartbeat Mechanisms
|
|
|
|
**Problem:** Dual-layer heartbeat (HTTP/2 + application) is redundant and inefficient
|
|
|
|
**Current State:**
|
|
- HTTP/2 keepalive: 15s (after Priority 1.3)
|
|
- Application heartbeat: 10s
|
|
- Both serve same purpose: detect broken connections
|
|
|
|
**Solution:** Remove application-level heartbeat, rely on HTTP/2 PING
|
|
|
|
**Rationale:**
|
|
- HTTP/2 PING is lower-level, more reliable for detecting transport failures
|
|
- gRPC already handles PING/PONG automatically
|
|
- Reduces code complexity and message overhead
|
|
|
|
**File:** `Assets/Eagle/PersistentClientConnection.cs`
|
|
|
|
```csharp
|
|
// REMOVE application-level heartbeat timer logic:
|
|
// - Remove HeartbeatTimerSeconds constant
|
|
// - Remove SetUpTimer() calls
|
|
// - Remove SendHeartbeatRequest() method
|
|
// - Remove TimerFired() method
|
|
|
|
// KEEP timeout detection based on last message received:
|
|
private const double MaxIdleSeconds = 30.0; // 2x HTTP/2 keepalive interval
|
|
|
|
private void CheckForIdleTimeout() {
|
|
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
|
|
|
|
if (idleTime > MaxIdleSeconds) {
|
|
_remoteEagleClientLogger.LogLine(
|
|
$"[TIMEOUT] No message for {idleTime:F1}s (max {MaxIdleSeconds}s). " +
|
|
$"HTTP/2 keepalive failed. Reconnecting...");
|
|
|
|
// Force reconnection
|
|
var toCancel = _streamingCall;
|
|
if (toCancel != null) {
|
|
toCancel.Dispose();
|
|
lock (this) { _streamingCall = null; }
|
|
Task.Run(() => Connect());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call CheckForIdleTimeout periodically from a monitoring thread
|
|
private Thread _monitoringThread;
|
|
|
|
private void StartMonitoring() {
|
|
_monitoringThread = new Thread(() => {
|
|
while (!_cancellationToken.IsCancellationRequested) {
|
|
Thread.Sleep(5000); // Check every 5 seconds
|
|
CheckForIdleTimeout();
|
|
}
|
|
});
|
|
_monitoringThread.IsBackground = true;
|
|
_monitoringThread.Start();
|
|
}
|
|
```
|
|
|
|
**Server-Side Enhancement:**
|
|
|
|
File: `src/main/scala/net/eagle0/eagle/Main.scala`
|
|
|
|
Enable gRPC keepalive enforcement:
|
|
|
|
```scala
|
|
val serverBuilder = ServerBuilder.forPort(grpcPort)
|
|
.keepAliveTime(15, TimeUnit.SECONDS) // Send PING every 15s if idle
|
|
.keepAliveTimeout(10, TimeUnit.SECONDS) // Close if no PONG in 10s
|
|
.permitKeepAliveTime(10, TimeUnit.SECONDS) // Allow client PINGs every 10s
|
|
.permitKeepAliveWithoutCalls(true) // Allow PINGs even when no active calls
|
|
|
|
serverBuilder.addService(...)
|
|
```
|
|
|
|
**Benefits:**
|
|
- Simpler code (remove ~100 lines of heartbeat logic)
|
|
- Single source of truth for connection liveness
|
|
- Faster failure detection (15s vs 20s)
|
|
- Lower message overhead (HTTP/2 PING is 8 bytes vs heartbeat protobuf)
|
|
|
|
**Testing:**
|
|
- Verify HTTP/2 PING frames sent every 15s (Wireshark)
|
|
- Kill network connection, verify reconnect within 30s
|
|
- Test with multiple simultaneous games
|
|
|
|
**Success Metrics:**
|
|
- Same or better connection stability than before
|
|
- Reduced message count in logs
|
|
- Faster detection of dead connections
|
|
|
|
---
|
|
|
|
### 3.2 Circuit Breaker Pattern for Connection Failures
|
|
|
|
**Problem:** Repeated connection attempts to failed server waste resources and delay user feedback
|
|
|
|
**Solution:** Implement circuit breaker pattern - "fail fast" after repeated failures
|
|
|
|
**States:**
|
|
1. **CLOSED** - Normal operation, attempts connections
|
|
2. **OPEN** - Too many failures, reject attempts immediately for timeout period
|
|
3. **HALF_OPEN** - After timeout, allow one test attempt
|
|
|
|
**File:** `Assets/Eagle/ConnectionCircuitBreaker.cs` (new)
|
|
|
|
```csharp
|
|
public class ConnectionCircuitBreaker {
|
|
public enum State { Closed, Open, HalfOpen }
|
|
|
|
private State _state = State.Closed;
|
|
private int _failureCount = 0;
|
|
private DateTime? _openedAt = null;
|
|
|
|
private const int FailureThreshold = 5; // Open after 5 failures
|
|
private const double OpenDurationSeconds = 60; // Stay open for 60s
|
|
private const double HalfOpenTestTimeout = 10; // Test attempt timeout
|
|
|
|
public State CurrentState => _state;
|
|
|
|
public bool ShouldAttemptConnection() {
|
|
lock (this) {
|
|
switch (_state) {
|
|
case State.Closed:
|
|
return true; // Normal operation
|
|
|
|
case State.Open:
|
|
// Check if timeout expired
|
|
if (_openedAt.HasValue &&
|
|
(DateTime.UtcNow - _openedAt.Value).TotalSeconds > OpenDurationSeconds) {
|
|
_state = State.HalfOpen;
|
|
Logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (timeout expired, testing)");
|
|
return true; // Allow test attempt
|
|
}
|
|
Logger.LogLine("[CIRCUIT] OPEN - rejecting connection attempt");
|
|
return false; // Still in timeout
|
|
|
|
case State.HalfOpen:
|
|
return true; // Test attempt
|
|
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void RecordSuccess() {
|
|
lock (this) {
|
|
if (_state != State.Closed) {
|
|
Logger.LogLine($"[CIRCUIT] {_state} → CLOSED (connection succeeded)");
|
|
}
|
|
_state = State.Closed;
|
|
_failureCount = 0;
|
|
_openedAt = null;
|
|
}
|
|
}
|
|
|
|
public void RecordFailure() {
|
|
lock (this) {
|
|
_failureCount++;
|
|
|
|
if (_state == State.HalfOpen) {
|
|
// Test attempt failed, reopen circuit
|
|
_state = State.Open;
|
|
_openedAt = DateTime.UtcNow;
|
|
Logger.LogLine($"[CIRCUIT] HALF_OPEN → OPEN (test failed)");
|
|
} else if (_failureCount >= FailureThreshold && _state == State.Closed) {
|
|
_state = State.Open;
|
|
_openedAt = DateTime.UtcNow;
|
|
Logger.LogLine($"[CIRCUIT] CLOSED → OPEN (failures={_failureCount})");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Integration with PersistentClientConnection:**
|
|
|
|
```csharp
|
|
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
|
|
|
|
public async Task Connect() {
|
|
if (!_circuitBreaker.ShouldAttemptConnection()) {
|
|
_remoteEagleClientLogger.LogLine(
|
|
"[CIRCUIT] Connection attempt blocked by circuit breaker");
|
|
return; // Don't attempt connection
|
|
}
|
|
|
|
// ... existing backoff logic ...
|
|
|
|
try {
|
|
// ... existing connection logic ...
|
|
|
|
_circuitBreaker.RecordSuccess();
|
|
} catch (Exception e) {
|
|
_circuitBreaker.RecordFailure();
|
|
throw;
|
|
}
|
|
}
|
|
```
|
|
|
|
**User Feedback:**
|
|
|
|
Show circuit breaker state in UI:
|
|
|
|
```csharp
|
|
// In EagleGameController or ConnectionUI
|
|
if (circuitBreaker.CurrentState == ConnectionCircuitBreaker.State.Open) {
|
|
ShowBanner("Server unavailable. Retrying in 60 seconds...", Color.red);
|
|
} else if (circuitBreaker.CurrentState == ConnectionCircuitBreaker.State.HalfOpen) {
|
|
ShowBanner("Testing connection...", Color.yellow);
|
|
}
|
|
```
|
|
|
|
**Testing:**
|
|
- Kill server, verify circuit opens after 5 failures
|
|
- Verify no connection attempts during 60s timeout
|
|
- Restart server during timeout, verify test attempt after 60s
|
|
- Verify circuit closes on successful test
|
|
|
|
**Success Metrics:**
|
|
- No wasted connection attempts during server outages
|
|
- Users see clear "server down" message
|
|
- Connection restored within 70s of server recovery (60s timeout + 10s test)
|
|
|
|
---
|
|
|
|
### 3.3 Server-Side Connection Tracking & Metrics
|
|
|
|
**Problem:** No visibility into server-side connection health and patterns
|
|
|
|
**Solution:** Track connections per user and expose metrics endpoint
|
|
|
|
**File:** `src/main/scala/net/eagle0/eagle/service/ConnectionTracker.scala` (new)
|
|
|
|
```scala
|
|
package net.eagle0.eagle.service
|
|
|
|
import java.time.Instant
|
|
import scala.collection.concurrent.TrieMap
|
|
|
|
case class ConnectionInfo(
|
|
userName: String,
|
|
connectedAt: Instant,
|
|
var lastActivity: Instant,
|
|
var messageCount: Long = 0,
|
|
var activeGameIds: Set[Long] = Set.empty,
|
|
var activeShardokGames: Int = 0
|
|
)
|
|
|
|
object ConnectionTracker {
|
|
private val connections = new TrieMap[String, ConnectionInfo]()
|
|
|
|
def recordConnection(userName: String): Unit = {
|
|
val now = Instant.now()
|
|
connections.put(userName, ConnectionInfo(userName, now, now))
|
|
println(s"[CONNECTION] User $userName connected. Total active: ${connections.size}")
|
|
}
|
|
|
|
def recordDisconnection(userName: String): Unit = {
|
|
connections.remove(userName) match {
|
|
case Some(info) =>
|
|
val duration = java.time.Duration.between(info.connectedAt, Instant.now())
|
|
println(s"[CONNECTION] User $userName disconnected after ${duration.toMinutes}min. " +
|
|
s"Messages: ${info.messageCount}")
|
|
case None =>
|
|
println(s"[CONNECTION] User $userName disconnected (no info)")
|
|
}
|
|
println(s"[CONNECTION] Total active: ${connections.size}")
|
|
}
|
|
|
|
def recordActivity(userName: String, gameId: Option[Long] = None, shardokGames: Int = 0): Unit = {
|
|
connections.get(userName).foreach { info =>
|
|
info.lastActivity = Instant.now()
|
|
info.messageCount += 1
|
|
gameId.foreach(id => info.activeGameIds += id)
|
|
if (shardokGames > 0) info.activeShardokGames = shardokGames
|
|
}
|
|
}
|
|
|
|
def getMetrics: Map[String, Any] = Map(
|
|
"total_connections" -> connections.size,
|
|
"connections_by_user" -> connections.map { case (user, info) =>
|
|
Map(
|
|
"user" -> user,
|
|
"connected_minutes" -> java.time.Duration.between(info.connectedAt, Instant.now()).toMinutes,
|
|
"idle_seconds" -> java.time.Duration.between(info.lastActivity, Instant.now()).getSeconds,
|
|
"message_count" -> info.messageCount,
|
|
"active_games" -> info.activeGameIds.size,
|
|
"shardok_games" -> info.activeShardokGames
|
|
)
|
|
}.toList
|
|
)
|
|
|
|
// Clean up stale connections (no activity for 10 minutes)
|
|
def cleanupStale(): Unit = {
|
|
val now = Instant.now()
|
|
val staleThreshold = java.time.Duration.ofMinutes(10)
|
|
|
|
connections.foreach { case (user, info) =>
|
|
if (java.time.Duration.between(info.lastActivity, now).compareTo(staleThreshold) > 0) {
|
|
println(s"[CONNECTION] Removing stale connection for $user")
|
|
connections.remove(user)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Integration with EagleServiceImpl:**
|
|
|
|
```scala
|
|
private class SyncStreamObserver(responseObserver: SyncResponseObserver)
|
|
extends StreamObserver[UpdateStreamRequest] {
|
|
|
|
private val userName = AuthorizationUtils.userName
|
|
ConnectionTracker.recordConnection(userName)
|
|
|
|
override def onNext(value: UpdateStreamRequest): Unit = try {
|
|
value.requestDetails match {
|
|
case UpdateStreamRequest.RequestDetails.StreamGameRequest(req) =>
|
|
ConnectionTracker.recordActivity(
|
|
userName,
|
|
Some(req.gameId),
|
|
req.shardokViewStatuses.size
|
|
)
|
|
streamOneUpdate(req, responseObserver)
|
|
|
|
// ... other cases ...
|
|
}
|
|
} catch { /* ... */ }
|
|
|
|
override def onError(t: Throwable): Unit = {
|
|
ConnectionTracker.recordDisconnection(userName)
|
|
// ... existing error handling ...
|
|
}
|
|
|
|
override def onCompleted(): Unit = {
|
|
ConnectionTracker.recordDisconnection(userName)
|
|
// ... existing completion handling ...
|
|
}
|
|
}
|
|
```
|
|
|
|
**Metrics Endpoint:**
|
|
|
|
Add to `EagleServiceImpl.scala`:
|
|
|
|
```scala
|
|
// Schedule periodic cleanup
|
|
val cleanupScheduler = Executioncontext.global
|
|
scala.concurrent.Future {
|
|
while (true) {
|
|
Thread.sleep(60000) // Every minute
|
|
ConnectionTracker.cleanupStale()
|
|
}
|
|
}(cleanupScheduler)
|
|
|
|
// Expose metrics (could be via separate admin endpoint or logging)
|
|
def logConnectionMetrics(): Unit = {
|
|
val metrics = ConnectionTracker.getMetrics
|
|
println(s"[METRICS] ${metrics}")
|
|
}
|
|
```
|
|
|
|
**Monitoring Dashboard:**
|
|
|
|
Create simple web endpoint or log-based dashboard:
|
|
|
|
```bash
|
|
# Example: Parse metrics from server logs
|
|
grep "\\[METRICS\\]" server.log | tail -1 | jq '.connections_by_user'
|
|
```
|
|
|
|
**Testing:**
|
|
- Connect with multiple users
|
|
- Verify metrics show all connections
|
|
- Disconnect users, verify metrics update
|
|
- Let connections go idle, verify stale cleanup
|
|
|
|
**Success Metrics:**
|
|
- Can see all active connections in real-time
|
|
- Can identify users with connection problems
|
|
- Can correlate server-side and client-side connection events
|
|
|
|
---
|
|
|
|
## Priority 4: Long-Term Enhancements (Week 5+)
|
|
|
|
### 4.1 Adaptive Connection Parameters
|
|
|
|
**Problem:** Fixed timeouts/keepalives don't adapt to varying network conditions
|
|
|
|
**Solution:** Dynamically adjust based on observed latency and packet loss
|
|
|
|
**File:** `Assets/Eagle/AdaptiveConnectionManager.cs` (new)
|
|
|
|
```csharp
|
|
public class AdaptiveConnectionManager {
|
|
private ConnectionHealthMonitor _healthMonitor;
|
|
|
|
// Adaptive parameters
|
|
public double CurrentKeepAliveSeconds { get; private set; } = 15.0;
|
|
public double CurrentTimeoutSeconds { get; private set; } = 300.0;
|
|
|
|
// Ranges
|
|
private const double MinKeepAlive = 10.0;
|
|
private const double MaxKeepAlive = 60.0;
|
|
private const double MinTimeout = 120.0;
|
|
private const double MaxTimeout = 600.0;
|
|
|
|
public void UpdateParameters() {
|
|
var health = _healthMonitor.CurrentStatus;
|
|
var latency = _healthMonitor.AverageLatencyMs;
|
|
|
|
// Adapt keepalive based on connection quality
|
|
switch (health) {
|
|
case ConnectionHealthMonitor.HealthStatus.Excellent:
|
|
// Stable connection, can use longer keepalive
|
|
CurrentKeepAliveSeconds = Math.Min(CurrentKeepAliveSeconds * 1.2, MaxKeepAlive);
|
|
break;
|
|
|
|
case ConnectionHealthMonitor.HealthStatus.Poor:
|
|
// Unstable connection, use aggressive keepalive
|
|
CurrentKeepAliveSeconds = Math.Max(CurrentKeepAliveSeconds * 0.8, MinKeepAlive);
|
|
break;
|
|
}
|
|
|
|
// Adapt timeout based on latency
|
|
// Timeout should be at least 10x average latency
|
|
var minTimeoutForLatency = Math.Max(10 * latency / 1000.0, MinTimeout);
|
|
CurrentTimeoutSeconds = Math.Clamp(minTimeoutForLatency, MinTimeout, MaxTimeout);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Implementation Notes:**
|
|
- Would require gRPC channel to be recreated when parameters change
|
|
- Complex to implement correctly
|
|
- Marginal benefit over fixed values
|
|
- **Recommendation:** Defer unless data shows need
|
|
|
|
---
|
|
|
|
### 4.2 WebSocket Fallback Option
|
|
|
|
**Problem:** Some networks/proxies may have issues with gRPC/HTTP2
|
|
|
|
**Solution:** Implement WebSocket transport as fallback
|
|
|
|
**Complexity:** HIGH - requires significant protocol changes
|
|
|
|
**Benefits:**
|
|
- Better compatibility with restrictive networks
|
|
- May work through more proxies/firewalls
|
|
|
|
**Drawbacks:**
|
|
- Duplicates transport layer code
|
|
- Harder to maintain two implementations
|
|
- Most modern networks support HTTP/2
|
|
|
|
**Recommendation:** Only implement if significant user segment cannot connect via gRPC
|
|
|
|
---
|
|
|
|
### 4.3 Client-Side Prediction for Shardok
|
|
|
|
**Problem:** High latency makes Shardok gameplay feel sluggish
|
|
|
|
**Solution:** Predict action results locally, reconcile with server
|
|
|
|
**Implementation Overview:**
|
|
1. Client sends command to server
|
|
2. Client immediately predicts result and updates UI
|
|
3. Server processes command and sends actual result
|
|
4. Client reconciles: if different, apply correction
|
|
|
|
**Complexity:** VERY HIGH - requires deterministic simulation on client
|
|
|
|
**Benefits:**
|
|
- Responsive gameplay even with 300ms+ latency
|
|
- Better user experience for remote players
|
|
|
|
**Drawbacks:**
|
|
- Complex reconciliation logic
|
|
- Potential for "rollback" visual artifacts
|
|
- Requires shipping Shardok simulation to client
|
|
|
|
**Recommendation:** Consider for future major release if player base grows internationally
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Automated Testing
|
|
|
|
1. **Unit Tests:**
|
|
- Connection state machine transitions
|
|
- Backoff calculation
|
|
- Circuit breaker state transitions
|
|
- Metrics calculations
|
|
|
|
2. **Integration Tests:**
|
|
- Full connect/disconnect/reconnect cycles
|
|
- State resync after forced disconnect
|
|
- Multiple simultaneous Shardok games
|
|
|
|
3. **Load Testing:**
|
|
- 100+ concurrent connections
|
|
- Rapid connect/disconnect cycles
|
|
- Large message bursts during Shardok
|
|
|
|
### Manual Testing Scenarios
|
|
|
|
1. **Network Simulation:**
|
|
- Use `tc` (Linux traffic control) or Charles Proxy
|
|
- Test with 50ms, 200ms, 500ms latency
|
|
- Test with 1%, 5%, 10% packet loss
|
|
- Test with bandwidth limits (1Mbps, 512Kbps)
|
|
|
|
2. **Failure Injection:**
|
|
- Kill server during Eagle turn
|
|
- Kill server during Shardok combat
|
|
- Kill server during AI processing
|
|
- Restart server multiple times rapidly
|
|
- Disconnect client network for 30s, 60s, 120s
|
|
|
|
3. **Remote Player Simulation:**
|
|
- Test from actual remote network (not local)
|
|
- Test through VPN
|
|
- Test from mobile hotspot
|
|
- Test from coffee shop WiFi
|
|
|
|
### Success Criteria
|
|
|
|
| Metric | Current (Baseline) | Target (After Implementation) |
|
|
|--------|-------------------|-------------------------------|
|
|
| Connection uptime during 1hr session | Unknown | >99% |
|
|
| Time to detect disconnect | 20s | <30s (with HTTP/2 keepalive) |
|
|
| Time to reconnect after server restart | Variable | <70s (60s circuit + 10s test) |
|
|
| State desyncs after reconnect | Unknown | 0% |
|
|
| Connection drops during 30min idle Shardok | Unknown | 0% |
|
|
| User-visible connection quality indicator | No | Yes |
|
|
| Server-side connection visibility | No | Yes (metrics dashboard) |
|
|
|
|
---
|
|
|
|
## Implementation Timeline
|
|
|
|
### Week 1: Critical Fixes & Diagnostics
|
|
- **Day 1-2:** Fix Shardok security vulnerability, deploy to production
|
|
- **Day 3-4:** Implement comprehensive connection logging
|
|
- **Day 5:** Reduce HTTP/2 keepalive to 15s, monitor results
|
|
|
|
**Deliverable:** Security fix deployed, detailed logs capturing all disconnects
|
|
|
|
### Week 2: State Consistency & Recovery
|
|
- **Day 1-2:** Implement Shardok state resync mechanism (client + server)
|
|
- **Day 3:** Implement exponential backoff
|
|
- **Day 4-5:** Implement connection health monitoring and UI indicators
|
|
|
|
**Deliverable:** Zero state desyncs after reconnection, visible connection quality
|
|
|
|
### Week 3-4: Architectural Improvements
|
|
- **Day 1-2:** Consolidate heartbeat mechanisms (remove application-level)
|
|
- **Day 3:** Implement circuit breaker pattern
|
|
- **Day 4-5:** Server-side connection tracking and metrics
|
|
|
|
**Deliverable:** Simplified codebase, circuit breaker prevents wasted retries
|
|
|
|
### Week 5+: Long-Term Enhancements
|
|
- **As needed:** Adaptive connection parameters
|
|
- **Future release:** WebSocket fallback
|
|
- **Future release:** Client-side prediction
|
|
|
|
**Deliverable:** Production-ready resilient connection system
|
|
|
|
---
|
|
|
|
## Monitoring & Observability
|
|
|
|
### Key Metrics to Track
|
|
|
|
1. **Connection Stability:**
|
|
- Mean time between disconnects (MTBD)
|
|
- Connection success rate on first attempt
|
|
- Average connection duration
|
|
|
|
2. **Network Quality:**
|
|
- Average latency (p50, p95, p99)
|
|
- Packet loss percentage
|
|
- Jitter (latency variance)
|
|
|
|
3. **Failure Recovery:**
|
|
- Time to detect disconnect
|
|
- Time to reconnect
|
|
- Circuit breaker activation frequency
|
|
|
|
4. **State Consistency:**
|
|
- Resync request frequency
|
|
- Resync success rate
|
|
- Average resync duration
|
|
|
|
5. **User Experience:**
|
|
- Gameplay sessions interrupted by disconnects
|
|
- User complaints about connection
|
|
- Average connection health status
|
|
|
|
### Logging Strategy
|
|
|
|
**Structured Logging Format:**
|
|
|
|
```
|
|
[TIMESTAMP] [LEVEL] [COMPONENT] [EVENT] key1=value1 key2=value2 ...
|
|
```
|
|
|
|
**Example:**
|
|
```
|
|
[2024-11-30 14:23:15.123] [INFO] [CONNECT] SUCCESS duration=2.3s attempts=1 backoff=0s
|
|
[2024-11-30 14:25:30.456] [WARN] [DISCONNECT] DETECTED reason=StatusCode.Cancelled duration=135.3s idle=45.2s shardok=true
|
|
[2024-11-30 14:25:32.789] [INFO] [BACKOFF] WAITING delay=1.2s failures=1
|
|
[2024-11-30 14:25:34.012] [INFO] [RESYNC] REQUESTED game=shardok_12345
|
|
```
|
|
|
|
**Log Aggregation:**
|
|
|
|
Use ELK stack or similar:
|
|
- **Elasticsearch:** Store all logs
|
|
- **Logstash:** Parse and index
|
|
- **Kibana:** Visualize and alert
|
|
|
|
**Alerts:**
|
|
|
|
1. **Critical:** Circuit breaker opened (server down)
|
|
2. **Warning:** Connection health drops below "Good" for >5 minutes
|
|
3. **Warning:** >10% of connection attempts fail
|
|
4. **Info:** Resync requested (possible state issue)
|
|
|
|
---
|
|
|
|
## Rollback Plan
|
|
|
|
Each change should be independently deployable and revertible:
|
|
|
|
1. **Feature Flags:**
|
|
```csharp
|
|
public static class FeatureFlags {
|
|
public static bool UseAggressiveKeepAlive => GetFlag("aggressive_keepalive", true);
|
|
public static bool UseCircuitBreaker => GetFlag("circuit_breaker", true);
|
|
public static bool UseShardokResync => GetFlag("shardok_resync", true);
|
|
}
|
|
```
|
|
|
|
2. **Gradual Rollout:**
|
|
- Deploy to 10% of users first
|
|
- Monitor metrics for 24 hours
|
|
- If stable, roll out to 50%, then 100%
|
|
|
|
3. **Quick Revert:**
|
|
- Keep previous nginx config backed up
|
|
- Feature flags can disable changes without redeployment
|
|
- Database/state changes are additive only (no breaking schema changes)
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
### Authentication & Authorization
|
|
|
|
1. **Shardok Internal Interface:**
|
|
- MUST remove public access or add authentication
|
|
- Consider mutual TLS for internal Eagle ↔ Shardok communication
|
|
|
|
2. **Rate Limiting:**
|
|
```nginx
|
|
# Per-user rate limits
|
|
limit_req_zone $remote_user zone=per_user:10m rate=100r/s;
|
|
limit_req zone=per_user burst=20 nodelay;
|
|
|
|
# Global rate limit
|
|
limit_req_zone $server_name zone=global:10m rate=1000r/s;
|
|
limit_req zone=global burst=100 nodelay;
|
|
```
|
|
|
|
3. **DDoS Protection:**
|
|
- nginx rate limiting (above)
|
|
- Connection limit per IP: `limit_conn_zone $binary_remote_addr zone=addr:10m; limit_conn addr 5;`
|
|
- Consider Cloudflare or similar CDN/DDoS protection
|
|
|
|
### Input Validation
|
|
|
|
1. **Command Validation:**
|
|
- Server MUST validate all commands from client
|
|
- Check token/state before executing
|
|
- Sanitize all user input
|
|
|
|
2. **State Validation:**
|
|
- Verify `filtered_result_count` is within valid range
|
|
- Reject obviously corrupted state
|
|
|
|
### Audit Logging
|
|
|
|
Log all security-relevant events:
|
|
- Failed authentication attempts
|
|
- Rate limit violations
|
|
- Invalid command attempts
|
|
- Unusual connection patterns
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
### Client-Side
|
|
|
|
1. **Memory Usage:**
|
|
- Connection metrics: ~1KB per connection
|
|
- Log buffering: limit to last 1000 entries, ~100KB
|
|
- State resync: temporary spike during full state load
|
|
|
|
2. **CPU Usage:**
|
|
- Heartbeat consolidation: reduces CPU by ~5-10%
|
|
- Metrics calculation: negligible (<1% CPU)
|
|
- Backoff jitter calculation: negligible
|
|
|
|
3. **Network Bandwidth:**
|
|
- HTTP/2 keepalive at 15s: ~32 bytes/min (negligible)
|
|
- Removing application heartbeat: saves ~40 bytes/min
|
|
- State resync: one-time spike of full game state (~50-200KB)
|
|
|
|
### Server-Side
|
|
|
|
1. **Memory Usage:**
|
|
- Connection tracking: ~2KB per connection
|
|
- 1000 concurrent users = 2MB (negligible)
|
|
|
|
2. **CPU Usage:**
|
|
- Connection cleanup: runs every 60s, <1ms
|
|
- Metrics calculation: on-demand, <10ms
|
|
|
|
3. **Database:**
|
|
- No new database requirements
|
|
- All metrics in-memory only
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
This comprehensive resilience plan addresses all identified connection issues through a prioritized, incremental approach:
|
|
|
|
**Immediate Impact (Week 1):**
|
|
- Security vulnerability fixed
|
|
- Can measure and diagnose connection issues
|
|
- NAT timeout mitigation deployed
|
|
|
|
**Short-Term Gains (Week 2):**
|
|
- Zero state desyncs after reconnection
|
|
- Graceful failure recovery
|
|
- User visibility into connection quality
|
|
|
|
**Long-Term Benefits (Week 3-4):**
|
|
- Simpler, more maintainable codebase
|
|
- Better resource utilization during failures
|
|
- Comprehensive server-side observability
|
|
|
|
**Success will be measured by:**
|
|
- >99% connection uptime during gameplay
|
|
- Zero user-reported state inconsistencies
|
|
- <1 minute to recover from server outages
|
|
- Clear visibility into connection health for both users and operators
|
|
|
|
The plan is designed for incremental deployment, easy rollback, and continuous monitoring to ensure each change improves the system without introducing new issues
|