Remote Control: WebSocket close code 1002 incorrectly treated as permanent — kills transport silently with no recovery
Summary
Remote Control's WebSocket transport permanently dies after receiving close code 1002 (Protocol Error), with no recovery mechanism. The local CLI continues running normally, giving no indication that remote control is dead. All subsequent messages from the mobile client are silently dropped.
This is a code-level bug in the WebSocketTransport class: close code 1002 is hardcoded in the permanent close set L9_ = new Set([1002, 4001, 4003]), causing the client to never reconnect after a transient 1002.
Related: #31853 (same root cause on macOS, this issue adds source-level analysis from binary reverse engineering on Linux)
Environment
- OS: Ubuntu Linux (kernel 6.17.0)
- Claude Code version: 2.1.74
- Network: Stable wired connection
- Platform: CLI in terminal, controlled from iOS mobile app
Root Cause Analysis (from binary reverse engineering)
The permanent close code set
Extracted from the compiled binary at /home/dell/.local/share/claude/versions/2.1.74:
// Timing constants
s8_ = 1000; // message buffer size
e8_ = 1000; // reconnect base delay: 1 second
lX8 = 30000; // reconnect max delay: 30 seconds
H9_ = 600000; // reconnect time budget: 600 seconds (10 minutes)
QX8 = lX8 * 2; // 60 seconds — system sleep detection threshold
// THE BUG: 1002 should NOT be in this set
L9_ = new Set([1002, 4001, 4003]);
The fatal code path
In WebSocketTransport.handleConnectionError():
handleConnectionError(closeCode) {
this.doDisconnect();
// Special case: 4003 gets a second chance if headers changed
if (closeCode === 4003 && this.refreshHeaders) {
let newHeaders = this.refreshHeaders();
if (newHeaders.Authorization !== this.headers.Authorization) {
// Allow reconnect
shouldReconnect = true;
}
}
// BUG: 1002 hits this branch unconditionally — no second chance
if (closeCode != null && L9_.has(closeCode) && !shouldReconnect) {
// State set to "closed" — PERMANENT, no reconnection ever
this.state = "closed";
this.onCloseCallback?.(closeCode);
return; // <-- exits here, skips all reconnection logic
}
// ... exponential backoff reconnection logic (never reached for 1002)
}
Silent message dropping in bridge:repl
Once the transport dies, bridge:repl silently drops all messages with no user-visible indication:
writeMessages(messages) {
if (!transport) {
// Messages silently dropped — no error, no UI notification
debug(`[bridge:repl] Transport not configured, dropping ${messages.length} message(s)`);
return;
}
// ... normal send path
}
The poll loop continues running (ws=null), creating the illusion that remote control is still active.
Observed Behavior
From debug logs (~/.claude/debug/b1793b9b-*.txt):
2026-03-09T18:08:23Z WebSocketTransport: Closed: 1002
2026-03-09T18:08:23Z WebSocketTransport: Disconnected from wss://api.anthropic.com/v1/session_ingress/ws/session_01Fz7X... (code 1002)
2026-03-09T18:08:23Z WebSocketTransport: Permanent close code 1002, not reconnecting
# From this point: 308 messages dropped, 5600+ empty polls
2026-03-09T18:08:24Z [bridge:repl] Transport not configured, dropping 1 message(s)
2026-03-09T18:08:25Z [bridge:repl] Transport not configured, dropping 1 message(s)
... (repeats for every message attempt)
The same pattern was confirmed independently in #31853 on macOS with identical timing.
Why 1002 Should Not Be Permanent
RFC 6455 defines close code 1002 as "Protocol Error", but in practice it is frequently triggered by:
| Cause | Permanent? |
|-------|-----------|
| Reverse proxy/CDN idle timeout | No |
| Load balancer rotation during deployment | No |
| TLS renegotiation failure | No |
| Non-conformant middlebox | No |
| Server-side rate limiting after repeated reconnects | Debatable |
Evidence from #31853 confirms the server itself triggers the 1002 after 3 consecutive 1006→reconnect cycles (~75 minutes), suggesting it's a server-side rate limit or connection limit — not a genuine protocol error that warrants permanent disconnection.
Suggested Fix
Minimal fix (client-side)
Remove 1002 from the permanent close set:
// Before (bug)
L9_ = new Set([1002, 4001, 4003]);
// After (fix)
L9_ = new Set([4001, 4003]);
This allows 1002 to fall through to the existing exponential backoff reconnection logic (up to 10-minute budget with sleep detection reset).
Better fix (defense in depth)
- Remove 1002 from permanent set (as above)
- Add transport recovery in bridge:repl: When WebSocket permanently closes, attempt to re-establish transport instead of silently dropping messages forever
- Surface dead transport state to UI: Show a warning when transport enters the "not configured" state so users know remote control is broken
- Server-side: Investigate why the WebSocket gateway issues 1002 after 3 reconnects within ~75 minutes — if this is intentional rate limiting, use a custom close code (4xxx range) with a
Retry-Aftersemantic
Impact
- Remote control becomes permanently unusable after ~75 minutes of operation
- No user-visible indication of failure — CLI appears to work normally
- Only workaround is manually toggling
/remote-controloff and on - Affects both macOS (#31853) and Linux (this issue)
- Multiple users confirmed in #31853 comments
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗